diff --git a/.github/workflows/js-tests.yml b/.github/workflows/js-tests.yml index c26e4e79fc..d28f6da24f 100644 --- a/.github/workflows/js-tests.yml +++ b/.github/workflows/js-tests.yml @@ -23,7 +23,7 @@ jobs: run: git fetch --depth=1 origin master - name: Setup Node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -73,7 +73,7 @@ jobs: xvfb-run --auto-servernum ./scripts/all-tests.sh - name: Save Job Artifacts - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: Build-Artifacts path: | diff --git a/.github/workflows/migrations-check.yml b/.github/workflows/migrations-check.yml index 18505b8f1b..3929e7f10e 100644 --- a/.github/workflows/migrations-check.yml +++ b/.github/workflows/migrations-check.yml @@ -133,6 +133,6 @@ jobs: steps: - name: Decide whether the needed jobs succeeded or failed # uses: re-actors/alls-green@v1.2.1 - uses: re-actors/alls-green@13b4244b312e8a314951e03958a2f91519a6a3c9 + uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe with: jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/publish-ci-docker-image.yml b/.github/workflows/publish-ci-docker-image.yml index 997c050b73..fd1c2da1d4 100644 --- a/.github/workflows/publish-ci-docker-image.yml +++ b/.github/workflows/publish-ci-docker-image.yml @@ -13,8 +13,17 @@ jobs: - name: Checkout uses: actions/checkout@v2 + # This has to happen after checkout in order for gh to work. + - name: "Cancel scheduled job on forks" + if: github.repository != 'openedx/edx-platform' && github.event_name == 'schedule' + env: + GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}" + run: | + gh run cancel "${{ github.run_id }}" + gh run watch "${{ github.run_id }}" + - name: Configure AWS Credentials - uses: aws-actions/configure-aws-credentials@v3 + uses: aws-actions/configure-aws-credentials@v4 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/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 94a1110cff..a67dcf83e3 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -79,6 +79,6 @@ jobs: steps: - name: Decide whether the needed jobs succeeded or failed # uses: re-actors/alls-green@v1.2.1 - uses: re-actors/alls-green@13b4244b312e8a314951e03958a2f91519a6a3c9 + uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe with: jobs: ${{ toJSON(needs) }} diff --git a/.github/workflows/quality-checks.yml b/.github/workflows/quality-checks.yml index 7e441e2dbe..0097feb4cd 100644 --- a/.github/workflows/quality-checks.yml +++ b/.github/workflows/quality-checks.yml @@ -35,7 +35,7 @@ jobs: python-version: ${{ matrix.python-version }} - name: Setup Node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} @@ -73,7 +73,7 @@ jobs: - name: Save Job Artifacts if: always() - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: Build-Artifacts path: | diff --git a/.github/workflows/static-assets-check.yml b/.github/workflows/static-assets-check.yml index bec7349fe3..271b6ada81 100644 --- a/.github/workflows/static-assets-check.yml +++ b/.github/workflows/static-assets-check.yml @@ -32,7 +32,7 @@ jobs: sudo apt-get install libxmlsec1-dev pkg-config - name: Setup Node - uses: actions/setup-node@v2 + uses: actions/setup-node@v3 with: node-version: ${{ matrix.node-version }} diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 50e54ea224..4c95f2510f 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -2,7 +2,6 @@ "lms-1": { "settings": "lms.envs.test", "paths": [ - "lms/djangoapps/badges/", "lms/djangoapps/branding/", "lms/djangoapps/bulk_email/", "lms/djangoapps/bulk_enroll/", @@ -78,7 +77,7 @@ "lms/tests.py" ] }, - "openedx-1": { + "openedx-1-with-lms": { "settings": "lms.envs.test", "paths": [ "openedx/core/djangoapps/ace_common/", @@ -116,7 +115,7 @@ "openedx/core/djangoapps/external_user_ids/" ] }, - "openedx-2": { + "openedx-2-with-lms": { "settings": "lms.envs.test", "paths": [ "openedx/core/djangoapps/geoinfo/", @@ -159,7 +158,7 @@ "openedx/tests/" ] }, - "openedx-3": { + "openedx-1-with-cms": { "settings": "cms.envs.test", "paths": [ "openedx/core/djangoapps/ace_common/", @@ -197,7 +196,7 @@ "openedx/core/djangoapps/external_user_ids/" ] }, - "openedx-4": { + "openedx-2-with-cms": { "settings": "cms.envs.test", "paths": [ "openedx/core/djangoapps/content_tagging/", @@ -258,22 +257,28 @@ "cms/djangoapps/contentstore/" ] }, - "common-1": { + "common-with-lms": { "settings": "lms.envs.test", "paths": [ "common/djangoapps/" ] }, - "common-2": { + "common-with-cms": { "settings": "cms.envs.test", "paths": [ "common/djangoapps/" ] }, - "xmodule-1": { + "xmodule-with-lms": { "settings": "lms.envs.test", "paths": [ "xmodule/" ] + }, + "xmodule-with-cms": { + "settings": "cms.envs.test", + "paths": [ + "xmodule/" + ] } } diff --git a/.github/workflows/unit-tests-gh-hosted.yml b/.github/workflows/unit-tests-gh-hosted.yml index 5062df625c..60dabbbece 100644 --- a/.github/workflows/unit-tests-gh-hosted.yml +++ b/.github/workflows/unit-tests-gh-hosted.yml @@ -26,15 +26,16 @@ jobs: - "lms-4" - "lms-5" - "lms-6" - - "openedx-1" - - "openedx-2" - - "openedx-3" - - "openedx-4" + - "openedx-1-with-lms" + - "openedx-2-with-lms" + - "openedx-1-with-cms" + - "openedx-2-with-cms" - "cms-1" - "cms-2" - - "common-1" - - "common-2" - - "xmodule-1" + - "common-with-lms" + - "common-with-cms" + - "xmodule-with-lms" + - "xmodule-with-cms" name: gh-hosted-python-${{ matrix.python-version }},django-${{ matrix.django-version }},${{ matrix.shard_name }} steps: - uses: actions/checkout@v2 diff --git a/.github/workflows/unit-tests.yml b/.github/workflows/unit-tests.yml index cf20ff1f0e..c3b1086c20 100644 --- a/.github/workflows/unit-tests.yml +++ b/.github/workflows/unit-tests.yml @@ -27,15 +27,16 @@ jobs: - "lms-4" - "lms-5" - "lms-6" - - "openedx-1" - - "openedx-2" - - "openedx-3" - - "openedx-4" + - "openedx-1-with-lms" + - "openedx-2-with-lms" + - "openedx-1-with-cms" + - "openedx-2-with-cms" - "cms-1" - "cms-2" - - "common-1" - - "common-2" - - "xmodule-1" + - "common-with-lms" + - "common-with-cms" + - "xmodule-with-lms" + - "xmodule-with-cms" # We expect Django 4.0 to fail, so don't stop when it fails. continue-on-error: ${{ matrix.django-version == '4.0' }} @@ -89,7 +90,7 @@ jobs: steps: - name: Decide whether the needed jobs succeeded or failed # uses: re-actors/alls-green@v1.2.1 - uses: re-actors/alls-green@13b4244b312e8a314951e03958a2f91519a6a3c9 + uses: re-actors/alls-green@05ac9388f0aebcb5727afa17fcccfecd6f8ec5fe with: jobs: ${{ toJSON(needs) }} @@ -101,7 +102,7 @@ jobs: run: sudo chown runner:runner -R .* - uses: actions/checkout@v3 - name: collect pytest warnings files - uses: actions/download-artifact@v2 + uses: actions/download-artifact@v3 with: name: pytest-warnings-json path: test_root/log diff --git a/.gitignore b/.gitignore index e7f12787f5..1692966900 100644 --- a/.gitignore +++ b/.gitignore @@ -135,6 +135,7 @@ build \#*\# .env/ openedx/core/djangoapps/django_comment_common/comment_client/python +openedx/core/djangoapps/cache_toolbox/__pycache__ autodeploy.properties .ws_migrations_complete dist diff --git a/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py b/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py index 39d64912bf..6c19a89cf3 100644 --- a/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py +++ b/cms/djangoapps/contentstore/management/commands/replace_v1_lib_refs_with_v2_in_courses.py @@ -34,15 +34,19 @@ class Command(BaseCommand): def replace_all_library_source_blocks_ids(self, v1_to_v2_lib_map): """A method to replace 'source_library_id' in all relevant blocks.""" - courses = CourseOverview.get_all_courses() + course_id_strings = list(CourseOverview.get_all_course_keys()) + + # Following is used for debugging and should be removed + for course_id_string in course_id_strings: + log.info(course_id_string) # Use Celery to distribute the workload tasks = group( replace_all_library_source_blocks_ids_for_course.s( - course, + course_id_string, v1_to_v2_lib_map ) - for course in courses + for course_id_string in course_id_strings ) results = tasks.apply_async() @@ -57,8 +61,8 @@ class Command(BaseCommand): def validate(self, v1_to_v2_lib_map): """ Validate that replace_all_library_source_blocks_ids was successful""" - courses = CourseOverview.get_all_courses() - tasks = group(validate_all_library_source_blocks_ids_for_course.s(course, v1_to_v2_lib_map) for course in courses) # lint-amnesty, pylint: disable=line-too-long + course_id_strings = list(CourseOverview.get_all_course_keys()) + tasks = group(validate_all_library_source_blocks_ids_for_course.s(course_id, v1_to_v2_lib_map) for course_id in course_id_strings) # lint-amnesty, pylint: disable=line-too-long results = tasks.apply_async() validation = set() @@ -79,10 +83,16 @@ class Command(BaseCommand): def undo(self, v1_to_v2_lib_map): """ undo the changes made by replace_all_library_source_blocks_ids""" - courses = CourseOverview.get_all_courses() + course_id_strings = list(CourseOverview.get_all_course_keys()) # Use Celery to distribute the workload - tasks = group(undo_all_library_source_blocks_ids_for_course.s(course, v1_to_v2_lib_map) for course in courses) + tasks = group( + undo_all_library_source_blocks_ids_for_course.s( + course_id, + v1_to_v2_lib_map + ) + for course_id in course_id_strings + ) results = tasks.apply_async() for result in results.get(): diff --git a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py index 5abcda673a..aefab2c6b1 100644 --- a/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py +++ b/cms/djangoapps/contentstore/rest_api/v1/serializers/home.py @@ -47,7 +47,9 @@ class CourseHomeSerializer(serializers.Serializer): 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() + taxonomies_enabled = serializers.BooleanField() library_authoring_mfe_url = serializers.CharField() + taxonomy_list_mfe_url = serializers.CharField() optimization_enabled = serializers.BooleanField() redirect_to_library_authoring_mfe = serializers.BooleanField() request_course_creator_url = serializers.CharField() 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 110ee24ba1..99f1b450cc 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 @@ -4,11 +4,15 @@ 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 edx_toggles.toggles.testutils import ( + override_waffle_switch, + override_waffle_flag, +) 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 cms.djangoapps.contentstore.toggles import ENABLE_TAGGING_TAXONOMY_LIST_PAGE from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from xmodule.modulestore.tests.factories import CourseFactory @@ -49,7 +53,9 @@ class HomePageViewTest(CourseTestCase): "in_process_course_actions": [], "libraries": [], "libraries_enabled": True, + "taxonomies_enabled": False, "library_authoring_mfe_url": settings.LIBRARY_AUTHORING_MICROFRONTEND_URL, + "taxonomy_list_mfe_url": None, "optimization_enabled": False, "redirect_to_library_authoring_mfe": False, "request_course_creator_url": "/request_course_creator", @@ -87,3 +93,12 @@ class HomePageViewTest(CourseTestCase): response = self.client.get(self.url) self.assertEqual(len(response.data['courses']), 0) self.assertEqual(response.status_code, status.HTTP_200_OK) + + @override_waffle_flag(ENABLE_TAGGING_TAXONOMY_LIST_PAGE, True) + def test_taxonomy_list_link(self): + response = self.client.get(self.url) + self.assertTrue(response.data['taxonomies_enabled']) + self.assertEqual( + response.data['taxonomy_list_mfe_url'], + f'{settings.COURSE_AUTHORING_MICROFRONTEND_URL}/taxonomy-list' + ) diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index fec46b6b74..8845874261 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -19,6 +19,7 @@ from openedx_events.content_authoring.signals import ( XBLOCK_DUPLICATED, XBLOCK_PUBLISHED, ) +from openedx.core.lib.events import determine_producer_config_for_signal_and_topic from openedx_events.event_bus import get_producer from pytz import UTC @@ -164,6 +165,13 @@ def listen_for_course_catalog_info_changed(sender, signal, **kwargs): """ Publish COURSE_CATALOG_INFO_CHANGED signals onto the event bus. """ + # temporary: defer to EVENT_BUS_PRODUCER_CONFIG if present + producer_config_setting = determine_producer_config_for_signal_and_topic(COURSE_CATALOG_INFO_CHANGED, + 'course-catalog-info-changed') + if producer_config_setting is True: + log.info("Producing course-catalog-info-changed event via config") + return + log.info("Producing course-catalog-info-changed event via manual send") get_producer().send( signal=COURSE_CATALOG_INFO_CHANGED, topic='course-catalog-info-changed', event_key_field='catalog_info.course_key', event_data={'catalog_info': kwargs['catalog_info']}, @@ -176,8 +184,14 @@ def listen_for_xblock_published(sender, signal, **kwargs): """ Publish XBLOCK_PUBLISHED signals onto the event bus. """ + # temporary: defer to EVENT_BUS_PRODUCER_CONFIG if present + topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") + producer_config_setting = determine_producer_config_for_signal_and_topic(XBLOCK_PUBLISHED, topic) + if producer_config_setting is True: + log.info("Producing xblock-published event via config") + return if settings.FEATURES.get("ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS"): - topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") + log.info("Producing xblock-published event via manual send") get_producer().send( signal=XBLOCK_PUBLISHED, topic=topic, event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, @@ -190,8 +204,14 @@ def listen_for_xblock_deleted(sender, signal, **kwargs): """ Publish XBLOCK_DELETED signals onto the event bus. """ + # temporary: defer to EVENT_BUS_PRODUCER_CONFIG if present + topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") + producer_config_setting = determine_producer_config_for_signal_and_topic(XBLOCK_DELETED, topic) + if producer_config_setting is True: + log.info("Producing xblock-deleted event via config") + return if settings.FEATURES.get("ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS"): - topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") + log.info("Producing xblock-deleted event via manual send") get_producer().send( signal=XBLOCK_DELETED, topic=topic, event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, @@ -204,8 +224,14 @@ def listen_for_xblock_duplicated(sender, signal, **kwargs): """ Publish XBLOCK_DUPLICATED signals onto the event bus. """ + # temporary: defer to EVENT_BUS_PRODUCER_CONFIG if present + topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") + producer_config_setting = determine_producer_config_for_signal_and_topic(XBLOCK_DUPLICATED, topic) + if producer_config_setting is True: + log.info("Producing xblock-duplicated event via config") + return if settings.FEATURES.get("ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS"): - topic = getattr(settings, "EVENT_BUS_XBLOCK_LIFECYCLE_TOPIC", "course-authoring-xblock-lifecycle") + log.info("Producing xblock-duplicated event via manual send") get_producer().send( signal=XBLOCK_DUPLICATED, topic=topic, event_key_field='xblock_info.usage_key', event_data={'xblock_info': kwargs['xblock_info']}, diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index b7a249bfa5..434aba419a 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -1006,23 +1006,24 @@ def delete_v1_library(v1_library_key_string): @shared_task(time_limit=30) @set_code_owner_attribute -def validate_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): +def validate_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): """Search a Modulestore for all library source blocks in a course by querying mongo. replace all source_library_ids with the corresponding v2 value from the map """ + course_id = CourseKey.from_string(course_key_string) store = modulestore() - with store.bulk_operations(course.id): + with store.bulk_operations(course_id): visited = [] for branch in [ModuleStoreEnum.BranchName.draft, ModuleStoreEnum.BranchName.published]: blocks = store.get_items( - course.id.for_branch(branch), + course_id.for_branch(branch), settings={'source_library_id': {'$exists': True}} ) for xblock in blocks: if xblock.source_library_id not in v1_to_v2_lib_map.values(): # lint-amnesty, pylint: disable=broad-except raise Exception( - f'{xblock.source_library_id} in {course.id} is not found in mapping. Validation failed' + f'{xblock.source_library_id} in {course_id} is not found in mapping. Validation failed' ) visited.append(xblock.source_library_id) # return sucess @@ -1031,18 +1032,20 @@ def validate_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): @shared_task(time_limit=30) @set_code_owner_attribute -def replace_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return +def replace_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return """Search a Modulestore for all library source blocks in a course by querying mongo. replace all source_library_ids with the corresponding v2 value from the map. This will trigger a publish on the course for every published library source block. """ store = modulestore() - with store.bulk_operations(course.id): + course_id = CourseKey.from_string(course_key_string) + + with store.bulk_operations(course_id): #for branch in [ModuleStoreEnum.BranchName.draft, ModuleStoreEnum.BranchName.published]: draft_blocks, published_blocks = [ store.get_items( - course.id.for_branch(branch), + course_id.for_branch(branch), settings={'source_library_id': {'$exists': True}} ) for branch in [ModuleStoreEnum.BranchName.draft, ModuleStoreEnum.BranchName.published] @@ -1058,7 +1061,7 @@ def replace_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): LOGGER.error( 'Key %s not found in mapping. Skipping block for course %s', str({draft_library_source_block.source_library_id}), - str(course.id) + str(course_id) ) continue @@ -1088,18 +1091,19 @@ def replace_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): @shared_task(time_limit=30) @set_code_owner_attribute -def undo_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return +def undo_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_lib_map): # lint-amnesty, pylint: disable=useless-return """Search a Modulestore for all library source blocks in a course by querying mongo. replace all source_library_ids with the corresponding v1 value from the inverted map. This is exists to undo changes made previously. """ + course_id = CourseKey.from_string(course_key_string) v2_to_v1_lib_map = {v: k for k, v in v1_to_v2_lib_map.items()} store = modulestore() draft_blocks, published_blocks = [ store.get_items( - course.id.for_branch(branch), + course_id.for_branch(branch), settings={'source_library_id': {'$exists': True}} ) for branch in [ModuleStoreEnum.BranchName.draft, ModuleStoreEnum.BranchName.published] @@ -1115,7 +1119,7 @@ def undo_all_library_source_blocks_ids_for_course(course, v1_to_v2_lib_map): # LOGGER.error( 'Key %s not found in mapping. Skipping block for course %s', str({draft_library_source_block.source_library_id}), - str(course.id) + str(course_id) ) continue diff --git a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py index fcba8f6e1b..d9023bfce2 100644 --- a/cms/djangoapps/contentstore/tests/test_course_create_rerun.py +++ b/cms/djangoapps/contentstore/tests/test_course_create_rerun.py @@ -51,7 +51,7 @@ class TestCourseListing(ModuleStoreTestCase): self.factory = RequestFactory() self.global_admin = AdminFactory() self.client = AjaxEnabledTestClient() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.course_create_rerun_url = reverse('course_handler') self.course_start = datetime.datetime.utcnow() self.course_end = self.course_start + datetime.timedelta(days=30) diff --git a/cms/djangoapps/contentstore/tests/test_course_listing.py b/cms/djangoapps/contentstore/tests/test_course_listing.py index 461a1281f5..66c16dc6dd 100644 --- a/cms/djangoapps/contentstore/tests/test_course_listing.py +++ b/cms/djangoapps/contentstore/tests/test_course_listing.py @@ -56,12 +56,12 @@ class TestCourseListing(ModuleStoreTestCase): super().setUp() # create and log in a staff user. # create and log in a non-staff user - self.user = UserFactory() + self.user = UserFactory(password=self.TEST_PASSWORD) self.factory = RequestFactory() self.request = self.factory.get('/course') self.request.user = self.user self.client = AjaxEnabledTestClient() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) def _create_course_with_access_groups(self, course_location, user=None): """ diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 605e0d2133..f19f35efe2 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -1866,10 +1866,10 @@ id=\"course-enrollment-end-time\" value=\"\" placeholder=\"HH:MM\" autocomplete= """ Return the course details page as either global or non-global staff """ - user = UserFactory(is_staff=global_staff) + user = UserFactory(is_staff=global_staff, password=self.TEST_PASSWORD) CourseInstructorRole(self.course.id).add_users(user) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get_html(self.course_details_url) diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index dc77403071..fdab4aef83 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -184,7 +184,7 @@ class InternationalizationTest(ModuleStoreTestCase): self.uname = 'testuser' self.email = 'test+courses@edx.org' - self.password = 'foo' + 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) diff --git a/cms/djangoapps/contentstore/tests/test_users_default_role.py b/cms/djangoapps/contentstore/tests/test_users_default_role.py index 590efc0bff..1abf0a0ec6 100644 --- a/cms/djangoapps/contentstore/tests/test_users_default_role.py +++ b/cms/djangoapps/contentstore/tests/test_users_default_role.py @@ -27,7 +27,7 @@ class TestUsersDefaultRole(ModuleStoreTestCase): # create and log in a staff user. self.user = UserFactory(is_staff=True) self.client = AjaxEnabledTestClient() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # create a course via the view handler to create course self.course_key = self.store.make_course_key('Org_1', 'Course_1', 'Run_1') diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index f212bc39f1..bd2c3dbfd3 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -95,7 +95,7 @@ class AuthTestCase(ContentStoreTestCase): super().setUp() self.email = 'a@b.com' - self.pw = 'xyz' + self.pw = 'password1234' self.username = 'testuser' self.client = AjaxEnabledTestClient() # clear the cache so ratelimiting won't affect these tests diff --git a/cms/djangoapps/contentstore/toggles.py b/cms/djangoapps/contentstore/toggles.py index a2a8055b5a..ac8469678f 100644 --- a/cms/djangoapps/contentstore/toggles.py +++ b/cms/djangoapps/contentstore/toggles.py @@ -537,3 +537,21 @@ def default_enable_flexible_peer_openassessments(course_key): level to opt in/out of rolling forward this feature. """ return DEFAULT_ENABLE_FLEXIBLE_PEER_OPENASSESSMENTS.is_enabled(course_key) + + +# .. toggle_name: new_studio_mfe.use_tagging_taxonomy_list_page +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: This flag enables the use of the taxonomy list page. +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-10-06 +# .. toggle_target_removal_date: TBA +# .. toggle_warning: +ENABLE_TAGGING_TAXONOMY_LIST_PAGE = WaffleFlag('new_studio_mfe.use_tagging_taxonomy_list_page', __name__) + + +def use_tagging_taxonomy_list_page(): + """ + Returns a boolean if taxonomy list page is enabled + """ + return ENABLE_TAGGING_TAXONOMY_LIST_PAGE.is_enabled() diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index 1a4b709622..83c26197a4 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -76,6 +76,7 @@ from cms.djangoapps.contentstore.toggles import ( use_new_video_editor, use_new_video_uploads_page, use_new_custom_pages, + use_tagging_taxonomy_list_page, ) from cms.djangoapps.models.settings.course_grading import CourseGradingModel from xmodule.library_tools import LibraryToolsService @@ -433,6 +434,18 @@ def get_custom_pages_url(course_locator) -> str: return custom_pages_url +def get_taxonomy_list_url(): + """ + Gets course authoring microfrontend URL for taxonomy list page view. + """ + taxonomy_list_url = None + if use_tagging_taxonomy_list_page(): + mfe_base_url = settings.COURSE_AUTHORING_MICROFRONTEND_URL + if mfe_base_url: + taxonomy_list_url = f'{mfe_base_url}/taxonomy-list' + return taxonomy_list_url + + def course_import_olx_validation_is_enabled(): """ Check if course olx validation is enabled on course import. @@ -1514,8 +1527,10 @@ def get_home_context(request): 'archived_courses': archived_courses, 'in_process_course_actions': in_process_course_actions, 'libraries_enabled': LIBRARIES_ENABLED, + 'taxonomies_enabled': use_tagging_taxonomy_list_page(), 'redirect_to_library_authoring_mfe': should_redirect_to_library_authoring_mfe(), 'library_authoring_mfe_url': LIBRARY_AUTHORING_MICROFRONTEND_URL, + 'taxonomy_list_mfe_url': get_taxonomy_list_url(), 'libraries': libraries, 'show_new_library_button': user_can_create_library(user) and not should_redirect_to_library_authoring_mfe(), 'user': user, diff --git a/cms/djangoapps/contentstore/views/tests/test_certificates.py b/cms/djangoapps/contentstore/views/tests/test_certificates.py index 76dfb3be95..7af7a448de 100644 --- a/cms/djangoapps/contentstore/views/tests/test_certificates.py +++ b/cms/djangoapps/contentstore/views/tests/test_certificates.py @@ -249,7 +249,7 @@ class CertificatesListHandlerTestCase( Tests user without write permissions on course should not able to create certificate """ user = UserFactory() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.ajax_post( self._url(), data=CERTIFICATE_JSON @@ -635,7 +635,7 @@ class CertificatesDetailHandlerTestCase( """ self._add_course_certificates(count=2, signatory_count=1, asset_path_format=signatory_path) user = UserFactory() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.delete( self._url(cid=1), content_type="application/json", @@ -653,7 +653,7 @@ class CertificatesDetailHandlerTestCase( user = UserFactory() for role in [CourseInstructorRole, CourseStaffRole]: role(self.course.id).add_users(user) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.delete( self._url(cid=1), content_type="application/json", @@ -681,7 +681,7 @@ class CertificatesDetailHandlerTestCase( user = UserFactory() for role in [CourseInstructorRole, CourseStaffRole]: role(self.course.id).add_users(user) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.put( self._url(cid=1), data=json.dumps(cert_data), @@ -799,7 +799,7 @@ class CertificatesDetailHandlerTestCase( test_url = reverse_course_url('certificate_activation_handler', self.course.id) self._add_course_certificates(count=1, signatory_count=2, asset_path_format=signatory_path) user = UserFactory() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.post( test_url, data=json.dumps({"is_active": activate}), diff --git a/cms/djangoapps/contentstore/views/tests/test_organizations.py b/cms/djangoapps/contentstore/views/tests/test_organizations.py index 6f3b676711..cf3a376f34 100644 --- a/cms/djangoapps/contentstore/views/tests/test_organizations.py +++ b/cms/djangoapps/contentstore/views/tests/test_organizations.py @@ -14,8 +14,9 @@ class TestOrganizationListing(TestCase): """Verify Organization listing behavior.""" def setUp(self): super().setUp() - self.staff = UserFactory(is_staff=True) - self.client.login(username=self.staff.username, password='test') + self.password = "password1234" + self.staff = UserFactory(is_staff=True, password=self.password) + self.client.login(username=self.staff.username, password=self.password) self.org_names_listing_url = reverse('organizations') self.org_short_names = ["alphaX", "betaX", "orgX"] for index, short_name in enumerate(self.org_short_names): diff --git a/cms/djangoapps/maintenance/tests.py b/cms/djangoapps/maintenance/tests.py index dd060c4c01..a487f8e37f 100644 --- a/cms/djangoapps/maintenance/tests.py +++ b/cms/djangoapps/maintenance/tests.py @@ -30,7 +30,7 @@ class TestMaintenanceIndex(ModuleStoreTestCase): def setUp(self): super().setUp() self.user = AdminFactory() - login_success = self.client.login(username=self.user.username, password='test') + login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.assertTrue(login_success) self.view_url = reverse('maintenance:maintenance_index') @@ -56,7 +56,7 @@ class MaintenanceViewTestCase(ModuleStoreTestCase): def setUp(self): super().setUp() self.user = AdminFactory() - login_success = self.client.login(username=self.user.username, password='test') + login_success = self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.assertTrue(login_success) def verify_error_message(self, data, error_message): @@ -110,8 +110,8 @@ class MaintenanceViewAccessTests(MaintenanceViewTestCase): """ Test that all maintenance app views are not accessible to non-global-staff user. """ - user = UserFactory(username='test', email='test@example.com', password='test') - login_success = self.client.login(username=user.username, password='test') + user = UserFactory(username='test', email='test@example.com', password=self.TEST_PASSWORD) + login_success = self.client.login(username=user.username, password=self.TEST_PASSWORD) self.assertTrue(login_success) response = self.client.get(url) @@ -245,13 +245,13 @@ class TestAnnouncementsViews(MaintenanceViewTestCase): self.admin = AdminFactory.create( email='staff@edx.org', username='admin', - password='pass' + password=self.TEST_PASSWORD ) - self.client.login(username=self.admin.username, password='pass') + self.client.login(username=self.admin.username, password=self.TEST_PASSWORD) self.non_staff_user = UserFactory.create( email='test@edx.org', username='test', - password='pass' + password=self.TEST_PASSWORD ) def test_index(self): @@ -301,7 +301,7 @@ class TestAnnouncementsViews(MaintenanceViewTestCase): self.assertEqual(response.status_code, 403) def test_authorization(self): - self.client.login(username=self.non_staff_user, password='pass') + self.client.login(username=self.non_staff_user, password=self.TEST_PASSWORD) announcement = Announcement.objects.create(content="Test Delete") announcement.save() diff --git a/cms/djangoapps/models/settings/course_metadata.py b/cms/djangoapps/models/settings/course_metadata.py index f45130d9fa..e5a0ab6a5b 100644 --- a/cms/djangoapps/models/settings/course_metadata.py +++ b/cms/djangoapps/models/settings/course_metadata.py @@ -126,11 +126,6 @@ class CourseMetadata: exclude_list.append('enable_ccx') exclude_list.append('ccx_connector') - # Do not show "Issue Open Badges" in Studio Advanced Settings - # if the feature is disabled. - if not settings.FEATURES.get('ENABLE_OPENBADGES'): - exclude_list.append('issue_badges') - # If the XBlockStudioConfiguration table is not being used, there is no need to # display the "Allow Unsupported XBlocks" setting. if not XBlockStudioConfigurationFlag.is_enabled(): diff --git a/cms/envs/common.py b/cms/envs/common.py index bcb7665210..eea4dae716 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -121,6 +121,9 @@ from lms.envs.common import ( # Methods to derive settings _make_mako_template_dirs, _make_locale_paths, + + # Password Validator Settings + AUTH_PASSWORD_VALIDATORS ) from path import Path as path from django.urls import reverse_lazy @@ -318,9 +321,6 @@ FEATURES = { # Show video bumper in Studio 'ENABLE_VIDEO_BUMPER': False, - # Show issue open badges in Studio - 'ENABLE_OPENBADGES': False, - # How many seconds to show the bumper again, default is 7 days: 'SHOW_BUMPER_PERIODICITY': 7 * 24 * 3600, @@ -531,6 +531,19 @@ FEATURES = { # .. toggle_creation_date: 2023-03-31 # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/32015 'DISABLE_ADVANCED_SETTINGS': False, + + # .. toggle_name: FEATURES['ENABLE_SEND_XBLOCK_LIFECYCLE_EVENTS_OVER_BUS'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Enables sending xblock lifecycle events over the event bus. Used to create the + # EVENT_BUS_PRODUCER_CONFIG setting + # .. toggle_use_cases: opt_in + # .. toggle_creation_date: 2023-10-10 + # .. toggle_target_removal_date: 2023-10-12 + # .. toggle_warning: The default may be changed in a later release. See + # https://github.com/openedx/openedx-events/issues/265 + # .. toggle_tickets: https://github.com/edx/edx-arch-experiments/issues/381 + 'ENABLE_SEND_XBLOCK_LIFECYCLE_EVENTS_OVER_BUS': False, } # .. toggle_name: ENABLE_COPPA_COMPLIANCE @@ -831,6 +844,7 @@ CSRF_COOKIE_SECURE = False CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = '' CROSS_DOMAIN_CSRF_COOKIE_NAME = '' CSRF_TRUSTED_ORIGINS = [] +CSRF_TRUSTED_ORIGINS_WITH_SCHEME = [] #################### CAPA External Code Evaluation ############################# XQUEUE_WAITTIME_BETWEEN_REQUESTS = 5 # seconds @@ -1085,8 +1099,8 @@ DATABASES = { } DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +# This will be overridden through CMS config DEFAULT_HASHING_ALGORITHM = 'sha1' - #################### Python sandbox ############################################ CODE_JAIL = { @@ -1794,6 +1808,7 @@ INSTALLED_APPS = [ # alternative swagger generator for CMS API 'drf_spectacular', + 'openedx_events', ] @@ -1878,24 +1893,6 @@ EVENT_TRACKING_PROCESSORS = [] EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST = [] #### PASSWORD POLICY SETTINGS ##### -AUTH_PASSWORD_VALIDATORS = [ - { - "NAME": "django.contrib.auth.password_validation.UserAttributeSimilarityValidator", - }, - { - "NAME": "common.djangoapps.util.password_policy_validators.MinimumLengthValidator", - "OPTIONS": { - "min_length": 2 - } - }, - { - "NAME": "common.djangoapps.util.password_policy_validators.MaximumLengthValidator", - "OPTIONS": { - "max_length": 75 - } - }, -] - PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG = { 'ENFORCE_COMPLIANCE_ON_LOGIN': False } @@ -2788,6 +2785,66 @@ SPECTACULAR_SETTINGS = { 'PREPROCESSING_HOOKS': ['cms.lib.spectacular.cms_api_filter'], # restrict spectacular to CMS API endpoints } -#### Event bus publishing #### -## Will be more filled out as part of https://github.com/edx/edx-arch-experiments/issues/381 -EVENT_BUS_PRODUCER_CONFIG = {} + +#### Event bus producing #### +def _should_send_xblock_events(settings): + return settings.FEATURES['ENABLE_SEND_XBLOCK_LIFECYCLE_EVENTS_OVER_BUS'] + +# .. setting_name: EVENT_BUS_PRODUCER_CONFIG +# .. setting_default: all events disabled +# .. setting_description: Dictionary of event_types mapped to dictionaries of topic to topic-related configuration. +# Each topic configuration dictionary contains +# * `enabled`: a toggle denoting whether the event will be published to the topic. These should be annotated +# according to +# https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html +# * `event_key_field` which is a period-delimited string path to event data field to use as event key. +# Note: The topic names should not include environment prefix as it will be dynamically added based on +# EVENT_BUS_TOPIC_PREFIX setting. + +EVENT_BUS_PRODUCER_CONFIG = { + 'org.openedx.content_authoring.course.catalog_info.changed.v1': { + 'course-catalog-info-changed': + {'event_key_field': 'catalog_info.course_key', + # .. toggle_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.content_authoring.course.catalog_info.changed.v1'] + # ['course-catalog-info-changed']['enabled'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: if enabled, will publish COURSE_CATALOG_INFO_CHANGED events to the event bus on + # the course-catalog-info-changed topics + # .. toggle_warning: The default may be changed in a later release. See + # https://github.com/openedx/openedx-events/issues/265 + # .. toggle_use_cases: opt_in + # .. toggle_creation_date: 2023-10-10 + 'enabled': False}, + }, + 'org.openedx.content_authoring.xblock.published.v1': { + 'course-authoring-xblock-lifecycle': + {'event_key_field': 'xblock_info.usage_key', 'enabled': _should_send_xblock_events}, + }, + 'org.openedx.content_authoring.xblock.deleted.v1': { + 'course-authoring-xblock-lifecycle': + {'event_key_field': 'xblock_info.usage_key', 'enabled': _should_send_xblock_events}, + }, + 'org.openedx.content_authoring.xblock.duplicated.v1': { + 'course-authoring-xblock-lifecycle': + {'event_key_field': 'xblock_info.usage_key', 'enabled': _should_send_xblock_events}, + }, + # LMS events. These have to be copied over here because lms.common adds some derived entries as well, + # and the derivation fails if the keys are missing. If we ever remove the import of lms.common, we can remove these. + 'org.openedx.learning.certificate.created.v1': { + 'learning-certificate-lifecycle': + {'event_key_field': 'certificate.course.course_key', 'enabled': False}, + }, + 'org.openedx.learning.certificate.revoked.v1': { + 'learning-certificate-lifecycle': + {'event_key_field': 'certificate.course.course_key', 'enabled': False}, + }, +} + + +derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.content_authoring.xblock.published.v1', + 'course-authoring-xblock-lifecycle', 'enabled') +derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.content_authoring.xblock.duplicated.v1', + 'course-authoring-xblock-lifecycle', 'enabled') +derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.content_authoring.xblock.deleted.v1', + 'course-authoring-xblock-lifecycle', 'enabled') diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 61359d0611..29c7770188 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -309,6 +309,7 @@ CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:18150' # .. toggle_creation_date: 2023-02-21 # .. toggle_warning: For consistency in user experience, keep the value in sync with the setting of the same name # in the LMS and CMS. +# This will be deprecated in favor of ENABLE_SEND_XBLOCK_LIFECYCLE_EVENTS_OVER_BUS # .. toggle_tickets: 'https://github.com/openedx/edx-platform/pull/31813' FEATURES['ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS'] = True FEATURES['ENABLE_SEND_ENROLLMENT_EVENTS_OVER_BUS'] = True diff --git a/cms/envs/production.py b/cms/envs/production.py index d04dfcd8ac..7e635c402e 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -14,12 +14,13 @@ import warnings import yaml from corsheaders.defaults import default_headers as corsheaders_default_headers +import django from django.core.exceptions import ImproperlyConfigured from django.urls import reverse_lazy from edx_django_utils.plugins import add_plugins +from openedx_events.event_bus import merge_producer_configs from path import Path as path - from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType from .common import * @@ -84,6 +85,7 @@ with codecs.open(CONFIG_FILE, encoding='utf-8') as f: 'MKTG_URL_LINK_MAP', 'MKTG_URL_OVERRIDES', 'REST_FRAMEWORK', + 'EVENT_BUS_PRODUCER_CONFIG', ] for key in KEYS_WITH_MERGED_VALUES: if key in __config_copy__: @@ -236,6 +238,11 @@ SHARED_COOKIE_DOMAIN = ENV_TOKENS.get('SHARED_COOKIE_DOMAIN', SESSION_COOKIE_DOM # by end users. CSRF_COOKIE_SECURE = ENV_TOKENS.get('CSRF_COOKIE_SECURE', False) +# values are already updated above with default CSRF_TRUSTED_ORIGINS values but in +# case of new django version these values will override. +if django.VERSION[0] >= 4: # for greater than django 3.2 use schemes. + CSRF_TRUSTED_ORIGINS = ENV_TOKENS.get('CSRF_TRUSTED_ORIGINS_WITH_SCHEME', []) + #Email overrides MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {})) MKTG_URL_OVERRIDES.update(ENV_TOKENS.get('MKTG_URL_OVERRIDES', MKTG_URL_OVERRIDES)) @@ -664,3 +671,7 @@ COURSE_LIVE_GLOBAL_CREDENTIALS["BIG_BLUE_BUTTON"] = { } INACTIVE_USER_URL = f'http{"s" if HTTPS == "on" else ""}://{CMS_BASE}' + +############## Event bus producer ############## +EVENT_BUS_PRODUCER_CONFIG = merge_producer_configs(EVENT_BUS_PRODUCER_CONFIG, + ENV_TOKENS.get('EVENT_BUS_PRODUCER_CONFIG', {})) diff --git a/cms/envs/test.py b/cms/envs/test.py index ebaaf09171..e7be26b73c 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -142,8 +142,7 @@ COURSE_AUTHORING_MICROFRONTEND_URL = "http://course-authoring-mfe" DISCUSSIONS_MICROFRONTEND_URL = "http://discussions-mfe" CACHES = { - # This is the cache used for most things. Askbot will not work without a - # functioning cache -- it relies on caching to load its settings in places. + # This is the cache used for most things. # In staging/prod envs, the sessions also live here. 'default': { 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', diff --git a/cms/static/js/i18n/eo/djangojs.js b/cms/static/js/i18n/eo/djangojs.js index 5ef96bc3e6..8b51840c37 100644 --- a/cms/static/js/i18n/eo/djangojs.js +++ b/cms/static/js/i18n/eo/djangojs.js @@ -132,8 +132,6 @@ "Access to some content in this {blockType} is restricted to specific groups of learners.": "\u00c0\u00e7\u00e7\u00e9ss t\u00f6 s\u00f6m\u00e9 \u00e7\u00f6nt\u00e9nt \u00efn th\u00efs {blockType} \u00efs r\u00e9str\u00ef\u00e7t\u00e9d t\u00f6 sp\u00e9\u00e7\u00eff\u00ef\u00e7 gr\u00f6\u00fcps \u00f6f l\u00e9\u00e4rn\u00e9rs. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#", "Access to this unit is restricted to: {selectedGroupsLabel}": "\u00c0\u00e7\u00e7\u00e9ss t\u00f6 th\u00efs \u00fcn\u00eft \u00efs r\u00e9str\u00ef\u00e7t\u00e9d t\u00f6: {selectedGroupsLabel} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #", "Access to this {blockType} is restricted to: {selectedGroupsLabel}": "\u00c0\u00e7\u00e7\u00e9ss t\u00f6 th\u00efs {blockType} \u00efs r\u00e9str\u00ef\u00e7t\u00e9d t\u00f6: {selectedGroupsLabel} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f#", - "Accomplishments": "\u00c0\u00e7\u00e7\u00f6mpl\u00efshm\u00e9nts \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1#", - "Accomplishments Pagination": "\u00c0\u00e7\u00e7\u00f6mpl\u00efshm\u00e9nts P\u00e4g\u00efn\u00e4t\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455#", "According to our records, you are not enrolled in any courses included in your {programName} program subscription. Enroll in a course from the {i_start}Program Details{i_end} page.": "\u00c0\u00e7\u00e7\u00f6rd\u00efng t\u00f6 \u00f6\u00fcr r\u00e9\u00e7\u00f6rds, \u00fd\u00f6\u00fc \u00e4r\u00e9 n\u00f6t \u00e9nr\u00f6ll\u00e9d \u00efn \u00e4n\u00fd \u00e7\u00f6\u00fcrs\u00e9s \u00efn\u00e7l\u00fcd\u00e9d \u00efn \u00fd\u00f6\u00fcr {programName} pr\u00f6gr\u00e4m s\u00fc\u00dfs\u00e7r\u00efpt\u00ef\u00f6n. \u00c9nr\u00f6ll \u00efn \u00e4 \u00e7\u00f6\u00fcrs\u00e9 fr\u00f6m th\u00e9 {i_start}Pr\u00f6gr\u00e4m D\u00e9t\u00e4\u00efls{i_end} p\u00e4g\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 \u00a2\u03c5\u0142\u03c1\u03b1 q\u03c5\u03b9 \u03c3\u0192\u0192\u03b9\u00a2\u03b9\u03b1 \u2202\u0454\u0455\u0454\u044f\u03c5#", "Account": "\u00c0\u00e7\u00e7\u00f6\u00fcnt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c #", "Account Information": "\u00c0\u00e7\u00e7\u00f6\u00fcnt \u00ccnf\u00f6rm\u00e4t\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442,#", @@ -538,7 +536,6 @@ "Create a content group": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4 \u00e7\u00f6nt\u00e9nt gr\u00f6\u00fcp \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#", "Create a new team if you can't find an existing team to join, or if you would like to learn with friends you know.": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4 n\u00e9w t\u00e9\u00e4m \u00eff \u00fd\u00f6\u00fc \u00e7\u00e4n't f\u00efnd \u00e4n \u00e9x\u00efst\u00efng t\u00e9\u00e4m t\u00f6 j\u00f6\u00efn, \u00f6r \u00eff \u00fd\u00f6\u00fc w\u00f6\u00fcld l\u00efk\u00e9 t\u00f6 l\u00e9\u00e4rn w\u00efth fr\u00ef\u00e9nds \u00fd\u00f6\u00fc kn\u00f6w. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142#", "Create a report of problem responses": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4 r\u00e9p\u00f6rt \u00f6f pr\u00f6\u00dfl\u00e9m r\u00e9sp\u00f6ns\u00e9s \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", - "Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4 {link_start}M\u00f6z\u00efll\u00e4 B\u00e4\u00e7kp\u00e4\u00e7k{link_end} \u00e4\u00e7\u00e7\u00f6\u00fcnt, \u00f6r l\u00f6g \u00efn t\u00f6 \u00fd\u00f6\u00fcr \u00e9x\u00efst\u00efng \u00e4\u00e7\u00e7\u00f6\u00fcnt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", "Create account using %(providerName)s.": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4\u00e7\u00e7\u00f6\u00fcnt \u00fcs\u00efng %(providerName)s. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455#", "Create an Account": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4n \u00c0\u00e7\u00e7\u00f6\u00fcnt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454#", "Create an Account.": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4n \u00c0\u00e7\u00e7\u00f6\u00fcnt. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442#", @@ -651,7 +648,6 @@ "Duplicate": "D\u00fcpl\u00ef\u00e7\u00e4t\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142#", "Duplicating": "D\u00fcpl\u00ef\u00e7\u00e4t\u00efng \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #", "Duration (sec)": "D\u00fcr\u00e4t\u00ef\u00f6n (s\u00e9\u00e7) \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442#", - "Earned %(created)s.": "\u00c9\u00e4rn\u00e9d %(created)s. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #", "Earned Certificates": "\u00c9\u00e4rn\u00e9d \u00c7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9s \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442,#", "EdX doesn't have a timed transcript for this video in Studio, but we found a transcript on YouTube. You can import the YouTube transcript or upload your own .srt transcript file.": "\u00c9dX d\u00f6\u00e9sn't h\u00e4v\u00e9 \u00e4 t\u00efm\u00e9d tr\u00e4ns\u00e7r\u00efpt f\u00f6r th\u00efs v\u00efd\u00e9\u00f6 \u00efn St\u00fcd\u00ef\u00f6, \u00df\u00fct w\u00e9 f\u00f6\u00fcnd \u00e4 tr\u00e4ns\u00e7r\u00efpt \u00f6n \u00dd\u00f6\u00fcT\u00fc\u00df\u00e9. \u00dd\u00f6\u00fc \u00e7\u00e4n \u00efmp\u00f6rt th\u00e9 \u00dd\u00f6\u00fcT\u00fc\u00df\u00e9 tr\u00e4ns\u00e7r\u00efpt \u00f6r \u00fcpl\u00f6\u00e4d \u00fd\u00f6\u00fcr \u00f6wn .srt tr\u00e4ns\u00e7r\u00efpt f\u00efl\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 #", "EdX doesn\\'t have a timed transcript for this video. Please upload an .srt file.": "\u00c9dX d\u00f6\u00e9sn\\'t h\u00e4v\u00e9 \u00e4 t\u00efm\u00e9d tr\u00e4ns\u00e7r\u00efpt f\u00f6r th\u00efs v\u00efd\u00e9\u00f6. Pl\u00e9\u00e4s\u00e9 \u00fcpl\u00f6\u00e4d \u00e4n .srt f\u00efl\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#", @@ -823,7 +819,6 @@ "Financial Assistance": "F\u00efn\u00e4n\u00e7\u00ef\u00e4l \u00c0ss\u00efst\u00e4n\u00e7\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, #", "Financial Assistance Application": "F\u00efn\u00e4n\u00e7\u00ef\u00e4l \u00c0ss\u00efst\u00e4n\u00e7\u00e9 \u00c0ppl\u00ef\u00e7\u00e4t\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#", "Find": "F\u00efnd \u2c60'\u03c3\u044f\u0454\u043c \u03b9#", - "Find a course": "F\u00efnd \u00e4 \u00e7\u00f6\u00fcrs\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9#", "Find and replace": "F\u00efnd \u00e4nd r\u00e9pl\u00e4\u00e7\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c#", "Find answers to the top questions asked by learners.": "F\u00efnd \u00e4nsw\u00e9rs t\u00f6 th\u00e9 t\u00f6p q\u00fc\u00e9st\u00ef\u00f6ns \u00e4sk\u00e9d \u00df\u00fd l\u00e9\u00e4rn\u00e9rs. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#", "Find discussions": "F\u00efnd d\u00efs\u00e7\u00fcss\u00ef\u00f6ns \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c#", @@ -1576,10 +1571,7 @@ "Settings": "S\u00e9tt\u00efngs \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202#", "Settings updated": "S\u00e9tt\u00efngs \u00fcpd\u00e4t\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c#", "Setup Started": "S\u00e9t\u00fcp St\u00e4rt\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9#", - "Share": "Sh\u00e4r\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455#", "Share Alike": "Sh\u00e4r\u00e9 \u00c0l\u00efk\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #", - "Share on Mozilla Backpack": "Sh\u00e4r\u00e9 \u00f6n M\u00f6z\u00efll\u00e4 B\u00e4\u00e7kp\u00e4\u00e7k \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455#", - "Share your \"%(display_name)s\" award": "Sh\u00e4r\u00e9 \u00fd\u00f6\u00fcr \"%(display_name)s\" \u00e4w\u00e4rd \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#", "Short explanation": "Sh\u00f6rt \u00e9xpl\u00e4n\u00e4t\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454#", "Show": "Montru", "Show All": "Sh\u00f6w \u00c0ll \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202#", @@ -1666,7 +1658,6 @@ "Start of transcript. Skip to the end.": "St\u00e4rt \u00f6f tr\u00e4ns\u00e7r\u00efpt. Sk\u00efp t\u00f6 th\u00e9 \u00e9nd. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", "Start regenerating certificates for students in this course?": "St\u00e4rt r\u00e9g\u00e9n\u00e9r\u00e4t\u00efng \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9s f\u00f6r st\u00fcd\u00e9nts \u00efn th\u00efs \u00e7\u00f6\u00fcrs\u00e9? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#", "Start search": "St\u00e4rt s\u00e9\u00e4r\u00e7h \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455#", - "Start working toward your next learning goal.": "St\u00e4rt w\u00f6rk\u00efng t\u00f6w\u00e4rd \u00fd\u00f6\u00fcr n\u00e9xt l\u00e9\u00e4rn\u00efng g\u00f6\u00e4l. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #", "Start {trialLength}-day free trial": "St\u00e4rt {trialLength}-d\u00e4\u00fd fr\u00e9\u00e9 tr\u00ef\u00e4l \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7#", "Started": "St\u00e4rt\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c #", "Started entrance exam rescore task for student '{student_id}'. Click the 'Show Task Status' button to see the status of the task.": "St\u00e4rt\u00e9d \u00e9ntr\u00e4n\u00e7\u00e9 \u00e9x\u00e4m r\u00e9s\u00e7\u00f6r\u00e9 t\u00e4sk f\u00f6r st\u00fcd\u00e9nt '{student_id}'. \u00c7l\u00ef\u00e7k th\u00e9 'Sh\u00f6w T\u00e4sk St\u00e4t\u00fcs' \u00df\u00fctt\u00f6n t\u00f6 s\u00e9\u00e9 th\u00e9 st\u00e4t\u00fcs \u00f6f th\u00e9 t\u00e4sk. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5#", @@ -1976,7 +1967,6 @@ "To receive a certificate, you must also verify your identity.": "T\u00f6 r\u00e9\u00e7\u00e9\u00efv\u00e9 \u00e4 \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9, \u00fd\u00f6\u00fc m\u00fcst \u00e4ls\u00f6 v\u00e9r\u00eff\u00fd \u00fd\u00f6\u00fcr \u00efd\u00e9nt\u00eft\u00fd. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#", "To receive credit for problems, you must select \"Submit\" for each problem before you select \"End My Exam\".": "T\u00f6 r\u00e9\u00e7\u00e9\u00efv\u00e9 \u00e7r\u00e9d\u00eft f\u00f6r pr\u00f6\u00dfl\u00e9ms, \u00fd\u00f6\u00fc m\u00fcst s\u00e9l\u00e9\u00e7t \"S\u00fc\u00dfm\u00eft\" f\u00f6r \u00e9\u00e4\u00e7h pr\u00f6\u00dfl\u00e9m \u00df\u00e9f\u00f6r\u00e9 \u00fd\u00f6\u00fc s\u00e9l\u00e9\u00e7t \"\u00c9nd M\u00fd \u00c9x\u00e4m\". \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 #", "To review learner cohort assignments or see the results of uploading a CSV file, download course profile information or cohort results on the {link_start}Data Download{link_end} page.": "T\u00f6 r\u00e9v\u00ef\u00e9w l\u00e9\u00e4rn\u00e9r \u00e7\u00f6h\u00f6rt \u00e4ss\u00efgnm\u00e9nts \u00f6r s\u00e9\u00e9 th\u00e9 r\u00e9s\u00fclts \u00f6f \u00fcpl\u00f6\u00e4d\u00efng \u00e4 \u00c7SV f\u00efl\u00e9, d\u00f6wnl\u00f6\u00e4d \u00e7\u00f6\u00fcrs\u00e9 pr\u00f6f\u00efl\u00e9 \u00efnf\u00f6rm\u00e4t\u00ef\u00f6n \u00f6r \u00e7\u00f6h\u00f6rt r\u00e9s\u00fclts \u00f6n th\u00e9 {link_start}D\u00e4t\u00e4 D\u00f6wnl\u00f6\u00e4d{link_end} p\u00e4g\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 \u00a2\u03c5\u0142\u03c1\u03b1 q\u03c5\u03b9 \u03c3\u0192\u0192\u03b9\u00a2#", - "To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.": "T\u00f6 sh\u00e4r\u00e9 \u00fd\u00f6\u00fcr \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9 \u00f6n M\u00f6z\u00efll\u00e4 B\u00e4\u00e7kp\u00e4\u00e7k, \u00fd\u00f6\u00fc m\u00fcst f\u00efrst h\u00e4v\u00e9 \u00e4 B\u00e4\u00e7kp\u00e4\u00e7k \u00e4\u00e7\u00e7\u00f6\u00fcnt. \u00c7\u00f6mpl\u00e9t\u00e9 th\u00e9 f\u00f6ll\u00f6w\u00efng st\u00e9ps t\u00f6 \u00e4dd \u00fd\u00f6\u00fcr \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9 t\u00f6 B\u00e4\u00e7kp\u00e4\u00e7k. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 \u00a2\u03c5\u0142\u03c1\u03b1 q\u03c5\u03b9 \u03c3\u0192\u0192\u03b9\u00a2\u03b9\u03b1 \u2202\u0454\u0455\u0454\u044f\u03c5\u03b7\u0442 \u043c\u03c3\u0142\u0142\u03b9\u0442#", "To take a successful photo, make sure that:": "T\u00f6 t\u00e4k\u00e9 \u00e4 s\u00fc\u00e7\u00e7\u00e9ssf\u00fcl ph\u00f6t\u00f6, m\u00e4k\u00e9 s\u00fcr\u00e9 th\u00e4t: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #", "To take the photo of your face, click on the camera button {icon}. If you need to try again, click 'Retake Photo'.": "T\u00f6 t\u00e4k\u00e9 th\u00e9 ph\u00f6t\u00f6 \u00f6f \u00fd\u00f6\u00fcr f\u00e4\u00e7\u00e9, \u00e7l\u00ef\u00e7k \u00f6n th\u00e9 \u00e7\u00e4m\u00e9r\u00e4 \u00df\u00fctt\u00f6n {icon}. \u00ccf \u00fd\u00f6\u00fc n\u00e9\u00e9d t\u00f6 tr\u00fd \u00e4g\u00e4\u00efn, \u00e7l\u00ef\u00e7k 'R\u00e9t\u00e4k\u00e9 Ph\u00f6t\u00f6'. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #", "To verify your identity, you need a webcam and a government-issued photo ID.": "T\u00f6 v\u00e9r\u00eff\u00fd \u00fd\u00f6\u00fcr \u00efd\u00e9nt\u00eft\u00fd, \u00fd\u00f6\u00fc n\u00e9\u00e9d \u00e4 w\u00e9\u00df\u00e7\u00e4m \u00e4nd \u00e4 g\u00f6v\u00e9rnm\u00e9nt-\u00efss\u00fc\u00e9d ph\u00f6t\u00f6 \u00ccD. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", @@ -2216,7 +2206,6 @@ "What if I have difficulty holding my ID in position relative to the camera?": "Wh\u00e4t \u00eff \u00cc h\u00e4v\u00e9 d\u00efff\u00ef\u00e7\u00fclt\u00fd h\u00f6ld\u00efng m\u00fd \u00ccD \u00efn p\u00f6s\u00eft\u00ef\u00f6n r\u00e9l\u00e4t\u00efv\u00e9 t\u00f6 th\u00e9 \u00e7\u00e4m\u00e9r\u00e4? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", "What if I have difficulty holding my head in position relative to the camera?": "Wh\u00e4t \u00eff \u00cc h\u00e4v\u00e9 d\u00efff\u00ef\u00e7\u00fclt\u00fd h\u00f6ld\u00efng m\u00fd h\u00e9\u00e4d \u00efn p\u00f6s\u00eft\u00ef\u00f6n r\u00e9l\u00e4t\u00efv\u00e9 t\u00f6 th\u00e9 \u00e7\u00e4m\u00e9r\u00e4? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442#", "What was the total combined income, during the last 12 months, of all members of your family? ": "Wh\u00e4t w\u00e4s th\u00e9 t\u00f6t\u00e4l \u00e7\u00f6m\u00df\u00efn\u00e9d \u00efn\u00e7\u00f6m\u00e9, d\u00fcr\u00efng th\u00e9 l\u00e4st 12 m\u00f6nths, \u00f6f \u00e4ll m\u00e9m\u00df\u00e9rs \u00f6f \u00fd\u00f6\u00fcr f\u00e4m\u00efl\u00fd? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#", - "What's Your Next Accomplishment?": "Wh\u00e4t's \u00dd\u00f6\u00fcr N\u00e9xt \u00c0\u00e7\u00e7\u00f6mpl\u00efshm\u00e9nt? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#", "What's in my clipboard?": "Wh\u00e4t's \u00efn m\u00fd \u00e7l\u00efp\u00df\u00f6\u00e4rd? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3#", "When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.": "Wh\u00e9n l\u00e9\u00e4rn\u00e9rs s\u00fc\u00dfm\u00eft \u00e4n \u00e4nsw\u00e9r t\u00f6 \u00e4n \u00e4ss\u00e9ssm\u00e9nt, th\u00e9\u00fd \u00efmm\u00e9d\u00ef\u00e4t\u00e9l\u00fd s\u00e9\u00e9 wh\u00e9th\u00e9r th\u00e9 \u00e4nsw\u00e9r \u00efs \u00e7\u00f6rr\u00e9\u00e7t \u00f6r \u00efn\u00e7\u00f6rr\u00e9\u00e7t, \u00e4nd th\u00e9 s\u00e7\u00f6r\u00e9 r\u00e9\u00e7\u00e9\u00efv\u00e9d. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 \u00a2\u03c5\u0142\u03c1\u03b1 q\u03c5\u03b9 \u03c3\u0192\u0192\u03b9\u00a2\u03b9\u03b1 \u2202\u0454\u0455\u0454\u044f\u03c5\u03b7\u0442 \u043c\u03c3\u0142\u0142\u03b9\u0442 \u03b1\u03b7\u03b9\u043c \u03b9\u2202 \u0454\u0455\u0442 \u0142\u03b1\u0432\u03c3#", "When your face is in position, use the Take Photo button {icon} below to take your photo.": "Wh\u00e9n \u00fd\u00f6\u00fcr f\u00e4\u00e7\u00e9 \u00efs \u00efn p\u00f6s\u00eft\u00ef\u00f6n, \u00fcs\u00e9 th\u00e9 T\u00e4k\u00e9 Ph\u00f6t\u00f6 \u00df\u00fctt\u00f6n {icon} \u00df\u00e9l\u00f6w t\u00f6 t\u00e4k\u00e9 \u00fd\u00f6\u00fcr ph\u00f6t\u00f6. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2#", @@ -2497,7 +2486,6 @@ "{categoryText} in {parentDisplayname}": "{categoryText} \u00efn {parentDisplayname} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3#", "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} of {maxCharacters}": "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} \u00f6f {maxCharacters} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c#", "{display_name} Settings": "{display_name} S\u00e9tt\u00efngs \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455#", - "{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack.": "{download_link_start}D\u00f6wnl\u00f6\u00e4d th\u00efs \u00efm\u00e4g\u00e9 (r\u00efght-\u00e7l\u00ef\u00e7k \u00f6r \u00f6pt\u00ef\u00f6n-\u00e7l\u00ef\u00e7k, s\u00e4v\u00e9 \u00e4s){link_end} \u00e4nd th\u00e9n {upload_link_start}\u00fcpl\u00f6\u00e4d{link_end} \u00eft t\u00f6 \u00fd\u00f6\u00fcr \u00df\u00e4\u00e7kp\u00e4\u00e7k. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442#", "{earned}/{possible} point (graded)": [ "{earned}/{possible} p\u00f6\u00efnt (gr\u00e4d\u00e9d) \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#", "{earned}/{possible} p\u00f6\u00efnts (gr\u00e4d\u00e9d) \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3#" diff --git a/cms/static/js/i18n/rtl/djangojs.js b/cms/static/js/i18n/rtl/djangojs.js index 6917e187da..b94b5774de 100644 --- a/cms/static/js/i18n/rtl/djangojs.js +++ b/cms/static/js/i18n/rtl/djangojs.js @@ -125,8 +125,6 @@ "Access to some content in this {blockType} is restricted to specific groups of learners.": "\u023a\u0254\u0254\u01ddss \u0287\u00f8 s\u00f8\u026f\u01dd \u0254\u00f8n\u0287\u01ddn\u0287 \u1d09n \u0287\u0265\u1d09s {blockType} \u1d09s \u0279\u01dds\u0287\u0279\u1d09\u0254\u0287\u01ddd \u0287\u00f8 sd\u01dd\u0254\u1d09\u025f\u1d09\u0254 \u0183\u0279\u00f8nds \u00f8\u025f l\u01dd\u0250\u0279n\u01dd\u0279s.", "Access to this unit is restricted to: {selectedGroupsLabel}": "\u023a\u0254\u0254\u01ddss \u0287\u00f8 \u0287\u0265\u1d09s nn\u1d09\u0287 \u1d09s \u0279\u01dds\u0287\u0279\u1d09\u0254\u0287\u01ddd \u0287\u00f8: {selectedGroupsLabel}", "Access to this {blockType} is restricted to: {selectedGroupsLabel}": "\u023a\u0254\u0254\u01ddss \u0287\u00f8 \u0287\u0265\u1d09s {blockType} \u1d09s \u0279\u01dds\u0287\u0279\u1d09\u0254\u0287\u01ddd \u0287\u00f8: {selectedGroupsLabel}", - "Accomplishments": "\u023a\u0254\u0254\u00f8\u026fdl\u1d09s\u0265\u026f\u01ddn\u0287s", - "Accomplishments Pagination": "\u023a\u0254\u0254\u00f8\u026fdl\u1d09s\u0265\u026f\u01ddn\u0287s \u2c63\u0250\u0183\u1d09n\u0250\u0287\u1d09\u00f8n", "According to our records, you are not enrolled in any courses included in your {programName} program subscription. Enroll in a course from the {i_start}Program Details{i_end} page.": "\u023a\u0254\u0254\u00f8\u0279d\u1d09n\u0183 \u0287\u00f8 \u00f8n\u0279 \u0279\u01dd\u0254\u00f8\u0279ds, \u028e\u00f8n \u0250\u0279\u01dd n\u00f8\u0287 \u01ddn\u0279\u00f8ll\u01ddd \u1d09n \u0250n\u028e \u0254\u00f8n\u0279s\u01dds \u1d09n\u0254lnd\u01ddd \u1d09n \u028e\u00f8n\u0279 {programName} d\u0279\u00f8\u0183\u0279\u0250\u026f snbs\u0254\u0279\u1d09d\u0287\u1d09\u00f8n. \u0246n\u0279\u00f8ll \u1d09n \u0250 \u0254\u00f8n\u0279s\u01dd \u025f\u0279\u00f8\u026f \u0287\u0265\u01dd {i_start}\u2c63\u0279\u00f8\u0183\u0279\u0250\u026f \u0110\u01dd\u0287\u0250\u1d09ls{i_end} d\u0250\u0183\u01dd.", "Account": "\u023a\u0254\u0254\u00f8nn\u0287", "Account Information": "\u023a\u0254\u0254\u00f8nn\u0287 \u0197n\u025f\u00f8\u0279\u026f\u0250\u0287\u1d09\u00f8n", @@ -500,7 +498,6 @@ "Create a content group": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250 \u0254\u00f8n\u0287\u01ddn\u0287 \u0183\u0279\u00f8nd", "Create a new team if you can't find an existing team to join, or if you would like to learn with friends you know.": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250 n\u01dd\u028d \u0287\u01dd\u0250\u026f \u1d09\u025f \u028e\u00f8n \u0254\u0250n'\u0287 \u025f\u1d09nd \u0250n \u01ddx\u1d09s\u0287\u1d09n\u0183 \u0287\u01dd\u0250\u026f \u0287\u00f8 \u027e\u00f8\u1d09n, \u00f8\u0279 \u1d09\u025f \u028e\u00f8n \u028d\u00f8nld l\u1d09\u029e\u01dd \u0287\u00f8 l\u01dd\u0250\u0279n \u028d\u1d09\u0287\u0265 \u025f\u0279\u1d09\u01ddnds \u028e\u00f8n \u029en\u00f8\u028d.", "Create a report of problem responses": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250 \u0279\u01ddd\u00f8\u0279\u0287 \u00f8\u025f d\u0279\u00f8bl\u01dd\u026f \u0279\u01ddsd\u00f8ns\u01dds", - "Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250 {link_start}M\u00f8z\u1d09ll\u0250 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e{link_end} \u0250\u0254\u0254\u00f8nn\u0287, \u00f8\u0279 l\u00f8\u0183 \u1d09n \u0287\u00f8 \u028e\u00f8n\u0279 \u01ddx\u1d09s\u0287\u1d09n\u0183 \u0250\u0254\u0254\u00f8nn\u0287", "Create account using %(providerName)s.": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250\u0254\u0254\u00f8nn\u0287 ns\u1d09n\u0183 %(providerName)s.", "Create an Account": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250n \u023a\u0254\u0254\u00f8nn\u0287", "Create an Account.": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250n \u023a\u0254\u0254\u00f8nn\u0287.", @@ -604,7 +601,6 @@ "Duplicate": "\u0110ndl\u1d09\u0254\u0250\u0287\u01dd", "Duplicating": "\u0110ndl\u1d09\u0254\u0250\u0287\u1d09n\u0183", "Duration (sec)": "\u0110n\u0279\u0250\u0287\u1d09\u00f8n (s\u01dd\u0254)", - "Earned %(created)s.": "\u0246\u0250\u0279n\u01ddd %(created)s.", "Earned Certificates": "\u0246\u0250\u0279n\u01ddd \u023b\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dds", "EdX doesn't have a timed transcript for this video in Studio, but we found a transcript on YouTube. You can import the YouTube transcript or upload your own .srt transcript file.": "\u0246dX d\u00f8\u01ddsn'\u0287 \u0265\u0250\u028c\u01dd \u0250 \u0287\u1d09\u026f\u01ddd \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u025f\u00f8\u0279 \u0287\u0265\u1d09s \u028c\u1d09d\u01dd\u00f8 \u1d09n S\u0287nd\u1d09\u00f8, bn\u0287 \u028d\u01dd \u025f\u00f8nnd \u0250 \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u00f8n \u024e\u00f8n\u0166nb\u01dd. \u024e\u00f8n \u0254\u0250n \u1d09\u026fd\u00f8\u0279\u0287 \u0287\u0265\u01dd \u024e\u00f8n\u0166nb\u01dd \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u00f8\u0279 ndl\u00f8\u0250d \u028e\u00f8n\u0279 \u00f8\u028dn .s\u0279\u0287 \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u025f\u1d09l\u01dd.", "EdX doesn\\'t have a timed transcript for this video. Please upload an .srt file.": "\u0246dX d\u00f8\u01ddsn\\'\u0287 \u0265\u0250\u028c\u01dd \u0250 \u0287\u1d09\u026f\u01ddd \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u025f\u00f8\u0279 \u0287\u0265\u1d09s \u028c\u1d09d\u01dd\u00f8. \u2c63l\u01dd\u0250s\u01dd ndl\u00f8\u0250d \u0250n .s\u0279\u0287 \u025f\u1d09l\u01dd.", @@ -761,7 +757,6 @@ "Financial Assistance": "F\u1d09n\u0250n\u0254\u1d09\u0250l \u023ass\u1d09s\u0287\u0250n\u0254\u01dd", "Financial Assistance Application": "F\u1d09n\u0250n\u0254\u1d09\u0250l \u023ass\u1d09s\u0287\u0250n\u0254\u01dd \u023addl\u1d09\u0254\u0250\u0287\u1d09\u00f8n", "Find": "F\u1d09nd", - "Find a course": "F\u1d09nd \u0250 \u0254\u00f8n\u0279s\u01dd", "Find and replace": "F\u1d09nd \u0250nd \u0279\u01dddl\u0250\u0254\u01dd", "Find answers to the top questions asked by learners.": "F\u1d09nd \u0250ns\u028d\u01dd\u0279s \u0287\u00f8 \u0287\u0265\u01dd \u0287\u00f8d bn\u01dds\u0287\u1d09\u00f8ns \u0250s\u029e\u01ddd b\u028e l\u01dd\u0250\u0279n\u01dd\u0279s.", "Find discussions": "F\u1d09nd d\u1d09s\u0254nss\u1d09\u00f8ns", @@ -1449,10 +1444,7 @@ "Set up your certificate": "S\u01dd\u0287 nd \u028e\u00f8n\u0279 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd", "Settings": "S\u01dd\u0287\u0287\u1d09n\u0183s", "Settings updated": "S\u01dd\u0287\u0287\u1d09n\u0183s ndd\u0250\u0287\u01ddd", - "Share": "S\u0265\u0250\u0279\u01dd", "Share Alike": "S\u0265\u0250\u0279\u01dd \u023al\u1d09\u029e\u01dd", - "Share on Mozilla Backpack": "S\u0265\u0250\u0279\u01dd \u00f8n M\u00f8z\u1d09ll\u0250 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e", - "Share your \"%(display_name)s\" award": "S\u0265\u0250\u0279\u01dd \u028e\u00f8n\u0279 \"%(display_name)s\" \u0250\u028d\u0250\u0279d", "Short explanation": "S\u0265\u00f8\u0279\u0287 \u01ddxdl\u0250n\u0250\u0287\u1d09\u00f8n", "Show All": "S\u0265\u00f8\u028d \u023all", "Show Annotations": "S\u0265\u00f8\u028d \u023ann\u00f8\u0287\u0250\u0287\u1d09\u00f8ns", @@ -1532,7 +1524,6 @@ "Start of transcript. Skip to the end.": "S\u0287\u0250\u0279\u0287 \u00f8\u025f \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287. S\u029e\u1d09d \u0287\u00f8 \u0287\u0265\u01dd \u01ddnd.", "Start regenerating certificates for students in this course?": "S\u0287\u0250\u0279\u0287 \u0279\u01dd\u0183\u01ddn\u01dd\u0279\u0250\u0287\u1d09n\u0183 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dds \u025f\u00f8\u0279 s\u0287nd\u01ddn\u0287s \u1d09n \u0287\u0265\u1d09s \u0254\u00f8n\u0279s\u01dd?", "Start search": "S\u0287\u0250\u0279\u0287 s\u01dd\u0250\u0279\u0254\u0265", - "Start working toward your next learning goal.": "S\u0287\u0250\u0279\u0287 \u028d\u00f8\u0279\u029e\u1d09n\u0183 \u0287\u00f8\u028d\u0250\u0279d \u028e\u00f8n\u0279 n\u01ddx\u0287 l\u01dd\u0250\u0279n\u1d09n\u0183 \u0183\u00f8\u0250l.", "Start {trialLength}-day free trial": "S\u0287\u0250\u0279\u0287 {trialLength}-d\u0250\u028e \u025f\u0279\u01dd\u01dd \u0287\u0279\u1d09\u0250l", "Started entrance exam rescore task for student '{student_id}'. Click the 'Show Task Status' button to see the status of the task.": "S\u0287\u0250\u0279\u0287\u01ddd \u01ddn\u0287\u0279\u0250n\u0254\u01dd \u01ddx\u0250\u026f \u0279\u01dds\u0254\u00f8\u0279\u01dd \u0287\u0250s\u029e \u025f\u00f8\u0279 s\u0287nd\u01ddn\u0287 '{student_id}'. \u023bl\u1d09\u0254\u029e \u0287\u0265\u01dd 'S\u0265\u00f8\u028d \u0166\u0250s\u029e S\u0287\u0250\u0287ns' bn\u0287\u0287\u00f8n \u0287\u00f8 s\u01dd\u01dd \u0287\u0265\u01dd s\u0287\u0250\u0287ns \u00f8\u025f \u0287\u0265\u01dd \u0287\u0250s\u029e.", "Started rescore problem task for problem '<%- problem_id %>' and student '<%- student_id %>'. Click the 'Show Task Status' button to see the status of the task.": "S\u0287\u0250\u0279\u0287\u01ddd \u0279\u01dds\u0254\u00f8\u0279\u01dd d\u0279\u00f8bl\u01dd\u026f \u0287\u0250s\u029e \u025f\u00f8\u0279 d\u0279\u00f8bl\u01dd\u026f '<%- problem_id %>' \u0250nd s\u0287nd\u01ddn\u0287 '<%- student_id %>'. \u023bl\u1d09\u0254\u029e \u0287\u0265\u01dd 'S\u0265\u00f8\u028d \u0166\u0250s\u029e S\u0287\u0250\u0287ns' bn\u0287\u0287\u00f8n \u0287\u00f8 s\u01dd\u01dd \u0287\u0265\u01dd s\u0287\u0250\u0287ns \u00f8\u025f \u0287\u0265\u01dd \u0287\u0250s\u029e.", @@ -1820,7 +1811,6 @@ "To receive a certificate, you must also verify your identity.": "\u0166\u00f8 \u0279\u01dd\u0254\u01dd\u1d09\u028c\u01dd \u0250 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd, \u028e\u00f8n \u026fns\u0287 \u0250ls\u00f8 \u028c\u01dd\u0279\u1d09\u025f\u028e \u028e\u00f8n\u0279 \u1d09d\u01ddn\u0287\u1d09\u0287\u028e.", "To receive credit for problems, you must select \"Submit\" for each problem before you select \"End My Exam\".": "\u0166\u00f8 \u0279\u01dd\u0254\u01dd\u1d09\u028c\u01dd \u0254\u0279\u01ddd\u1d09\u0287 \u025f\u00f8\u0279 d\u0279\u00f8bl\u01dd\u026fs, \u028e\u00f8n \u026fns\u0287 s\u01ddl\u01dd\u0254\u0287 \"Snb\u026f\u1d09\u0287\" \u025f\u00f8\u0279 \u01dd\u0250\u0254\u0265 d\u0279\u00f8bl\u01dd\u026f b\u01dd\u025f\u00f8\u0279\u01dd \u028e\u00f8n s\u01ddl\u01dd\u0254\u0287 \"\u0246nd M\u028e \u0246x\u0250\u026f\".", "To review learner cohort assignments or see the results of uploading a CSV file, download course profile information or cohort results on the {link_start}Data Download{link_end} page.": "\u0166\u00f8 \u0279\u01dd\u028c\u1d09\u01dd\u028d l\u01dd\u0250\u0279n\u01dd\u0279 \u0254\u00f8\u0265\u00f8\u0279\u0287 \u0250ss\u1d09\u0183n\u026f\u01ddn\u0287s \u00f8\u0279 s\u01dd\u01dd \u0287\u0265\u01dd \u0279\u01ddsnl\u0287s \u00f8\u025f ndl\u00f8\u0250d\u1d09n\u0183 \u0250 \u023bSV \u025f\u1d09l\u01dd, d\u00f8\u028dnl\u00f8\u0250d \u0254\u00f8n\u0279s\u01dd d\u0279\u00f8\u025f\u1d09l\u01dd \u1d09n\u025f\u00f8\u0279\u026f\u0250\u0287\u1d09\u00f8n \u00f8\u0279 \u0254\u00f8\u0265\u00f8\u0279\u0287 \u0279\u01ddsnl\u0287s \u00f8n \u0287\u0265\u01dd {link_start}\u0110\u0250\u0287\u0250 \u0110\u00f8\u028dnl\u00f8\u0250d{link_end} d\u0250\u0183\u01dd.", - "To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.": "\u0166\u00f8 s\u0265\u0250\u0279\u01dd \u028e\u00f8n\u0279 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd \u00f8n M\u00f8z\u1d09ll\u0250 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e, \u028e\u00f8n \u026fns\u0287 \u025f\u1d09\u0279s\u0287 \u0265\u0250\u028c\u01dd \u0250 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e \u0250\u0254\u0254\u00f8nn\u0287. \u023b\u00f8\u026fdl\u01dd\u0287\u01dd \u0287\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 s\u0287\u01ddds \u0287\u00f8 \u0250dd \u028e\u00f8n\u0279 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd \u0287\u00f8 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e.", "To take a successful photo, make sure that:": "\u0166\u00f8 \u0287\u0250\u029e\u01dd \u0250 sn\u0254\u0254\u01ddss\u025fnl d\u0265\u00f8\u0287\u00f8, \u026f\u0250\u029e\u01dd sn\u0279\u01dd \u0287\u0265\u0250\u0287:", "To take the photo of your face, click on the camera button {icon}. If you need to try again, click 'Retake Photo'.": "\u0166\u00f8 \u0287\u0250\u029e\u01dd \u0287\u0265\u01dd d\u0265\u00f8\u0287\u00f8 \u00f8\u025f \u028e\u00f8n\u0279 \u025f\u0250\u0254\u01dd, \u0254l\u1d09\u0254\u029e \u00f8n \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250 bn\u0287\u0287\u00f8n {icon}. \u0197\u025f \u028e\u00f8n n\u01dd\u01ddd \u0287\u00f8 \u0287\u0279\u028e \u0250\u0183\u0250\u1d09n, \u0254l\u1d09\u0254\u029e '\u024c\u01dd\u0287\u0250\u029e\u01dd \u2c63\u0265\u00f8\u0287\u00f8'.", "To verify your identity, you need a webcam and a government-issued photo ID.": "\u0166\u00f8 \u028c\u01dd\u0279\u1d09\u025f\u028e \u028e\u00f8n\u0279 \u1d09d\u01ddn\u0287\u1d09\u0287\u028e, \u028e\u00f8n n\u01dd\u01ddd \u0250 \u028d\u01ddb\u0254\u0250\u026f \u0250nd \u0250 \u0183\u00f8\u028c\u01dd\u0279n\u026f\u01ddn\u0287-\u1d09ssn\u01ddd d\u0265\u00f8\u0287\u00f8 \u0197\u0110.", @@ -2046,7 +2036,6 @@ "What if I have difficulty holding my ID in position relative to the camera?": "W\u0265\u0250\u0287 \u1d09\u025f \u0197 \u0265\u0250\u028c\u01dd d\u1d09\u025f\u025f\u1d09\u0254nl\u0287\u028e \u0265\u00f8ld\u1d09n\u0183 \u026f\u028e \u0197\u0110 \u1d09n d\u00f8s\u1d09\u0287\u1d09\u00f8n \u0279\u01ddl\u0250\u0287\u1d09\u028c\u01dd \u0287\u00f8 \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250?", "What if I have difficulty holding my head in position relative to the camera?": "W\u0265\u0250\u0287 \u1d09\u025f \u0197 \u0265\u0250\u028c\u01dd d\u1d09\u025f\u025f\u1d09\u0254nl\u0287\u028e \u0265\u00f8ld\u1d09n\u0183 \u026f\u028e \u0265\u01dd\u0250d \u1d09n d\u00f8s\u1d09\u0287\u1d09\u00f8n \u0279\u01ddl\u0250\u0287\u1d09\u028c\u01dd \u0287\u00f8 \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250?", "What was the total combined income, during the last 12 months, of all members of your family? ": "W\u0265\u0250\u0287 \u028d\u0250s \u0287\u0265\u01dd \u0287\u00f8\u0287\u0250l \u0254\u00f8\u026fb\u1d09n\u01ddd \u1d09n\u0254\u00f8\u026f\u01dd, dn\u0279\u1d09n\u0183 \u0287\u0265\u01dd l\u0250s\u0287 12 \u026f\u00f8n\u0287\u0265s, \u00f8\u025f \u0250ll \u026f\u01dd\u026fb\u01dd\u0279s \u00f8\u025f \u028e\u00f8n\u0279 \u025f\u0250\u026f\u1d09l\u028e? ", - "What's Your Next Accomplishment?": "W\u0265\u0250\u0287's \u024e\u00f8n\u0279 N\u01ddx\u0287 \u023a\u0254\u0254\u00f8\u026fdl\u1d09s\u0265\u026f\u01ddn\u0287?", "What's in my clipboard?": "W\u0265\u0250\u0287's \u1d09n \u026f\u028e \u0254l\u1d09db\u00f8\u0250\u0279d?", "When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.": "W\u0265\u01ddn l\u01dd\u0250\u0279n\u01dd\u0279s snb\u026f\u1d09\u0287 \u0250n \u0250ns\u028d\u01dd\u0279 \u0287\u00f8 \u0250n \u0250ss\u01ddss\u026f\u01ddn\u0287, \u0287\u0265\u01dd\u028e \u1d09\u026f\u026f\u01ddd\u1d09\u0250\u0287\u01ddl\u028e s\u01dd\u01dd \u028d\u0265\u01dd\u0287\u0265\u01dd\u0279 \u0287\u0265\u01dd \u0250ns\u028d\u01dd\u0279 \u1d09s \u0254\u00f8\u0279\u0279\u01dd\u0254\u0287 \u00f8\u0279 \u1d09n\u0254\u00f8\u0279\u0279\u01dd\u0254\u0287, \u0250nd \u0287\u0265\u01dd s\u0254\u00f8\u0279\u01dd \u0279\u01dd\u0254\u01dd\u1d09\u028c\u01ddd.", "When your face is in position, use the Take Photo button {icon} below to take your photo.": "W\u0265\u01ddn \u028e\u00f8n\u0279 \u025f\u0250\u0254\u01dd \u1d09s \u1d09n d\u00f8s\u1d09\u0287\u1d09\u00f8n, ns\u01dd \u0287\u0265\u01dd \u0166\u0250\u029e\u01dd \u2c63\u0265\u00f8\u0287\u00f8 bn\u0287\u0287\u00f8n {icon} b\u01ddl\u00f8\u028d \u0287\u00f8 \u0287\u0250\u029e\u01dd \u028e\u00f8n\u0279 d\u0265\u00f8\u0287\u00f8.", @@ -2301,7 +2290,6 @@ "{categoryText} in {parentDisplayname}": "{categoryText} \u1d09n {parentDisplayname}", "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} of {maxCharacters}": "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} \u00f8\u025f {maxCharacters}", "{display_name} Settings": "{display_name} S\u01dd\u0287\u0287\u1d09n\u0183s", - "{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack.": "{download_link_start}\u0110\u00f8\u028dnl\u00f8\u0250d \u0287\u0265\u1d09s \u1d09\u026f\u0250\u0183\u01dd (\u0279\u1d09\u0183\u0265\u0287-\u0254l\u1d09\u0254\u029e \u00f8\u0279 \u00f8d\u0287\u1d09\u00f8n-\u0254l\u1d09\u0254\u029e, s\u0250\u028c\u01dd \u0250s){link_end} \u0250nd \u0287\u0265\u01ddn {upload_link_start}ndl\u00f8\u0250d{link_end} \u1d09\u0287 \u0287\u00f8 \u028e\u00f8n\u0279 b\u0250\u0254\u029ed\u0250\u0254\u029e.", "{earned}/{possible} point (graded)": [ "{earned}/{possible} d\u00f8\u1d09n\u0287 (\u0183\u0279\u0250d\u01ddd)", "{earned}/{possible} d\u00f8\u1d09n\u0287s (\u0183\u0279\u0250d\u01ddd)" diff --git a/cms/static/js/views/settings/grading.js b/cms/static/js/views/settings/grading.js index ac4d170352..dcb2ce9b3e 100644 --- a/cms/static/js/views/settings/grading.js +++ b/cms/static/js/views/settings/grading.js @@ -268,7 +268,7 @@ function(ValidatingView, _, $, ui, GraderView, StringUtils, HtmlUtils) { var cutoffs = this.descendingCutoffs; this.$el.find('.range').each(function(i) { var min = (i < cutoffs.length ? cutoffs[i].cutoff : 0); - var max = (i > 0 ? cutoffs[i - 1].cutoff : 100); + var max = (i > 0 ? cutoffs[i - 1].cutoff - 1 : 100); $(this).text(min + '-' + max); }); }, diff --git a/cms/templates/asset_index.html b/cms/templates/asset_index.html index 728ee864c6..fc7d92173b 100644 --- a/cms/templates/asset_index.html +++ b/cms/templates/asset_index.html @@ -7,7 +7,7 @@ from openedx.core.djangolib.markup import HTML, Text from openedx.core.djangolib.js_utils import js_escaped_string, dump_js_escaped_json %> -<%block name="title">${_("Files & Uploads")} +<%block name="title">${_("Files")} <%block name="bodyclass">is-signedin course uploads view-uploads <%namespace name='static' file='static_content.html'/> @@ -25,7 +25,7 @@
diff --git a/cms/templates/index.html b/cms/templates/index.html index dbd83b2ff5..766d68da78 100644 --- a/cms/templates/index.html +++ b/cms/templates/index.html @@ -360,6 +360,9 @@ from openedx.core.djangolib.js_utils import ( % endif % endif + % if taxonomies_enabled: +
  • ${_("Taxonomies")}
  • + % endif % endif diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 6dd94ec78c..14d054344c 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -125,12 +125,12 @@ % endif %if not files_uploads_mfe_enabled: %endif %if files_uploads_mfe_enabled: %endif % if not pages_and_resources_mfe_enabled: diff --git a/common/djangoapps/course_modes/tests/test_admin.py b/common/djangoapps/course_modes/tests/test_admin.py index 52640dc52f..25b6558d24 100644 --- a/common/djangoapps/course_modes/tests/test_admin.py +++ b/common/djangoapps/course_modes/tests/test_admin.py @@ -54,7 +54,7 @@ class AdminCourseModePageTest(ModuleStoreTestCase): '_expiration_datetime_1': expiration.time(), } - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) # Create a new course mode from django admin page response = self.client.post(reverse('admin:course_modes_coursemode_add'), data=data) diff --git a/common/djangoapps/student/auth.py b/common/djangoapps/student/auth.py index abb98dcaff..fb2999eca9 100644 --- a/common/djangoapps/student/auth.py +++ b/common/djangoapps/student/auth.py @@ -93,9 +93,16 @@ def get_user_permissions(user, course_key, org=None): return all_perms if course_key and user_has_role(user, CourseInstructorRole(course_key)): return all_perms - # Limited Course Staff does not have access to Studio. + # HACK: Limited Staff should not have studio read access. However, since many LMS views depend on the + # `has_course_author_access` check and `course_author_access_required` decorator, we have to allow write access + # until the permissions become more granular. For example, there could be STUDIO_VIEW_COHORTS and + # STUDIO_EDIT_COHORTS specifically for the cohorts endpoint, which is used to display the "Cohorts" tab of the + # Instructor Dashboard. + # The permissions matrix from the RBAC project (https://github.com/openedx/platform-roadmap/issues/246) shows that + # the LMS and Studio permissions will be separated as a part of this project. Once this is done (and this code is + # not removed during its implementation), we can replace the Limited Staff permissions with more granular ones. if course_key and user_has_role(user, CourseLimitedStaffRole(course_key)): - return STUDIO_NO_PERMISSIONS + return STUDIO_EDIT_CONTENT # Staff have all permissions except EDIT_ROLES: if OrgStaffRole(org=org).has_user(user) or (course_key and user_has_role(user, CourseStaffRole(course_key))): return STUDIO_VIEW_USERS | STUDIO_EDIT_CONTENT | STUDIO_VIEW_CONTENT diff --git a/common/djangoapps/student/handlers.py b/common/djangoapps/student/handlers.py index 6dc841f98f..d7d5eeee3c 100644 --- a/common/djangoapps/student/handlers.py +++ b/common/djangoapps/student/handlers.py @@ -8,6 +8,9 @@ from openedx_events.event_bus import get_producer from openedx_events.learning.signals import ( COURSE_UNENROLLMENT_COMPLETED, ) +from openedx.core.lib.events import determine_producer_config_for_signal_and_topic +import logging +log = logging.getLogger(__name__) @receiver(COURSE_UNENROLLMENT_COMPLETED) @@ -15,10 +18,16 @@ def course_unenrollment_receiver(sender, signal, **kwargs): """ Removes user notification preference when user un-enrolls from the course """ + topic = getattr(settings, "EVENT_BUS_ENROLLMENT_LIFECYCLE_TOPIC", "course-unenrollment-lifecycle") + producer_config_setting = determine_producer_config_for_signal_and_topic(COURSE_UNENROLLMENT_COMPLETED, topic) + if producer_config_setting is True: + log.info("Producing unenrollment-event event via config") + return if settings.FEATURES.get("ENABLE_SEND_ENROLLMENT_EVENTS_OVER_BUS"): + log.info("Producing unenrollment-event event via manual send") get_producer().send( signal=COURSE_UNENROLLMENT_COMPLETED, - topic=getattr(settings, "EVENT_BUS_ENROLLMENT_LIFECYCLE_TOPIC", "course-unenrollment-lifecycle"), + topic=topic, event_key_field='enrollment.course.course_key', event_data={'enrollment': kwargs.get('enrollment')}, event_metadata=kwargs.get('metadata') diff --git a/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py b/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py new file mode 100644 index 0000000000..6c13392d22 --- /dev/null +++ b/common/djangoapps/student/management/commands/populate_users_emails_on_braze.py @@ -0,0 +1,139 @@ +""" Management command to add user emails data on Braze. """ +import logging +import time + +from braze.client import BrazeClient +from braze.exceptions import BrazeClientError +from django.conf import settings +from django.contrib.auth import get_user_model +from django.core.management.base import BaseCommand + +from common.djangoapps.util.query import use_read_replica_if_available + +User = get_user_model() + +MARKETING_EMAIL_ATTRIBUTE_NAME = 'is_marketable' +TRACK_USER_COMPONENT_CHUNK_SIZE = 75 + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Command to add user email address on Braze against a user_id. + Example usage: + $ ./manage.py lms populate_user_emails_on_braze + """ + help = """ + Updates user accounts with email addresses on braze. + """ + + def add_arguments(self, parser): + """ + Function to get command arguments + """ + parser.add_argument( + '--batch-delay', + type=float, + dest='batch_delay', + default=0.5, + help='Time delay in seconds between each iteration' + ) + parser.add_argument( + '--batch-size', + type=int, + dest='batch_size', + default=10000, + help='Batch size' + ) + parser.add_argument( + '--starting-user-id', + type=int, + dest='starting_user_id', + default=0, + help='Starting user id to process a specific batch of users. ' + 'Both start and end id should be provided.', + ) + parser.add_argument( + '--ending-user-id', + type=int, + dest='ending_user_id', + default=0, + help='Ending user id (inclusive) to process a specific batch of users. ' + 'Both start and end id should be provided.', + ) + + def __init__(self): + super().__init__() + self.braze_client = BrazeClient( + api_key=settings.EDX_BRAZE_API_KEY, + api_url=settings.EDX_BRAZE_API_SERVER, + app_id='', + ) + + @staticmethod + def _chunks(users, chunk_size=TRACK_USER_COMPONENT_CHUNK_SIZE): + """ + Yields successive chunks of users. The size of each chunk is determined by + TRACK_USER_COMPONENT_CHUNK_SIZE which is set to 75. + Reference: https://www.braze.com/docs/api/endpoints/user_data/post_user_track/ + """ + for index in range(0, len(users), chunk_size): + yield users[index:index + chunk_size] + + def _get_user_batch(self, batch_start_id, batch_end_id): + """ + This returns the batch of users. + """ + query = User.objects.filter( + id__gte=batch_start_id, id__lt=batch_end_id, + ).select_related('profile').values_list( + 'id', 'email', named=True, + ).order_by('id') + + return use_read_replica_if_available(query) + + def _update_braze_attributes(self, users): + """ + Sends Braze API request to update user account. + Fields sent using the API include: + - external_id (user_id) + - email + """ + attributes = [] + for user in users: + attributes.append( + { + "external_id": user.id, + "email": user.email, + } + ) + + try: + self.braze_client.track_user(attributes=attributes) + except BrazeClientError as error: + logger.error(f'Failed to update attributes. Error: {error}') + + def handle(self, *args, **options): + """ + Handler to run the command. + """ + sleep_time = options['batch_delay'] + batch_size = options['batch_size'] + starting_user_id = options['starting_user_id'] + ending_user_id = options['ending_user_id'] + + all_users_query = use_read_replica_if_available(User.objects) + total_users_count = ending_user_id if ending_user_id else all_users_query.count() + + for index in range(starting_user_id, total_users_count + 1, batch_size): + users = self._get_user_batch(index, index + batch_size) + logger.info(f'Processing users with user ids in {index} - {(index + batch_size) - 1} range') + + # Force evaluating the query to avoid multiple hits to db + # when we evaluate the chunks. + evaluated_users = list(users) + for user_chunk in self._chunks(evaluated_users): + self._update_braze_attributes(user_chunk) + + time.sleep(sleep_time) diff --git a/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py b/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py new file mode 100644 index 0000000000..8d8ad31c4c --- /dev/null +++ b/common/djangoapps/student/management/tests/test_populate_users_emails_on_braze.py @@ -0,0 +1,91 @@ +""" +Unittests for populate_marketing_opt_in_user_attribute management command. +""" +from unittest.mock import patch, MagicMock + +from braze.exceptions import BrazeClientError +from django.core.management import call_command +from django.test import TestCase +from testfixtures import LogCapture + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_lms + +LOGGER_NAME = 'common.djangoapps.student.management.commands.populate_users_emails_on_braze' + + +@skip_unless_lms +class TestPopulateUsersEmailsOnBraze(TestCase): + """ + Tests for PopulateUsersEmailsOnBraze management command. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + for index in range(15): + user = UserFactory() + + @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user') + def test_command_updates_users_on_braze(self, track_user): + """ + Test that Braze API is called successfully for all users + """ + track_user.return_value = MagicMock() + call_command('populate_users_emails_on_braze', batch_delay=0) + assert track_user.called + + @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user') + def test_logs_for_success(self, track_user): + """ + Test logs for a successful run of updating user accounts on Braze + """ + track_user.return_value = MagicMock() + with LogCapture(LOGGER_NAME) as log: + call_command( + 'populate_users_emails_on_braze', + batch_size=5, + batch_delay=0, + ) + log.check( + (LOGGER_NAME, 'INFO', 'Processing users with user ids in 0 - 4 range'), + (LOGGER_NAME, 'INFO', 'Processing users with user ids in 5 - 9 range'), + (LOGGER_NAME, 'INFO', 'Processing users with user ids in 10 - 14 range'), + (LOGGER_NAME, 'INFO', 'Processing users with user ids in 15 - 19 range'), + ) + + @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user') + def test_logs_for_failure(self, track_user): + """ + Test logs for when the update to Braze fails + """ + track_user.side_effect = BrazeClientError('Update to attributes failed.') + with LogCapture(LOGGER_NAME) as log: + call_command( + 'populate_users_emails_on_braze', + batch_size=5, + batch_delay=0, + ) + log.check_present( + (LOGGER_NAME, 'ERROR', 'Failed to update attributes. Error: Update to attributes failed.'), + ) + + @patch('common.djangoapps.student.management.commands.populate_users_emails_on_braze.BrazeClient.track_user') + def test_running_a_specific_batch(self, track_user): + """ + Test running command for a specific batch of users + """ + track_user.return_value = MagicMock() + with LogCapture(LOGGER_NAME) as log: + call_command( + 'populate_users_emails_on_braze', + batch_size=5, + batch_delay=0, + starting_user_id=2, + ending_user_id=13, + ) + log.check( + (LOGGER_NAME, 'INFO', 'Processing users with user ids in 2 - 6 range'), + (LOGGER_NAME, 'INFO', 'Processing users with user ids in 7 - 11 range'), + (LOGGER_NAME, 'INFO', 'Processing users with user ids in 12 - 16 range'), + ) diff --git a/common/djangoapps/student/roles.py b/common/djangoapps/student/roles.py index c2fd4564e4..7bbd0cf924 100644 --- a/common/djangoapps/student/roles.py +++ b/common/djangoapps/student/roles.py @@ -7,6 +7,7 @@ adding users, removing users, and listing members import logging from abc import ABCMeta, abstractmethod from collections import defaultdict +from contextlib import contextmanager from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from opaque_keys.edx.django.models import CourseKeyField @@ -44,6 +45,21 @@ def register_access_role(cls): return cls +@contextmanager +def strict_role_checking(): + """ + Context manager that temporarily disables role inheritance. + + You may want to use it to check if a user has a base role. For example, if a user has `CourseLimitedStaffRole`, + by enclosing `has_role` call with this context manager, you can check it has the `CourseStaffRole` too. This is + useful when derived roles have less permissions than their base roles, but users can have both roles at the same. + """ + OLD_ACCESS_ROLES_INHERITANCE = ACCESS_ROLES_INHERITANCE.copy() + ACCESS_ROLES_INHERITANCE.clear() + yield + ACCESS_ROLES_INHERITANCE.update(OLD_ACCESS_ROLES_INHERITANCE) + + class BulkRoleCache: # lint-amnesty, pylint: disable=missing-class-docstring CACHE_NAMESPACE = "student.roles.BulkRoleCache" CACHE_KEY = 'roles_by_user' @@ -78,7 +94,7 @@ class RoleCache: ) @staticmethod - def _get_roles(role): + def get_roles(role): """ Return the roles that should have the same permissions as the specified role. """ @@ -90,7 +106,7 @@ class RoleCache: or a role that inherits from the specified role, course_id and org. """ return any( - access_role.role in self._get_roles(role) and + access_role.role in self.get_roles(role) and access_role.course_id == course_id and access_role.org == org for access_role in self._roles @@ -463,11 +479,11 @@ class UserBasedRole: def courses_with_role(self): """ - Return a django QuerySet for all of the courses with this user x role. You can access + Return a django QuerySet for all of the courses with this user x (or derived from x) role. You can access any of these properties on each result record: * user (will be self.user--thus uninteresting) * org * course_id * role (will be self.role--thus uninteresting) """ - return CourseAccessRole.objects.filter(role=self.role, user=self.user) + return CourseAccessRole.objects.filter(role__in=RoleCache.get_roles(self.role), user=self.user) diff --git a/common/djangoapps/student/tests/factories.py b/common/djangoapps/student/tests/factories.py index 7080824125..27e245288d 100644 --- a/common/djangoapps/student/tests/factories.py +++ b/common/djangoapps/student/tests/factories.py @@ -35,7 +35,7 @@ from common.djangoapps.student.roles import OrgStaffRole from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory -TEST_PASSWORD = 'test' +TEST_PASSWORD = 'Password1234' class GroupFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing-class-docstring @@ -81,7 +81,7 @@ class UserFactory(DjangoModelFactory): # lint-amnesty, pylint: disable=missing- model = User django_get_or_create = ('email', 'username') - _DEFAULT_PASSWORD = 'test' + _DEFAULT_PASSWORD = 'Password1234' username = factory.Sequence('robot{}'.format) email = factory.Sequence('robot+test+{}@edx.org'.format) diff --git a/common/djangoapps/student/tests/test_admin_views.py b/common/djangoapps/student/tests/test_admin_views.py index 5c6350c2f0..2914bcd61c 100644 --- a/common/djangoapps/student/tests/test_admin_views.py +++ b/common/djangoapps/student/tests/test_admin_views.py @@ -53,7 +53,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): 'email': self.user.email } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -78,7 +78,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): 'course_id': str(self.course.id) } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -96,7 +96,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -115,7 +115,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -136,7 +136,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): 'email': email } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Adding new role with invalid data response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -163,7 +163,7 @@ class AdminCourseRolesPageTest(SharedModuleStoreTestCase): 'email': self.user.email } - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # # adding new role from django admin page response = self.client.post(reverse('admin:student_courseaccessrole_add'), data=data) @@ -230,7 +230,7 @@ class CourseEnrollmentAdminTest(SharedModuleStoreTestCase): user=self.user, course_id=self.course.id, # pylint: disable=no-member ) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) @ddt.data(*ADMIN_URLS) @ddt.unpack @@ -324,13 +324,14 @@ class LoginFailuresAdminTest(TestCase): def setUpClass(cls): """Setup class""" super().setUpClass() - cls.user = UserFactory.create(username='§', is_staff=True, is_superuser=True) + cls.TEST_PASSWORD = 'Password1234' + cls.user = UserFactory.create(username='§', password=cls.TEST_PASSWORD, is_staff=True, is_superuser=True) cls.user.save() def setUp(self): """Setup.""" super().setUp() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.user2 = UserFactory.create(username='Zażółć gęślą jaźń') self.user_lockout_until = datetime.datetime.now(UTC) LoginFailures.objects.create(user=self.user, failure_count=10, lockout_until=self.user_lockout_until) diff --git a/common/djangoapps/student/tests/test_authz.py b/common/djangoapps/student/tests/test_authz.py index a7a3694d34..1c79780e88 100644 --- a/common/djangoapps/student/tests/test_authz.py +++ b/common/djangoapps/student/tests/test_authz.py @@ -285,14 +285,14 @@ class CourseGroupTest(TestCase): with pytest.raises(PermissionDenied): remove_users(self.staff, CourseStaffRole(self.course_key), another_staff) - def test_no_limited_staff_read_or_write_access(self): + def test_limited_staff_no_studio_read_access(self): """ - Test that course limited staff have no read or write access. + Verifies that course limited staff have no read, but have write access. """ add_users(self.global_admin, CourseLimitedStaffRole(self.course_key), self.limited_staff) assert not has_studio_read_access(self.limited_staff, self.course_key) - assert not has_studio_write_access(self.limited_staff, self.course_key) + assert has_studio_write_access(self.limited_staff, self.course_key) class CourseOrgGroupTest(TestCase): diff --git a/common/djangoapps/student/tests/test_bulk_email_settings.py b/common/djangoapps/student/tests/test_bulk_email_settings.py index d9bfd15621..308ccf66e0 100644 --- a/common/djangoapps/student/tests/test_bulk_email_settings.py +++ b/common/djangoapps/student/tests/test_bulk_email_settings.py @@ -33,7 +33,7 @@ class TestStudentDashboardEmailView(SharedModuleStoreTestCase): # Create student account student = UserFactory.create() CourseEnrollmentFactory.create(user=student, course_id=self.course.id) - self.client.login(username=student.username, password="test") + self.client.login(username=student.username, password=self.TEST_PASSWORD) self.url = reverse('dashboard') # URL for email settings modal diff --git a/common/djangoapps/student/tests/test_email.py b/common/djangoapps/student/tests/test_email.py index 7f1287fba0..bcede18213 100644 --- a/common/djangoapps/student/tests/test_email.py +++ b/common/djangoapps/student/tests/test_email.py @@ -136,7 +136,7 @@ class ActivationEmailTests(EmailTemplateTagMixin, CacheIsolationTestCase): params = { 'username': 'test_user', 'email': 'test_user@example.com', - 'password': 'edx', + 'password': 'Password1234', 'name': 'Test User', 'honor_code': True, 'terms_of_service': True @@ -319,7 +319,7 @@ class EmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, CacheIsolat self.new_email = 'new.email@edx.org' self.req_factory = RequestFactory() self.request = self.req_factory.post('unused_url', data={ - 'password': 'test', + 'password': 'Password1234', 'new_email': self.new_email }) self.request.user = self.user @@ -628,7 +628,7 @@ class SecondaryEmailChangeRequestTests(EventTestMixin, EmailTemplateTagMixin, Ca self.new_secondary_email = 'new.secondary.email@edx.org' self.req_factory = RequestFactory() self.request = self.req_factory.post('unused_url', data={ - 'password': 'test', + 'password': 'Password1234', 'new_email': self.new_secondary_email }) self.request.user = self.user diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index 6e429fd39a..376595a850 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -316,7 +316,7 @@ class StudentDashboardFiltersTest(ModuleStoreTestCase): def setUp(self): # pylint: disable=arguments-differ super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password="test") + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.dashboard_url = reverse("dashboard") self.first_course = CourseFactory.create( org="test1", course="course1", display_name="run1", diff --git a/common/djangoapps/student/tests/test_retirement.py b/common/djangoapps/student/tests/test_retirement.py index a7e5fac865..ffeb1af14f 100644 --- a/common/djangoapps/student/tests/test_retirement.py +++ b/common/djangoapps/student/tests/test_retirement.py @@ -260,7 +260,7 @@ class TestRegisterRetiredUsername(TestCase): 'username': 'username', 'email': 'foo_bar' + '@bar.com', 'name': 'foo bar', - 'password': '123', + 'password': 'Password1234', 'terms_of_service': 'true', 'honor_code': 'true', } diff --git a/common/djangoapps/student/tests/test_userstanding.py b/common/djangoapps/student/tests/test_userstanding.py index 832127e474..67fcebda28 100644 --- a/common/djangoapps/student/tests/test_userstanding.py +++ b/common/djangoapps/student/tests/test_userstanding.py @@ -43,7 +43,7 @@ class UserStandingTest(TestCase): (self.non_staff, self.non_staff_client), (self.admin, self.admin_client), ]: - client.login(username=user.username, password='test') + client.login(username=user.username, password='Password1234') UserStandingFactory.create( user=self.bad_user, diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index b9518efa04..1230f8320a 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -49,7 +49,6 @@ from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: d from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order -PASSWORD = 'test' TOMORROW = now() + timedelta(days=1) ONE_WEEK_AGO = now() - timedelta(weeks=1) THREE_YEARS_FROM_NOW = now() + timedelta(days=(365 * 3)) @@ -81,7 +80,7 @@ class TestStudentDashboardUnenrollments(SharedModuleStoreTestCase): self.user = UserFactory() self.enrollment = CourseEnrollmentFactory(course_id=self.course.id, user=self.user) self.cert_status = 'processing' - self.client.login(username=self.user.username, password=PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) def mock_cert(self, _user, _course_overview): """ Return a preset certificate status. """ @@ -212,7 +211,7 @@ class StudentDashboardTests(SharedModuleStoreTestCase, MilestonesTestCaseMixin, """ super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.path = reverse('dashboard') def set_course_sharing_urls(self, set_marketing, set_social_sharing): @@ -1018,7 +1017,7 @@ class TestCourseDashboardNoticesRedirects(SharedModuleStoreTestCase): def setUp(self): super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.path = reverse('dashboard') def test_check_for_unacknowledged_notices(self): diff --git a/common/djangoapps/student/tests/tests.py b/common/djangoapps/student/tests/tests.py index b41ad2f856..a90b2504d1 100644 --- a/common/djangoapps/student/tests/tests.py +++ b/common/djangoapps/student/tests/tests.py @@ -278,7 +278,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): def setUp(self): super().setUp() self.course = CourseFactory.create() - self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password='test') + self.user = UserFactory.create(username="jack", email="jack@fake.edx.org", password=self.TEST_PASSWORD) self.client = Client() cache.clear() @@ -307,7 +307,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): """ Test that the certificate verification status for courses is visible on the dashboard. """ - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) self._check_verification_status_on('verified', 'You're enrolled as a verified student') self._check_verification_status_on('honor', 'You're enrolled as an honor code student') self._check_verification_status_off('audit', '') @@ -345,7 +345,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): Test that the certificate verification status for courses is not visible on the dashboard if the verified certificates setting is off. """ - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) self._check_verification_status_off('verified', 'You\'re enrolled as a verified student') self._check_verification_status_off('honor', 'You\'re enrolled as an honor code student') self._check_verification_status_off('audit', '') @@ -371,7 +371,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): @skip_unless_lms def test_linked_in_add_to_profile_btn_not_appearing_without_config(self): # Without linked-in config don't show Add Certificate to LinkedIn button - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) CourseModeFactory.create( course_id=self.course.id, @@ -409,7 +409,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): def test_linked_in_add_to_profile_btn_with_certificate(self): # If user has a certificate with valid linked-in config then Add Certificate to LinkedIn button # should be visible. and it has URL value with valid parameters. - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) linkedin_config = LinkedInAddToProfileConfiguration.objects.create(company_identifier='1337', enabled=True) CourseModeFactory.create( @@ -480,7 +480,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): # Create a course and log in the user. # Creating a new course will trigger a publish event and the course will be cached test_course = CourseFactory.create(emit_signals=True) - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) with check_mongo_calls(0): CourseEnrollment.enroll(self.user, test_course.id) @@ -495,7 +495,7 @@ class DashboardTest(ModuleStoreTestCase, TestVerificationBase): @skip_unless_lms def test_dashboard_header_nav_has_find_courses(self): - self.client.login(username="jack", password="test") + self.client.login(username="jack", password=self.TEST_PASSWORD) response = self.client.get(reverse("dashboard")) # "Explore courses" is shown in the side panel @@ -542,7 +542,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase): super().setUp() self.org = 'fakeX' self.course = CourseFactory.create(org=self.org) - self.user = UserFactory.create(username='jack', email='jack@fake.edx.org', password='test') + self.user = UserFactory.create(username='jack', email='jack@fake.edx.org', password=self.TEST_PASSWORD) CourseModeFactory.create(mode_slug='no-id-professional', course_id=self.course.id) CourseEnrollment.enroll(self.user, self.course.location.course_key, mode='no-id-professional') cache.clear() @@ -564,7 +564,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase): 'course_org_filter': self.org }) self.set_up_site(site_domain, site_configuration_values) - self.client.login(username='jack', password='test') + self.client.login(username='jack', password=self.TEST_PASSWORD) response = self.client.get(reverse('dashboard')) self.assertContains(response, 'class="course professional"') @@ -585,7 +585,7 @@ class DashboardTestsWithSiteOverrides(SiteMixin, ModuleStoreTestCase): 'course_org_filter': self.org }) self.set_up_site(site_domain, site_configuration_values) - self.client.login(username='jack', password='test') + self.client.login(username='jack', password=self.TEST_PASSWORD) response = self.client.get(reverse('dashboard')) self.assertNotContains(response, 'class="course professional"') @@ -899,8 +899,8 @@ class ChangeEnrollmentViewTest(ModuleStoreTestCase): def setUp(self): super().setUp() self.course = CourseFactory.create() - self.user = UserFactory.create(password='secret') - self.client.login(username=self.user.username, password='secret') + self.user = UserFactory.create(password=self.TEST_PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.url = reverse('change_enrollment') def _enroll_through_view(self, course): @@ -1056,14 +1056,13 @@ class AnonymousLookupTable(ModuleStoreTestCase): class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): """Tests verifying that related programs appear on the course dashboard.""" maxDiff = None - password = 'test' related_programs_preface = 'Related Programs' @classmethod def setUpClass(cls): super().setUpClass() - cls.user = UserFactory() + cls.user = UserFactory(password=cls.TEST_PASSWORD) cls.course = CourseFactory() cls.enrollment = CourseEnrollmentFactory(user=cls.user, course_id=cls.course.id) # pylint: disable=no-member @@ -1073,7 +1072,7 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): self.url = reverse('dashboard') self.create_programs_config() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) course_run = CourseRunFactory(key=str(self.course.id)) # pylint: disable=no-member course = CatalogCourseFactory(course_runs=[course_run]) diff --git a/common/djangoapps/third_party_auth/models.py b/common/djangoapps/third_party_auth/models.py index af5159764c..1ea265e868 100644 --- a/common/djangoapps/third_party_auth/models.py +++ b/common/djangoapps/third_party_auth/models.py @@ -366,13 +366,12 @@ class OAuth2ProviderConfig(ProviderConfig): .. no_pii: """ - # We are keying the provider config by backend_name here as suggested in the python social - # auth documentation. In order to reuse a backend for a second provider, a subclass can be - # created with seperate name. + # We are keying the provider config by backend_name and site_id to support configuration per site. + # In order to reuse a backend for a second provider, a subclass can be created with seperate name. # example: # class SecondOpenIDProvider(OpenIDAuth): # name = "second-openId-provider" - KEY_FIELDS = ('backend_name',) + KEY_FIELDS = ('site_id', 'backend_name') prefix = 'oa2' backend_name = models.CharField( max_length=50, blank=False, db_index=True, @@ -401,6 +400,29 @@ class OAuth2ProviderConfig(ProviderConfig): verbose_name = "Provider Configuration (OAuth)" verbose_name_plural = verbose_name + @classmethod + def current(cls, *args): + """ + Get the current config model for the provider according to the given backend and the current + site. + """ + site_id = Site.objects.get_current(get_current_request()).id + return super(OAuth2ProviderConfig, cls).current(site_id, *args) + + @property + def provider_id(self): + """ + Unique string key identifying this provider. Must be URL and css class friendly. + Ignoring site_id as the config is filtered using current method which fetches the configuration for the current + site_id. + """ + assert self.prefix is not None + return "-".join((self.prefix, ) + tuple( + str(getattr(self, field)) + for field in self.KEY_FIELDS + if field != 'site_id' + )) + def clean(self): """ Standardize and validate fields """ super().clean() diff --git a/common/djangoapps/third_party_auth/pipeline.py b/common/djangoapps/third_party_auth/pipeline.py index 9135ea556b..fde2bb9cbc 100644 --- a/common/djangoapps/third_party_auth/pipeline.py +++ b/common/djangoapps/third_party_auth/pipeline.py @@ -854,7 +854,7 @@ def user_details_force_sync(auth_entry, strategy, details, user=None, *args, **k This step is controlled by the `sync_learner_profile_data` flag on the provider's configuration. """ current_provider = provider.Registry.get_from_pipeline({'backend': strategy.request.backend.name, 'kwargs': kwargs}) - if user and current_provider.sync_learner_profile_data: + if user and current_provider and current_provider.sync_learner_profile_data: # Keep track of which incoming values get applied. changed = {} @@ -931,7 +931,7 @@ def set_id_verification_status(auth_entry, strategy, details, user=None, *args, Use the user's authentication with the provider, if configured, as evidence of their identity being verified. """ current_provider = provider.Registry.get_from_pipeline({'backend': strategy.request.backend.name, 'kwargs': kwargs}) - if user and current_provider.enable_sso_id_verification: + if user and current_provider and current_provider.enable_sso_id_verification: # Get previous valid, non expired verification attempts for this SSO Provider and user verifications = SSOVerification.objects.filter( user=user, diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 116deacf5e..8f96235017 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -481,7 +481,7 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): # The AJAX on the page will log them in: ajax_login_response = self.client.post( reverse('user_api_login_session', kwargs={'api_version': 'v1'}), - {'email': self.user.email, 'password': 'test'} + {'email': self.user.email, 'password': 'Password1234'} ) assert ajax_login_response.status_code == 200 # Then the AJAX will finish the third party auth: diff --git a/common/djangoapps/third_party_auth/tests/test_admin.py b/common/djangoapps/third_party_auth/tests/test_admin.py index c5481a3a09..0acfb3492a 100644 --- a/common/djangoapps/third_party_auth/tests/test_admin.py +++ b/common/djangoapps/third_party_auth/tests/test_admin.py @@ -14,6 +14,9 @@ from common.djangoapps.third_party_auth.tests import testutil from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth +TEST_PASSWORD = 'Password1234' + + # This is necessary because cms does not implement third party auth @skip_unless_thirdpartyauth() class Oauth2ProviderConfigAdminTest(testutil.TestCase): @@ -36,9 +39,9 @@ class Oauth2ProviderConfigAdminTest(testutil.TestCase): prepopulated correctly, and that we can clear and update the image. """ # Login as a super user - user = UserFactory.create(is_staff=True, is_superuser=True) + user = UserFactory.create(is_staff=True, is_superuser=True, password=TEST_PASSWORD) user.save() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=TEST_PASSWORD) # Get baseline provider count providers = OAuth2ProviderConfig.objects.all() diff --git a/common/djangoapps/third_party_auth/tests/test_provider.py b/common/djangoapps/third_party_auth/tests/test_provider.py index 3fa8f80f4d..28e95c16b8 100644 --- a/common/djangoapps/third_party_auth/tests/test_provider.py +++ b/common/djangoapps/third_party_auth/tests/test_provider.py @@ -11,7 +11,9 @@ from django.test.utils import CaptureQueriesContext from common.djangoapps.third_party_auth import provider from common.djangoapps.third_party_auth.tests import testutil from common.djangoapps.third_party_auth.tests.utils import skip_unless_thirdpartyauth -from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration +from openedx.core.djangoapps.site_configuration.tests.test_util import ( + with_site_configuration, with_site_configuration_context +) SITE_DOMAIN_A = 'professionalx.example.com' SITE_DOMAIN_B = 'somethingelse.example.com' @@ -114,13 +116,13 @@ class RegistryTest(testutil.TestCase): assert no_log_in_provider.provider_id not in provider_ids assert normal_provider.provider_id in provider_ids - def test_tpa_hint_provider_displayed_for_login(self): + def test_tpa_hint_exp_hidden_provider_displayed_for_login(self): """ - Tests to ensure that an enabled-but-not-visible provider is presented + Test to ensure that an explicitly enabled-but-not-visible provider is presented for use in the UI when the "tpa_hint" parameter is specified + A hidden provider should be accessible with tpa_hint (this is the main case) """ - # A hidden provider should be accessible with tpa_hint (this is the main case) hidden_provider = self.configure_google_provider(visible=False, enabled=True) provider_ids = [ idp.provider_id @@ -128,8 +130,14 @@ class RegistryTest(testutil.TestCase): ] assert hidden_provider.provider_id in provider_ids - # New providers are hidden (ie, not flagged as 'visible') by default - # The tpa_hint parameter should work for these providers as well + def test_tpa_hint_hidden_provider_displayed_for_login(self): + """ + Tests to ensure that an implicitly enabled-but-not-visible provider is presented + for use in the UI when the "tpa_hint" parameter is specified. + New providers are hidden (ie, not flagged as 'visible') by default + The tpa_hint parameter should work for these providers as well. + """ + implicitly_hidden_provider = self.configure_linkedin_provider(enabled=True) provider_ids = [ idp.provider_id @@ -137,7 +145,10 @@ class RegistryTest(testutil.TestCase): ] assert implicitly_hidden_provider.provider_id in provider_ids - # Disabled providers should not be matched in tpa_hint scenarios + def test_tpa_hint_disabled_hidden_provider_displayed_for_login(self): + """ + Disabled providers should not be matched in tpa_hint scenarios + """ disabled_provider = self.configure_twitter_provider(visible=True, enabled=False) provider_ids = [ idp.provider_id @@ -145,7 +156,10 @@ class RegistryTest(testutil.TestCase): ] assert disabled_provider.provider_id not in provider_ids - # Providers not utilized for learner authentication should not match tpa_hint + def test_tpa_hint_no_log_hidden_provider_displayed_for_login(self): + """ + Providers not utilized for learner authentication should not match tpa_hint + """ no_log_in_provider = self.configure_lti_provider() provider_ids = [ idp.provider_id @@ -153,6 +167,30 @@ class RegistryTest(testutil.TestCase): ] assert no_log_in_provider.provider_id not in provider_ids + def test_get_current_site_oauth_provider(self): + """ + Verify that correct provider for current site is returned even if same backend is used for multiple sites. + """ + site_a = Site.objects.get_or_create(domain=SITE_DOMAIN_A, name=SITE_DOMAIN_A)[0] + site_b = Site.objects.get_or_create(domain=SITE_DOMAIN_B, name=SITE_DOMAIN_B)[0] + site_a_provider = self.configure_google_provider(visible=True, enabled=True, site=site_a) + site_b_provider = self.configure_google_provider(visible=True, enabled=True, site=site_b) + with with_site_configuration_context(domain=SITE_DOMAIN_A): + assert site_a_provider.enabled_for_current_site is True + + # Registry.displayed_for_login gets providers enabled for current site + provider_ids = provider.Registry.displayed_for_login() + # Google oauth provider for current site should be displayed + assert site_a_provider in provider_ids + assert site_b_provider not in provider_ids + + # Similarly, the other site should only see its own providers + with with_site_configuration_context(domain=SITE_DOMAIN_B): + assert site_b_provider.enabled_for_current_site is True + provider_ids = provider.Registry.displayed_for_login() + assert site_b_provider in provider_ids + assert site_a_provider not in provider_ids + def test_provider_enabled_for_current_site(self): """ Verify that enabled_for_current_site returns True when the provider matches the current site. @@ -201,7 +239,7 @@ class RegistryTest(testutil.TestCase): def test_get_returns_none_if_provider_id_is_none(self): assert provider.Registry.get(None) is None - def test_get_returns_none_if_provider_not_enabled(self): + def test_get_returns_none_if_provider_not_enabled_change(self): linkedin_provider_id = "oa2-linkedin-oauth2" # At this point there should be no configuration entries at all so no providers should be enabled assert provider.Registry.enabled() == [] @@ -209,6 +247,12 @@ class RegistryTest(testutil.TestCase): # Now explicitly disabled this provider: self.configure_linkedin_provider(enabled=False) assert provider.Registry.get(linkedin_provider_id) is None + + def test_get_returns_provider_if_provider_enabled(self): + """ + Test to ensure that Registry gets enabled providers. + """ + linkedin_provider_id = "oa2-linkedin-oauth2" self.configure_linkedin_provider(enabled=True) assert provider.Registry.get(linkedin_provider_id).provider_id == linkedin_provider_id diff --git a/common/djangoapps/third_party_auth/views.py b/common/djangoapps/third_party_auth/views.py index c6a406c530..d24ba8cfd7 100644 --- a/common/djangoapps/third_party_auth/views.py +++ b/common/djangoapps/third_party_auth/views.py @@ -47,7 +47,7 @@ def inactive_user_view(request): if third_party_auth.is_enabled() and pipeline.running(request): running_pipeline = pipeline.get(request) third_party_provider = provider.Registry.get_from_pipeline(running_pipeline) - if third_party_provider.skip_email_verification and not activated: + if third_party_provider and third_party_provider.skip_email_verification and not activated: user.is_active = True user.save() activated = True diff --git a/common/djangoapps/util/tests/test_db.py b/common/djangoapps/util/tests/test_db.py index 4a16c2a20a..ee7825e876 100644 --- a/common/djangoapps/util/tests/test_db.py +++ b/common/djangoapps/util/tests/test_db.py @@ -3,6 +3,7 @@ from io import StringIO import ddt +import unittest from django.core.management import call_command from django.db.transaction import TransactionManagementError, atomic from django.test import TestCase, TransactionTestCase @@ -120,6 +121,7 @@ class MigrationTests(TestCase): Tests for migrations. """ + @unittest.skip("Migration will delete several models. Need to ship not referencing it first") @override_settings(MIGRATION_MODULES={}) def test_migrations_are_in_sync(self): """ diff --git a/common/djangoapps/util/tests/test_password_policy_validators.py b/common/djangoapps/util/tests/test_password_policy_validators.py index f0ffe3c2b1..f5eea255bf 100644 --- a/common/djangoapps/util/tests/test_password_policy_validators.py +++ b/common/djangoapps/util/tests/test_password_policy_validators.py @@ -45,6 +45,11 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase): validate_password(password, user) assert msg in ' '.join(cm.value.messages) + @override_settings(AUTH_PASSWORD_VALIDATORS=[ + create_validator_config( + 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4} + ) + ]) def test_unicode_password(self): """ Tests that validate_password enforces unicode """ unicode_str = '𤭮' @@ -55,12 +60,17 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase): assert len(unicode_str) == 1 # Test length check - self.validation_errors_checker(byte_str, 'This password is too short. It must contain at least 2 characters.') - self.validation_errors_checker(byte_str + byte_str, None) + self.validation_errors_checker(byte_str, 'This password is too short. It must contain at least 4 characters.') + self.validation_errors_checker(byte_str * 4, None) # Test badly encoded password self.validation_errors_checker(b'\xff\xff', 'Invalid password.') + @override_settings(AUTH_PASSWORD_VALIDATORS=[ + create_validator_config( + 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4} + ) + ]) def test_password_unicode_normalization(self): """ Tests that validate_password normalizes passwords """ # s ̣ ̇ (s with combining dot below and combining dot above) @@ -70,7 +80,7 @@ class PasswordPolicyValidatorsTestCase(unittest.TestCase): # When we normalize we expect the not_normalized password to fail # because it should be normalized to '\u1E69' -> ṩ self.validation_errors_checker(not_normalized_password, - 'This password is too short. It must contain at least 2 characters.') + 'This password is too short. It must contain at least 4 characters.') @data( ([create_validator_config('common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 2})], # lint-amnesty, pylint: disable=line-too-long diff --git a/common/static/data/geoip/GeoLite2-Country.mmdb b/common/static/data/geoip/GeoLite2-Country.mmdb index 46e3379657..d5a6c3e7b7 100644 Binary files a/common/static/data/geoip/GeoLite2-Country.mmdb and b/common/static/data/geoip/GeoLite2-Country.mmdb differ diff --git a/conf/locale/ar/LC_MESSAGES/django.po b/conf/locale/ar/LC_MESSAGES/django.po index 150f0b8043..eac51e287d 100644 --- a/conf/locale/ar/LC_MESSAGES/django.po +++ b/conf/locale/ar/LC_MESSAGES/django.po @@ -259,7 +259,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: NELC Open edX Translation , 2020\n" "Language-Team: Arabic (https://app.transifex.com/open-edx/teams/6205/ar/)\n" @@ -3799,12 +3799,12 @@ msgid "Good" msgstr "جيّد" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7601,6 +7601,14 @@ msgstr "أردج {country} على اللائحة السوداء للمساق {co msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7637,6 +7645,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/ar/LC_MESSAGES/djangojs.po b/conf/locale/ar/LC_MESSAGES/djangojs.po index 09d3be4a95..4c3922ce7e 100644 --- a/conf/locale/ar/LC_MESSAGES/djangojs.po +++ b/conf/locale/ar/LC_MESSAGES/djangojs.po @@ -194,7 +194,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Abderraouf Mehdi Bouhali , 2022\n" "Language-Team: Arabic (http://app.transifex.com/open-edx/edx-platform/language/ar/)\n" diff --git a/conf/locale/ca/LC_MESSAGES/django.po b/conf/locale/ca/LC_MESSAGES/django.po index 418d0a02ab..409ce7fb74 100644 --- a/conf/locale/ca/LC_MESSAGES/django.po +++ b/conf/locale/ca/LC_MESSAGES/django.po @@ -67,7 +67,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Catalan (https://app.transifex.com/open-edx/teams/6205/ca/)\n" @@ -3250,12 +3250,12 @@ msgid "Good" msgstr "" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6803,6 +6803,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6839,6 +6847,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/ca/LC_MESSAGES/djangojs.po b/conf/locale/ca/LC_MESSAGES/djangojs.po index 9e6413627b..5e9a5cd774 100644 --- a/conf/locale/ca/LC_MESSAGES/djangojs.po +++ b/conf/locale/ca/LC_MESSAGES/djangojs.po @@ -48,7 +48,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Catalan (http://app.transifex.com/open-edx/edx-platform/language/ca/)\n" diff --git a/conf/locale/de_DE/LC_MESSAGES/django.po b/conf/locale/de_DE/LC_MESSAGES/django.po index ce94b6980b..f7bc07af7b 100644 --- a/conf/locale/de_DE/LC_MESSAGES/django.po +++ b/conf/locale/de_DE/LC_MESSAGES/django.po @@ -177,7 +177,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Stefania Trabucchi , 2019\n" "Language-Team: German (Germany) (https://app.transifex.com/open-edx/teams/6205/de_DE/)\n" @@ -3691,12 +3691,12 @@ msgid "Good" msgstr "Gut" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7563,6 +7563,14 @@ msgstr "Blacklist {country} für {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7599,6 +7607,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/de_DE/LC_MESSAGES/djangojs.po b/conf/locale/de_DE/LC_MESSAGES/djangojs.po index 8dc44711bb..27dc186b42 100644 --- a/conf/locale/de_DE/LC_MESSAGES/djangojs.po +++ b/conf/locale/de_DE/LC_MESSAGES/djangojs.po @@ -134,7 +134,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Alfredo Guillem, 2022\n" "Language-Team: German (Germany) (http://app.transifex.com/open-edx/edx-platform/language/de_DE/)\n" diff --git a/conf/locale/el/LC_MESSAGES/django.po b/conf/locale/el/LC_MESSAGES/django.po index f6ce1c8a49..956aeba85e 100644 --- a/conf/locale/el/LC_MESSAGES/django.po +++ b/conf/locale/el/LC_MESSAGES/django.po @@ -98,7 +98,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Greek (https://app.transifex.com/open-edx/teams/6205/el/)\n" @@ -3376,12 +3376,12 @@ msgid "Good" msgstr "" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6950,6 +6950,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6986,6 +6994,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/el/LC_MESSAGES/djangojs.po b/conf/locale/el/LC_MESSAGES/djangojs.po index 19025b0dd5..2355c9b335 100644 --- a/conf/locale/el/LC_MESSAGES/djangojs.po +++ b/conf/locale/el/LC_MESSAGES/djangojs.po @@ -87,7 +87,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Angelos Chraniotis, 2023\n" "Language-Team: Greek (http://app.transifex.com/open-edx/edx-platform/language/el/)\n" diff --git a/conf/locale/en/LC_MESSAGES/django.po b/conf/locale/en/LC_MESSAGES/django.po index 52f45b05ba..4c4bfdcc5e 100644 --- a/conf/locale/en/LC_MESSAGES/django.po +++ b/conf/locale/en/LC_MESSAGES/django.po @@ -38,8 +38,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.857987\n" +"POT-Creation-Date: 2023-10-22 20:35+0000\n" +"PO-Revision-Date: 2023-10-22 20:35:52.246505\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: en\n" @@ -1827,53 +1827,6 @@ msgstr "" msgid "This value must be all lowercase." msgstr "" -#: lms/djangoapps/badges/models.py -msgid "The course mode for this badge image. For example, \"verified\" or \"honor\"." -msgstr "" - -#: lms/djangoapps/badges/models.py -msgid "" -"Badge images must be square PNG files. The file size should be under 250KB." -msgstr "" - -#: lms/djangoapps/badges/models.py -msgid "" -"Set this value to True if you want this image to be the default image for " -"any course modes that do not have a specified badge image. You can have only" -" one default image." -msgstr "" - -#: lms/djangoapps/badges/models.py -msgid "There can be only one default image." -msgstr "" - -#: lms/djangoapps/badges/models.py -msgid "" -"On each line, put the number of completed courses to award a badge for, a " -"comma, and the slug of a badge class you have created that has the issuing " -"component 'openedx__course'. For example: 3,enrolled_3_courses" -msgstr "" - -#: lms/djangoapps/badges/models.py -msgid "" -"On each line, put the number of enrolled courses to award a badge for, a " -"comma, and the slug of a badge class you have created that has the issuing " -"component 'openedx__course'. For example: 3,enrolled_3_courses" -msgstr "" - -#: lms/djangoapps/badges/models.py -msgid "" -"Each line is a comma-separated list. The first item in each line is the slug" -" of a badge class you have created that has an issuing component of " -"'openedx__course'. The remaining items in each line are the course keys the " -"learner needs to complete to be awarded the badge. For example: " -"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second" -msgstr "" - -#: lms/djangoapps/badges/models.py -msgid "Please check the syntax of your entry." -msgstr "" - #. Translators: This string is used across Open edX installations #. as a callback to edX. Please do not translate `edX.org` #: lms/djangoapps/branding/api.py @@ -3235,12 +3188,12 @@ msgid "Good" msgstr "" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6853,6 +6806,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " @@ -9737,16 +9704,6 @@ msgid "" "thumbnail image on the Settings & Details page." msgstr "" -#: xmodule/course_block.py -msgid "Issue Open Badges" -msgstr "" - -#: xmodule/course_block.py -msgid "" -"Issue Open Badges badges for this course. Badges are generated when " -"certificates are created." -msgstr "" - #: xmodule/course_block.py msgid "" "Use this setting only when generating PDF certificates. Between quotation " @@ -18758,7 +18715,7 @@ msgid "Studio Accessibility Policy" msgstr "" #: cms/templates/asset_index.html cms/templates/widgets/header.html -msgid "Files & Uploads" +msgid "Files" msgstr "" #: cms/templates/certificates.html @@ -20410,6 +20367,10 @@ msgstr "" msgid "Libraries" msgstr "" +#: cms/templates/index.html +msgid "Taxonomies" +msgstr "" + #: cms/templates/index.html msgid "Are you staff on an existing {studio_name} course?" msgstr "" diff --git a/conf/locale/en/LC_MESSAGES/djangojs.po b/conf/locale/en/LC_MESSAGES/djangojs.po index d0b2f6871c..c200d37c47 100644 --- a/conf/locale/en/LC_MESSAGES/djangojs.po +++ b/conf/locale/en/LC_MESSAGES/djangojs.po @@ -32,8 +32,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.819628\n" +"POT-Creation-Date: 2023-10-22 20:35+0000\n" +"PO-Revision-Date: 2023-10-22 20:35:52.488124\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: en\n" @@ -132,7 +132,6 @@ msgstr "" #: cms/templates/js/course-video-settings.underscore #: common/static/common/templates/discussion/templates.underscore #: common/static/common/templates/image-modal.underscore -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore msgid "Close" msgstr "" @@ -3679,10 +3678,6 @@ msgid "" "interests are, why you're taking courses, or what you hope to learn." msgstr "" -#: openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js -msgid "Accomplishments Pagination" -msgstr "" - #: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js msgid "Account Settings page." msgstr "" @@ -3711,10 +3706,6 @@ msgstr "" msgid "About Me" msgstr "" -#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js -msgid "Accomplishments" -msgstr "" - #: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js msgid "Profile" msgstr "" @@ -9201,32 +9192,6 @@ msgstr[1] "" msgid "Sorry, no results were found." msgstr "" -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -#, python-format -msgid "Share your \"%(display_name)s\" award" -msgstr "" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -msgid "Share" -msgstr "" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -#, python-format -msgid "Earned %(created)s." -msgstr "" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "What's Your Next Accomplishment?" -msgstr "" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "Start working toward your next learning goal." -msgstr "" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "Find a course" -msgstr "" - #: openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore msgid "You are currently sharing a limited profile." msgstr "" @@ -9235,30 +9200,6 @@ msgstr "" msgid "This learner is currently sharing a limited profile." msgstr "" -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "Share on Mozilla Backpack" -msgstr "" - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"To share your certificate on Mozilla Backpack, you must first have a " -"Backpack account. Complete the following steps to add your certificate to " -"Backpack." -msgstr "" - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"Create a {link_start}Mozilla Backpack{link_end} account, or log in to your " -"existing account" -msgstr "" - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"{download_link_start}Download this image (right-click or option-click, save " -"as){link_end} and then {upload_link_start}upload{link_end} it to your " -"backpack." -msgstr "" - #: cms/templates/js/access-editor.underscore msgid "Limit Access" msgstr "" diff --git a/conf/locale/eo/LC_MESSAGES/django.mo b/conf/locale/eo/LC_MESSAGES/django.mo index bacfc67180..b521617652 100644 Binary files a/conf/locale/eo/LC_MESSAGES/django.mo and b/conf/locale/eo/LC_MESSAGES/django.mo differ diff --git a/conf/locale/eo/LC_MESSAGES/django.po b/conf/locale/eo/LC_MESSAGES/django.po index 1892fb2de7..cc8726ae8d 100644 --- a/conf/locale/eo/LC_MESSAGES/django.po +++ b/conf/locale/eo/LC_MESSAGES/django.po @@ -38,8 +38,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.857987\n" +"POT-Creation-Date: 2023-10-22 20:35+0000\n" +"PO-Revision-Date: 2023-10-22 20:35:52.246505\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: eo\n" @@ -2320,90 +2320,6 @@ msgid "This value must be all lowercase." msgstr "" "Thïs välüé müst ßé äll löwérçäsé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#" -#: lms/djangoapps/badges/models.py -msgid "The course mode for this badge image. For example, \"verified\" or \"honor\"." -msgstr "" -"Thé çöürsé mödé för thïs ßädgé ïmägé. För éxämplé, \"vérïfïéd\" ör " -"\"hönör\". Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя#" - -#: lms/djangoapps/badges/models.py -msgid "" -"Badge images must be square PNG files. The file size should be under 250KB." -msgstr "" -"Bädgé ïmägés müst ßé sqüäré PNG fïlés. Thé fïlé sïzé shöüld ßé ündér 250KB. " -"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#" - -#: lms/djangoapps/badges/models.py -msgid "" -"Set this value to True if you want this image to be the default image for " -"any course modes that do not have a specified badge image. You can have only" -" one default image." -msgstr "" -"Sét thïs välüé tö Trüé ïf ýöü wänt thïs ïmägé tö ßé thé défäült ïmägé för " -"äný çöürsé mödés thät dö nöt hävé ä spéçïfïéd ßädgé ïmägé. Ýöü çän hävé önlý" -" öné défäült ïmägé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg " -"єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт " -"єηιм α∂ мιηιм νєηιαм, qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт " -"αłιqυιρ єχ єα ¢σммσ∂σ ¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη " -"νσłυρтαтє νєłιт єѕѕє ¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт " -"σ¢¢αє¢αт ¢υρι∂αтαт ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒ#" - -#: lms/djangoapps/badges/models.py -msgid "There can be only one default image." -msgstr "" -"Théré çän ßé önlý öné défäült ïmägé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " -"¢σηѕє¢тєтυ#" - -#: lms/djangoapps/badges/models.py -msgid "" -"On each line, put the number of completed courses to award a badge for, a " -"comma, and the slug of a badge class you have created that has the issuing " -"component 'openedx__course'. For example: 3,enrolled_3_courses" -msgstr "" -"Ön éäçh lïné, püt thé nümßér öf çömplétéd çöürsés tö äwärd ä ßädgé för, ä " -"çömmä, änd thé slüg öf ä ßädgé çläss ýöü hävé çréätéd thät häs thé ïssüïng " -"çömpönént 'öpénédx__çöürsé'. För éxämplé: 3,énrölléd_3_çöürsés Ⱡ'σяєм ιρѕυм " -"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя " -"ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм, qυιѕ " -"ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ " -"¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє " -"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρт#" - -#: lms/djangoapps/badges/models.py -msgid "" -"On each line, put the number of enrolled courses to award a badge for, a " -"comma, and the slug of a badge class you have created that has the issuing " -"component 'openedx__course'. For example: 3,enrolled_3_courses" -msgstr "" -"Ön éäçh lïné, püt thé nümßér öf énrölléd çöürsés tö äwärd ä ßädgé för, ä " -"çömmä, änd thé slüg öf ä ßädgé çläss ýöü hävé çréätéd thät häs thé ïssüïng " -"çömpönént 'öpénédx__çöürsé'. För éxämplé: 3,énrölléd_3_çöürsés Ⱡ'σяєм ιρѕυм " -"∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ єιυѕмσ∂ тємρσя " -"ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм νєηιαм, qυιѕ " -"ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα ¢σммσ∂σ " -"¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт єѕѕє " -"¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтє#" - -#: lms/djangoapps/badges/models.py -msgid "" -"Each line is a comma-separated list. The first item in each line is the slug" -" of a badge class you have created that has an issuing component of " -"'openedx__course'. The remaining items in each line are the course keys the " -"learner needs to complete to be awarded the badge. For example: " -"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second" -msgstr "" -"Éäçh lïné ïs ä çömmä-sépärätéd lïst. Thé fïrst ïtém ïn éäçh lïné ïs thé slüg" -" öf ä ßädgé çläss ýöü hävé çréätéd thät häs än ïssüïng çömpönént öf " -"'öpénédx__çöürsé'. Thé rémäïnïng ïtéms ïn éäçh lïné äré thé çöürsé kéýs thé " -"léärnér nééds tö çömplété tö ßé äwärdéd thé ßädgé. För éxämplé: " -"slüg_för_çömpsçï_çöürsés_gröüp_ßädgé,çöürsé-v1:ÇömpSçï+Çöürsé+Fïrst,çöürsé-v1:ÇömpsSçï+Çöürsé+Séçönd#" - -#: lms/djangoapps/badges/models.py -msgid "Please check the syntax of your entry." -msgstr "" -"Pléäsé çhéçk thé sýntäx öf ýöür éntrý. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " -"¢σηѕє¢тєтυя#" - #. Translators: This string is used across Open edX installations #. as a callback to edX. Please do not translate `edX.org` #: lms/djangoapps/branding/api.py @@ -4122,12 +4038,12 @@ msgid "Good" msgstr "Gööd Ⱡ'σяєм ι#" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "ýöür Ⱡ'σяєм ι#" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "théïr Ⱡ'σяєм ιρѕ#" @@ -8713,6 +8629,26 @@ msgstr "" "<{strong}>{post_title} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " "¢σηѕє¢т#" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" +"<{p}><{strong}>{replier_name} réspöndéd tö ä pöst ýöü’ré " +"föllöwïng: <{strong}>{post_title} Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт " +"αмєт, ¢σηѕє¢тєтυя α#" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" +"<{p}><{strong}>{replier_name} çömméntéd ön {author_name}'s " +"réspönsé ïn ä pöst ýöü’ré föllöwïng <{strong}>{post_title} " +"Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " @@ -12444,18 +12380,6 @@ msgstr "" "Édït thé nämé öf thé vïdéö thümßnäïl ïmägé fïlé. Ýöü çän sét thé vïdéö " "thümßnäïl ïmägé ön thé Séttïngs & Détäïls pägé. Ⱡ'σяєм ιρѕυм #" -#: xmodule/course_block.py -msgid "Issue Open Badges" -msgstr "Ìssüé Öpén Bädgés Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмє#" - -#: xmodule/course_block.py -msgid "" -"Issue Open Badges badges for this course. Badges are generated when " -"certificates are created." -msgstr "" -"Ìssüé Öpén Bädgés ßädgés för thïs çöürsé. Bädgés äré générätéd whén " -"çértïfïçätés äré çréätéd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σ#" - #: xmodule/course_block.py msgid "" "Use this setting only when generating PDF certificates. Between quotation " @@ -24182,8 +24106,8 @@ msgid "Studio Accessibility Policy" msgstr "Stüdïö Àççéssïßïlïtý Pölïçý Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє#" #: cms/templates/asset_index.html cms/templates/widgets/header.html -msgid "Files & Uploads" -msgstr "Fïlés & Ûplöäds Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#" +msgid "Files" +msgstr "Fïlés Ⱡ'σяєм ιρѕ#" #: cms/templates/certificates.html msgid "Course Certificates" @@ -26496,6 +26420,10 @@ msgstr "Àrçhïvéd Çöürsés Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αм msgid "Libraries" msgstr "Lïßrärïés Ⱡ'σяєм ιρѕυм ∂σł#" +#: cms/templates/index.html +msgid "Taxonomies" +msgstr "Täxönömïés Ⱡ'σяєм ιρѕυм ∂σłσ#" + #: cms/templates/index.html msgid "Are you staff on an existing {studio_name} course?" msgstr "" diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.mo b/conf/locale/eo/LC_MESSAGES/djangojs.mo index a0ae05bf43..04a57cb98c 100644 Binary files a/conf/locale/eo/LC_MESSAGES/djangojs.mo and b/conf/locale/eo/LC_MESSAGES/djangojs.mo differ diff --git a/conf/locale/eo/LC_MESSAGES/djangojs.po b/conf/locale/eo/LC_MESSAGES/djangojs.po index 31d5461819..b5201799f4 100644 --- a/conf/locale/eo/LC_MESSAGES/djangojs.po +++ b/conf/locale/eo/LC_MESSAGES/djangojs.po @@ -32,8 +32,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.819628\n" +"POT-Creation-Date: 2023-10-22 20:35+0000\n" +"PO-Revision-Date: 2023-10-22 20:35:52.488124\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: eo\n" @@ -132,7 +132,6 @@ msgstr "Ûplöädïng Ⱡ'σяєм ιρѕυм ∂σł#" #: cms/templates/js/course-video-settings.underscore #: common/static/common/templates/discussion/templates.underscore #: common/static/common/templates/image-modal.underscore -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore msgid "Close" msgstr "Çlösé Ⱡ'σяєм ιρѕ#" @@ -4634,10 +4633,6 @@ msgstr "" "¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт ¢υρι∂αтαт " "ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт αηιм ι∂ єѕт łα#" -#: openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js -msgid "Accomplishments Pagination" -msgstr "Àççömplïshménts Pägïnätïön Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" - #: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js msgid "Account Settings page." msgstr "Àççöünt Séttïngs pägé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#" @@ -4672,10 +4667,6 @@ msgstr "Pröfïlé ïmägé för {username} Ⱡ'σяєм ιρѕυм ∂σłσя msgid "About Me" msgstr "Àßöüt Mé Ⱡ'σяєм ιρѕυм ∂#" -#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js -msgid "Accomplishments" -msgstr "Àççömplïshménts Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт α#" - #: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js msgid "Profile" msgstr "Pröfïlé Ⱡ'σяєм ιρѕυм #" @@ -11066,35 +11057,6 @@ msgstr[1] "Löäd néxt {num_items} résülts Ⱡ'σяєм ιρѕυм ∂σłσ msgid "Sorry, no results were found." msgstr "Sörrý, nö résülts wéré föünd. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢#" -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -#, python-format -msgid "Share your \"%(display_name)s\" award" -msgstr "Shäré ýöür \"%(display_name)s\" äwärd Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢#" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -msgid "Share" -msgstr "Shäré Ⱡ'σяєм ιρѕ#" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -#, python-format -msgid "Earned %(created)s." -msgstr "Éärnéd %(created)s. Ⱡ'σяєм ιρѕυм ∂σłσя #" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "What's Your Next Accomplishment?" -msgstr "" -"Whät's Ýöür Néxt Àççömplïshmént? Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тє#" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "Start working toward your next learning goal." -msgstr "" -"Stärt wörkïng töwärd ýöür néxt léärnïng göäl. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, " -"¢σηѕє¢тєтυя #" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "Find a course" -msgstr "Fïnd ä çöürsé Ⱡ'σяєм ιρѕυм ∂σłσя ѕι#" - #: openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore msgid "You are currently sharing a limited profile." msgstr "" @@ -11107,43 +11069,6 @@ msgstr "" "Thïs léärnér ïs çürréntlý shärïng ä lïmïtéd pröfïlé. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт " "αмєт, ¢σηѕє¢тєтυя α#" -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "Share on Mozilla Backpack" -msgstr "Shäré ön Mözïllä Bäçkpäçk Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕ#" - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"To share your certificate on Mozilla Backpack, you must first have a " -"Backpack account. Complete the following steps to add your certificate to " -"Backpack." -msgstr "" -"Tö shäré ýöür çértïfïçäté ön Mözïllä Bäçkpäçk, ýöü müst fïrst hävé ä " -"Bäçkpäçk äççöünt. Çömplété thé föllöwïng stéps tö ädd ýöür çértïfïçäté tö " -"Bäçkpäçk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυя α∂ιριѕι¢ιηg єłιт, ѕє∂ ∂σ " -"єιυѕмσ∂ тємρσя ιη¢ι∂ι∂υηт υт łαвσяє єт ∂σłσяє мαgηα αłιqυα. υт єηιм α∂ мιηιм" -" νєηιαм, qυιѕ ησѕтяυ∂ єχєя¢ιтαтιση υłłαм¢σ łαвσяιѕ ηιѕι υт αłιqυιρ єχ єα " -"¢σммσ∂σ ¢σηѕєqυαт. ∂υιѕ αυтє ιяυяє ∂σłσя ιη яєρяєнєη∂єяιт ιη νσłυρтαтє νєłιт" -" єѕѕє ¢ιłłυм ∂σłσяє єυ ƒυgιαт ηυłłα ραяιαтυя. єχ¢єρтєυя ѕιηт σ¢¢αє¢αт " -"¢υρι∂αтαт ηση ρяσι∂єηт, ѕυηт ιη ¢υłρα qυι σƒƒι¢ια ∂єѕєяυηт мσłłιт#" - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"Create a {link_start}Mozilla Backpack{link_end} account, or log in to your " -"existing account" -msgstr "" -"Çréäté ä {link_start}Mözïllä Bäçkpäçk{link_end} äççöünt, ör lög ïn tö ýöür " -"éxïstïng äççöünt Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт αмєт, ¢σηѕє¢тєтυ#" - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"{download_link_start}Download this image (right-click or option-click, save " -"as){link_end} and then {upload_link_start}upload{link_end} it to your " -"backpack." -msgstr "" -"{download_link_start}Döwnlöäd thïs ïmägé (rïght-çlïçk ör öptïön-çlïçk, sävé " -"äs){link_end} änd thén {upload_link_start}üplöäd{link_end} ït tö ýöür " -"ßäçkpäçk. Ⱡ'σяєм ιρѕυм ∂σłσя ѕιт#" - #: cms/templates/js/access-editor.underscore msgid "Limit Access" msgstr "Lïmït Àççéss Ⱡ'σяєм ιρѕυм ∂σłσя ѕ#" diff --git a/conf/locale/es_419/LC_MESSAGES/django.mo b/conf/locale/es_419/LC_MESSAGES/django.mo index e4fb596ceb..0415da9a70 100644 Binary files a/conf/locale/es_419/LC_MESSAGES/django.mo and b/conf/locale/es_419/LC_MESSAGES/django.mo differ diff --git a/conf/locale/es_419/LC_MESSAGES/django.po b/conf/locale/es_419/LC_MESSAGES/django.po index d5b34818ea..8756d24480 100644 --- a/conf/locale/es_419/LC_MESSAGES/django.po +++ b/conf/locale/es_419/LC_MESSAGES/django.po @@ -296,7 +296,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Albeiro Gonzalez , 2019\n" "Language-Team: Spanish (Latin America) (https://app.transifex.com/open-edx/teams/6205/es_419/)\n" @@ -3969,12 +3969,12 @@ msgid "Good" msgstr "Bien" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "su" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "su" @@ -7970,6 +7970,14 @@ msgstr "Incluir en lista negra a {country} para el curso {course}" msgid "Learner Pathways" msgstr "Rutas de aprendizaje" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "Aplicación de notificación" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "Tipo de notificación" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -8017,6 +8025,25 @@ msgstr "" "<{p}><{strong}>{username} preguntó " "<{strong}>{post_title}" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" +"<{p}><{strong}>{replier_name} respondió a su publicación " +"<{strong}>{post_title}" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" +"<{p}><{strong}>{replier_name} respondió el " +"<{strong}>{author_name} respuesta a su publicación " +"<{strong}>{post_title}" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/es_419/LC_MESSAGES/djangojs.po b/conf/locale/es_419/LC_MESSAGES/djangojs.po index 81688ff415..7772c64531 100644 --- a/conf/locale/es_419/LC_MESSAGES/djangojs.po +++ b/conf/locale/es_419/LC_MESSAGES/djangojs.po @@ -182,7 +182,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Jesica Greco, 2023\n" "Language-Team: Spanish (Latin America) (http://app.transifex.com/open-edx/edx-platform/language/es_419/)\n" diff --git a/conf/locale/eu_ES/LC_MESSAGES/django.po b/conf/locale/eu_ES/LC_MESSAGES/django.po index 3942a14866..3d2c37bcd9 100644 --- a/conf/locale/eu_ES/LC_MESSAGES/django.po +++ b/conf/locale/eu_ES/LC_MESSAGES/django.po @@ -64,7 +64,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Abel Camacho , 2019\n" "Language-Team: Basque (Spain) (https://app.transifex.com/open-edx/teams/6205/eu_ES/)\n" @@ -3315,12 +3315,12 @@ msgid "Good" msgstr "Ona" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6917,6 +6917,14 @@ msgstr "{country} zerrenda beltza {course}-rako" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6953,6 +6961,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/eu_ES/LC_MESSAGES/djangojs.po b/conf/locale/eu_ES/LC_MESSAGES/djangojs.po index 93b1d94852..c6202f7069 100644 --- a/conf/locale/eu_ES/LC_MESSAGES/djangojs.po +++ b/conf/locale/eu_ES/LC_MESSAGES/djangojs.po @@ -50,7 +50,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Abel Camacho , 2017,2019-2020\n" "Language-Team: Basque (Spain) (http://app.transifex.com/open-edx/edx-platform/language/eu_ES/)\n" diff --git a/conf/locale/fa_IR/LC_MESSAGES/django.po b/conf/locale/fa_IR/LC_MESSAGES/django.po index 2458f76795..973512ea8a 100644 --- a/conf/locale/fa_IR/LC_MESSAGES/django.po +++ b/conf/locale/fa_IR/LC_MESSAGES/django.po @@ -134,7 +134,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-24 20:43+0000\n" +"POT-Creation-Date: 2023-10-15 20:42+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Somaye Joolaee, 2022\n" "Language-Team: Persian (Iran) (https://app.transifex.com/open-edx/teams/6205/fa_IR/)\n" @@ -3664,12 +3664,12 @@ msgid "Good" msgstr "خوب" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7548,6 +7548,14 @@ msgstr "فهرست سیاه {country} برای {course}" msgid "Learner Pathways" msgstr "مسیرهای یادگیرنده" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7588,6 +7596,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/fa_IR/LC_MESSAGES/djangojs.po b/conf/locale/fa_IR/LC_MESSAGES/djangojs.po index 24db4fbf75..39d4a4a06f 100644 --- a/conf/locale/fa_IR/LC_MESSAGES/djangojs.po +++ b/conf/locale/fa_IR/LC_MESSAGES/djangojs.po @@ -88,7 +88,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: SeyedMahdi Saeid , 2023\n" "Language-Team: Persian (Iran) (http://app.transifex.com/open-edx/edx-platform/language/fa_IR/)\n" diff --git a/conf/locale/fr/LC_MESSAGES/django.po b/conf/locale/fr/LC_MESSAGES/django.po index 5b339180ad..908197c5c5 100644 --- a/conf/locale/fr/LC_MESSAGES/django.po +++ b/conf/locale/fr/LC_MESSAGES/django.po @@ -316,7 +316,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Alexandre DS , 2020\n" "Language-Team: French (https://app.transifex.com/open-edx/teams/6205/fr/)\n" @@ -3982,12 +3982,12 @@ msgid "Good" msgstr "Bien" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7967,6 +7967,14 @@ msgstr "Mettre sur le pays {country} sur liste noire pour {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -8003,6 +8011,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/fr/LC_MESSAGES/djangojs.po b/conf/locale/fr/LC_MESSAGES/djangojs.po index ea4134d773..ac60a46e94 100644 --- a/conf/locale/fr/LC_MESSAGES/djangojs.po +++ b/conf/locale/fr/LC_MESSAGES/djangojs.po @@ -220,7 +220,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Pierre Mailhot , 2023\n" "Language-Team: French (http://app.transifex.com/open-edx/edx-platform/language/fr/)\n" diff --git a/conf/locale/he/LC_MESSAGES/django.po b/conf/locale/he/LC_MESSAGES/django.po index d29bdaef1f..2ed94f23c8 100644 --- a/conf/locale/he/LC_MESSAGES/django.po +++ b/conf/locale/he/LC_MESSAGES/django.po @@ -19121,10 +19121,6 @@ msgid "" "subjects." msgstr "עיין בקורסים שהושקו לאחרונה וראה מה חדש בנושאים האהובים עליך." -#: themes/edx.org/lms/templates/dashboard.html -msgid "Take advantage of free coaching!" -msgstr "" - #: themes/edx.org/lms/templates/dashboard.html msgid "Get Started" msgstr "" diff --git a/conf/locale/hi/LC_MESSAGES/django.po b/conf/locale/hi/LC_MESSAGES/django.po index 3ecb5136ab..a7bca1b22a 100644 --- a/conf/locale/hi/LC_MESSAGES/django.po +++ b/conf/locale/hi/LC_MESSAGES/django.po @@ -18201,10 +18201,6 @@ msgid "" "subjects." msgstr "" -#: themes/edx.org/lms/templates/dashboard.html -msgid "Take advantage of free coaching!" -msgstr "" - #: themes/edx.org/lms/templates/dashboard.html msgid "Get Started" msgstr "" diff --git a/conf/locale/id/LC_MESSAGES/django.po b/conf/locale/id/LC_MESSAGES/django.po index 24719010aa..8dbe2acb83 100644 --- a/conf/locale/id/LC_MESSAGES/django.po +++ b/conf/locale/id/LC_MESSAGES/django.po @@ -106,7 +106,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Aprisa Chrysantina , 2019\n" "Language-Team: Indonesian (https://app.transifex.com/open-edx/teams/6205/id/)\n" @@ -3463,12 +3463,12 @@ msgid "Good" msgstr "Baik" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7140,6 +7140,14 @@ msgstr "Daftar hitamkan {country} untuk {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7176,6 +7184,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/id/LC_MESSAGES/djangojs.po b/conf/locale/id/LC_MESSAGES/djangojs.po index fab593b619..2da633adeb 100644 --- a/conf/locale/id/LC_MESSAGES/djangojs.po +++ b/conf/locale/id/LC_MESSAGES/djangojs.po @@ -84,7 +84,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Faizar Septiawan , 2023\n" "Language-Team: Indonesian (http://app.transifex.com/open-edx/edx-platform/language/id/)\n" diff --git a/conf/locale/it_IT/LC_MESSAGES/django.po b/conf/locale/it_IT/LC_MESSAGES/django.po index ae49f47dda..13faaa463a 100644 --- a/conf/locale/it_IT/LC_MESSAGES/django.po +++ b/conf/locale/it_IT/LC_MESSAGES/django.po @@ -127,7 +127,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Ilaria Botti , 2021\n" "Language-Team: Italian (Italy) (https://app.transifex.com/open-edx/teams/6205/it_IT/)\n" @@ -3811,12 +3811,12 @@ msgid "Good" msgstr "Buono" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7814,6 +7814,14 @@ msgstr "Lista di proscrizione {country} per {course}" msgid "Learner Pathways" msgstr "Percorsi di apprendimento" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7850,6 +7858,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/it_IT/LC_MESSAGES/djangojs.po b/conf/locale/it_IT/LC_MESSAGES/djangojs.po index fc3081764f..d7b7a8a435 100644 --- a/conf/locale/it_IT/LC_MESSAGES/djangojs.po +++ b/conf/locale/it_IT/LC_MESSAGES/djangojs.po @@ -113,7 +113,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Ilaria Botti , 2022\n" "Language-Team: Italian (Italy) (http://app.transifex.com/open-edx/edx-platform/language/it_IT/)\n" diff --git a/conf/locale/ja_JP/LC_MESSAGES/django.po b/conf/locale/ja_JP/LC_MESSAGES/django.po index 9f67617d19..f5dceb99c8 100644 --- a/conf/locale/ja_JP/LC_MESSAGES/django.po +++ b/conf/locale/ja_JP/LC_MESSAGES/django.po @@ -114,7 +114,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Japanese (Japan) (https://app.transifex.com/open-edx/teams/6205/ja_JP/)\n" @@ -3311,12 +3311,12 @@ msgid "Good" msgstr "良い" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6852,6 +6852,14 @@ msgstr "{course} 講座のブラックリスト {country} " msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6888,6 +6896,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/ja_JP/LC_MESSAGES/djangojs.po b/conf/locale/ja_JP/LC_MESSAGES/djangojs.po index 8bcde97424..330c7a8099 100644 --- a/conf/locale/ja_JP/LC_MESSAGES/djangojs.po +++ b/conf/locale/ja_JP/LC_MESSAGES/djangojs.po @@ -78,7 +78,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Kyoto University , 2017\n" "Language-Team: Japanese (Japan) (http://app.transifex.com/open-edx/edx-platform/language/ja_JP/)\n" diff --git a/conf/locale/ka/LC_MESSAGES/django.po b/conf/locale/ka/LC_MESSAGES/django.po index 64c62625d1..936221fd80 100644 --- a/conf/locale/ka/LC_MESSAGES/django.po +++ b/conf/locale/ka/LC_MESSAGES/django.po @@ -60,7 +60,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Georgian (https://app.transifex.com/open-edx/teams/6205/ka/)\n" @@ -3372,12 +3372,12 @@ msgid "Good" msgstr "კარგია" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7032,6 +7032,14 @@ msgstr "დაემატოს \"{country}\" {course} კურსის შ msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7068,6 +7076,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/ka/LC_MESSAGES/djangojs.po b/conf/locale/ka/LC_MESSAGES/djangojs.po index 20eaf4425c..5705e22be2 100644 --- a/conf/locale/ka/LC_MESSAGES/djangojs.po +++ b/conf/locale/ka/LC_MESSAGES/djangojs.po @@ -56,7 +56,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Lasha Kokilashvili, 2018\n" "Language-Team: Georgian (http://app.transifex.com/open-edx/edx-platform/language/ka/)\n" diff --git a/conf/locale/ko_KR/LC_MESSAGES/django.po b/conf/locale/ko_KR/LC_MESSAGES/django.po index 88054ae64f..3992de5d8d 100644 --- a/conf/locale/ko_KR/LC_MESSAGES/django.po +++ b/conf/locale/ko_KR/LC_MESSAGES/django.po @@ -17370,10 +17370,6 @@ msgid "" "subjects." msgstr "" -#: themes/edx.org/lms/templates/dashboard.html -msgid "Take advantage of free coaching!" -msgstr "" - #: themes/edx.org/lms/templates/dashboard.html msgid "Get Started" msgstr "" diff --git a/conf/locale/lt_LT/LC_MESSAGES/django.po b/conf/locale/lt_LT/LC_MESSAGES/django.po index af46587ba2..18fb6c32e4 100644 --- a/conf/locale/lt_LT/LC_MESSAGES/django.po +++ b/conf/locale/lt_LT/LC_MESSAGES/django.po @@ -72,7 +72,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Lithuanian (Lithuania) (https://app.transifex.com/open-edx/teams/6205/lt_LT/)\n" @@ -3266,12 +3266,12 @@ msgid "Good" msgstr "" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6767,6 +6767,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6803,6 +6811,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/lt_LT/LC_MESSAGES/djangojs.po b/conf/locale/lt_LT/LC_MESSAGES/djangojs.po index 4c162463f0..01e2a4c66e 100644 --- a/conf/locale/lt_LT/LC_MESSAGES/djangojs.po +++ b/conf/locale/lt_LT/LC_MESSAGES/djangojs.po @@ -50,7 +50,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Riina , 2014-2015\n" "Language-Team: Lithuanian (Lithuania) (http://app.transifex.com/open-edx/edx-platform/language/lt_LT/)\n" diff --git a/conf/locale/lv/LC_MESSAGES/django.po b/conf/locale/lv/LC_MESSAGES/django.po index 20725bde01..5b107efa2f 100644 --- a/conf/locale/lv/LC_MESSAGES/django.po +++ b/conf/locale/lv/LC_MESSAGES/django.po @@ -49,7 +49,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Latvian (https://app.transifex.com/open-edx/teams/6205/lv/)\n" @@ -3436,12 +3436,12 @@ msgid "Good" msgstr "Labi" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7168,6 +7168,14 @@ msgstr "Ievietot valsti {country} melnajā sarakstā kursam {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7204,6 +7212,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/lv/LC_MESSAGES/djangojs.po b/conf/locale/lv/LC_MESSAGES/djangojs.po index 4dea7b3416..584ef278f7 100644 --- a/conf/locale/lv/LC_MESSAGES/djangojs.po +++ b/conf/locale/lv/LC_MESSAGES/djangojs.po @@ -40,7 +40,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: LTMC Latvijas Tiesnešu mācību centrs , 2019\n" "Language-Team: Latvian (http://app.transifex.com/open-edx/edx-platform/language/lv/)\n" diff --git a/conf/locale/mn/LC_MESSAGES/django.po b/conf/locale/mn/LC_MESSAGES/django.po index 562c794239..0960b5b29c 100644 --- a/conf/locale/mn/LC_MESSAGES/django.po +++ b/conf/locale/mn/LC_MESSAGES/django.po @@ -74,7 +74,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Mongolian (https://app.transifex.com/open-edx/teams/6205/mn/)\n" @@ -3292,12 +3292,12 @@ msgid "Good" msgstr "" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6824,6 +6824,14 @@ msgstr "{country} улсыг {course} хичээлийн хар жагсаал msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6860,6 +6868,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/mn/LC_MESSAGES/djangojs.po b/conf/locale/mn/LC_MESSAGES/djangojs.po index a5fbdfbfaa..08a83d7d7f 100644 --- a/conf/locale/mn/LC_MESSAGES/djangojs.po +++ b/conf/locale/mn/LC_MESSAGES/djangojs.po @@ -63,7 +63,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Myagmarjav Enkhbileg , 2018\n" "Language-Team: Mongolian (http://app.transifex.com/open-edx/edx-platform/language/mn/)\n" diff --git a/conf/locale/pl/LC_MESSAGES/django.po b/conf/locale/pl/LC_MESSAGES/django.po index da1f4536ac..f8c273e0b6 100644 --- a/conf/locale/pl/LC_MESSAGES/django.po +++ b/conf/locale/pl/LC_MESSAGES/django.po @@ -150,7 +150,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Marcin Miłek, 2022\n" "Language-Team: Polish (https://app.transifex.com/open-edx/teams/6205/pl/)\n" @@ -3554,12 +3554,12 @@ msgid "Good" msgstr "Dobrze" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7296,6 +7296,14 @@ msgstr "Dodaj kraj \"{country}\" do czarnej listy kursu {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7332,6 +7340,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/pl/LC_MESSAGES/djangojs.po b/conf/locale/pl/LC_MESSAGES/djangojs.po index e9a391e6c7..98d7624c18 100644 --- a/conf/locale/pl/LC_MESSAGES/djangojs.po +++ b/conf/locale/pl/LC_MESSAGES/djangojs.po @@ -116,7 +116,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Klara Sielicka-Baryłka, 2022\n" "Language-Team: Polish (http://app.transifex.com/open-edx/edx-platform/language/pl/)\n" diff --git a/conf/locale/pt_BR/LC_MESSAGES/django.po b/conf/locale/pt_BR/LC_MESSAGES/django.po index 6d9800d190..ed9a191556 100644 --- a/conf/locale/pt_BR/LC_MESSAGES/django.po +++ b/conf/locale/pt_BR/LC_MESSAGES/django.po @@ -19492,10 +19492,6 @@ msgid "" "subjects." msgstr "" -#: themes/edx.org/lms/templates/dashboard.html -msgid "Take advantage of free coaching!" -msgstr "" - #: themes/edx.org/lms/templates/dashboard.html msgid "Get Started" msgstr "" diff --git a/conf/locale/pt_BR/LC_MESSAGES/djangojs.po b/conf/locale/pt_BR/LC_MESSAGES/djangojs.po index 80dd53ef4f..614635706f 100644 --- a/conf/locale/pt_BR/LC_MESSAGES/djangojs.po +++ b/conf/locale/pt_BR/LC_MESSAGES/djangojs.po @@ -246,7 +246,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Rodrigo Rocha , 2020\n" "Language-Team: Portuguese (Brazil) (http://app.transifex.com/open-edx/edx-platform/language/pt_BR/)\n" diff --git a/conf/locale/pt_PT/LC_MESSAGES/django.po b/conf/locale/pt_PT/LC_MESSAGES/django.po index e6fe1acdb8..28569e7605 100644 --- a/conf/locale/pt_PT/LC_MESSAGES/django.po +++ b/conf/locale/pt_PT/LC_MESSAGES/django.po @@ -141,7 +141,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Cátia Lopes , 2019\n" "Language-Team: Portuguese (Portugal) (https://app.transifex.com/open-edx/teams/6205/pt_PT/)\n" @@ -3786,12 +3786,12 @@ msgid "Good" msgstr "Bom" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7773,6 +7773,14 @@ msgstr "Incluir na lista negra {country} para o curso {course}" msgid "Learner Pathways" msgstr "Trajetos do Estudante" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7809,6 +7817,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/pt_PT/LC_MESSAGES/djangojs.po b/conf/locale/pt_PT/LC_MESSAGES/djangojs.po index a3ef571d2c..a784860dd6 100644 --- a/conf/locale/pt_PT/LC_MESSAGES/djangojs.po +++ b/conf/locale/pt_PT/LC_MESSAGES/djangojs.po @@ -113,7 +113,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Ivo Branco , 2021,2023\n" "Language-Team: Portuguese (Portugal) (http://app.transifex.com/open-edx/edx-platform/language/pt_PT/)\n" diff --git a/conf/locale/rtl/LC_MESSAGES/django.mo b/conf/locale/rtl/LC_MESSAGES/django.mo index 8ad5d41434..65f38f3dde 100644 Binary files a/conf/locale/rtl/LC_MESSAGES/django.mo and b/conf/locale/rtl/LC_MESSAGES/django.mo differ diff --git a/conf/locale/rtl/LC_MESSAGES/django.po b/conf/locale/rtl/LC_MESSAGES/django.po index 85b4e8a6f3..abba53851d 100644 --- a/conf/locale/rtl/LC_MESSAGES/django.po +++ b/conf/locale/rtl/LC_MESSAGES/django.po @@ -38,8 +38,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.857987\n" +"POT-Creation-Date: 2023-10-22 20:35+0000\n" +"PO-Revision-Date: 2023-10-22 20:35:52.246505\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: rtl\n" @@ -2027,68 +2027,6 @@ msgstr "Ŧɥǝ bɐdƃǝ ᴉɯɐƃǝ ɟᴉlǝ sᴉzǝ ɯnsʇ bǝ lǝss ʇɥɐn 25 msgid "This value must be all lowercase." msgstr "Ŧɥᴉs ʌɐlnǝ ɯnsʇ bǝ ɐll løʍǝɹɔɐsǝ." -#: lms/djangoapps/badges/models.py -msgid "The course mode for this badge image. For example, \"verified\" or \"honor\"." -msgstr "Ŧɥǝ ɔønɹsǝ ɯødǝ ɟøɹ ʇɥᴉs bɐdƃǝ ᴉɯɐƃǝ. Føɹ ǝxɐɯdlǝ, \"ʌǝɹᴉɟᴉǝd\" øɹ \"ɥønøɹ\"." - -#: lms/djangoapps/badges/models.py -msgid "" -"Badge images must be square PNG files. The file size should be under 250KB." -msgstr "" -"Ƀɐdƃǝ ᴉɯɐƃǝs ɯnsʇ bǝ sbnɐɹǝ ⱣNǤ ɟᴉlǝs. Ŧɥǝ ɟᴉlǝ sᴉzǝ sɥønld bǝ nndǝɹ 250ꝀɃ." - -#: lms/djangoapps/badges/models.py -msgid "" -"Set this value to True if you want this image to be the default image for " -"any course modes that do not have a specified badge image. You can have only" -" one default image." -msgstr "" -"Sǝʇ ʇɥᴉs ʌɐlnǝ ʇø Ŧɹnǝ ᴉɟ ʎøn ʍɐnʇ ʇɥᴉs ᴉɯɐƃǝ ʇø bǝ ʇɥǝ dǝɟɐnlʇ ᴉɯɐƃǝ ɟøɹ " -"ɐnʎ ɔønɹsǝ ɯødǝs ʇɥɐʇ dø nøʇ ɥɐʌǝ ɐ sdǝɔᴉɟᴉǝd bɐdƃǝ ᴉɯɐƃǝ. Ɏøn ɔɐn ɥɐʌǝ ønlʎ" -" ønǝ dǝɟɐnlʇ ᴉɯɐƃǝ." - -#: lms/djangoapps/badges/models.py -msgid "There can be only one default image." -msgstr "Ŧɥǝɹǝ ɔɐn bǝ ønlʎ ønǝ dǝɟɐnlʇ ᴉɯɐƃǝ." - -#: lms/djangoapps/badges/models.py -msgid "" -"On each line, put the number of completed courses to award a badge for, a " -"comma, and the slug of a badge class you have created that has the issuing " -"component 'openedx__course'. For example: 3,enrolled_3_courses" -msgstr "" -"Øn ǝɐɔɥ lᴉnǝ, dnʇ ʇɥǝ nnɯbǝɹ øɟ ɔøɯdlǝʇǝd ɔønɹsǝs ʇø ɐʍɐɹd ɐ bɐdƃǝ ɟøɹ, ɐ " -"ɔøɯɯɐ, ɐnd ʇɥǝ slnƃ øɟ ɐ bɐdƃǝ ɔlɐss ʎøn ɥɐʌǝ ɔɹǝɐʇǝd ʇɥɐʇ ɥɐs ʇɥǝ ᴉssnᴉnƃ " -"ɔøɯdønǝnʇ 'ødǝnǝdx__ɔønɹsǝ'. Føɹ ǝxɐɯdlǝ: 3,ǝnɹøllǝd_3_ɔønɹsǝs" - -#: lms/djangoapps/badges/models.py -msgid "" -"On each line, put the number of enrolled courses to award a badge for, a " -"comma, and the slug of a badge class you have created that has the issuing " -"component 'openedx__course'. For example: 3,enrolled_3_courses" -msgstr "" -"Øn ǝɐɔɥ lᴉnǝ, dnʇ ʇɥǝ nnɯbǝɹ øɟ ǝnɹøllǝd ɔønɹsǝs ʇø ɐʍɐɹd ɐ bɐdƃǝ ɟøɹ, ɐ " -"ɔøɯɯɐ, ɐnd ʇɥǝ slnƃ øɟ ɐ bɐdƃǝ ɔlɐss ʎøn ɥɐʌǝ ɔɹǝɐʇǝd ʇɥɐʇ ɥɐs ʇɥǝ ᴉssnᴉnƃ " -"ɔøɯdønǝnʇ 'ødǝnǝdx__ɔønɹsǝ'. Føɹ ǝxɐɯdlǝ: 3,ǝnɹøllǝd_3_ɔønɹsǝs" - -#: lms/djangoapps/badges/models.py -msgid "" -"Each line is a comma-separated list. The first item in each line is the slug" -" of a badge class you have created that has an issuing component of " -"'openedx__course'. The remaining items in each line are the course keys the " -"learner needs to complete to be awarded the badge. For example: " -"slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second" -msgstr "" -"Ɇɐɔɥ lᴉnǝ ᴉs ɐ ɔøɯɯɐ-sǝdɐɹɐʇǝd lᴉsʇ. Ŧɥǝ ɟᴉɹsʇ ᴉʇǝɯ ᴉn ǝɐɔɥ lᴉnǝ ᴉs ʇɥǝ slnƃ" -" øɟ ɐ bɐdƃǝ ɔlɐss ʎøn ɥɐʌǝ ɔɹǝɐʇǝd ʇɥɐʇ ɥɐs ɐn ᴉssnᴉnƃ ɔøɯdønǝnʇ øɟ " -"'ødǝnǝdx__ɔønɹsǝ'. Ŧɥǝ ɹǝɯɐᴉnᴉnƃ ᴉʇǝɯs ᴉn ǝɐɔɥ lᴉnǝ ɐɹǝ ʇɥǝ ɔønɹsǝ ʞǝʎs ʇɥǝ " -"lǝɐɹnǝɹ nǝǝds ʇø ɔøɯdlǝʇǝ ʇø bǝ ɐʍɐɹdǝd ʇɥǝ bɐdƃǝ. Føɹ ǝxɐɯdlǝ: " -"slnƃ_ɟøɹ_ɔøɯdsɔᴉ_ɔønɹsǝs_ƃɹønd_bɐdƃǝ,ɔønɹsǝ-ʌ1:ȻøɯdSɔᴉ+Ȼønɹsǝ+Fᴉɹsʇ,ɔønɹsǝ-ʌ1:ȻøɯdsSɔᴉ+Ȼønɹsǝ+Sǝɔønd" - -#: lms/djangoapps/badges/models.py -msgid "Please check the syntax of your entry." -msgstr "Ᵽlǝɐsǝ ɔɥǝɔʞ ʇɥǝ sʎnʇɐx øɟ ʎønɹ ǝnʇɹʎ." - #. Translators: This string is used across Open edX installations #. as a callback to edX. Please do not translate `edX.org` #: lms/djangoapps/branding/api.py @@ -3595,12 +3533,12 @@ msgid "Good" msgstr "Ǥøød" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "ʎønɹ" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "ʇɥǝᴉɹ" @@ -7546,6 +7484,24 @@ msgstr "" "<{p}><{strong}>{username} ɐsʞǝd " "<{strong}>{post_title}" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" +"<{p}><{strong}>{replier_name} ɹǝsdøndǝd ʇø ɐ døsʇ ʎøn’ɹǝ " +"ɟølløʍᴉnƃ: <{strong}>{post_title}" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" +"<{p}><{strong}>{replier_name} ɔøɯɯǝnʇǝd øn {author_name}'s " +"ɹǝsdønsǝ ᴉn ɐ døsʇ ʎøn’ɹǝ ɟølløʍᴉnƃ <{strong}>{post_title}" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " @@ -10770,18 +10726,6 @@ msgstr "" "Ɇdᴉʇ ʇɥǝ nɐɯǝ øɟ ʇɥǝ ʌᴉdǝø ʇɥnɯbnɐᴉl ᴉɯɐƃǝ ɟᴉlǝ. Ɏøn ɔɐn sǝʇ ʇɥǝ ʌᴉdǝø " "ʇɥnɯbnɐᴉl ᴉɯɐƃǝ øn ʇɥǝ Sǝʇʇᴉnƃs & Đǝʇɐᴉls dɐƃǝ." -#: xmodule/course_block.py -msgid "Issue Open Badges" -msgstr "Ɨssnǝ Ødǝn Ƀɐdƃǝs" - -#: xmodule/course_block.py -msgid "" -"Issue Open Badges badges for this course. Badges are generated when " -"certificates are created." -msgstr "" -"Ɨssnǝ Ødǝn Ƀɐdƃǝs bɐdƃǝs ɟøɹ ʇɥᴉs ɔønɹsǝ. Ƀɐdƃǝs ɐɹǝ ƃǝnǝɹɐʇǝd ʍɥǝn " -"ɔǝɹʇᴉɟᴉɔɐʇǝs ɐɹǝ ɔɹǝɐʇǝd." - #: xmodule/course_block.py msgid "" "Use this setting only when generating PDF certificates. Between quotation " @@ -21035,8 +20979,8 @@ msgid "Studio Accessibility Policy" msgstr "Sʇndᴉø Ⱥɔɔǝssᴉbᴉlᴉʇʎ Ᵽølᴉɔʎ" #: cms/templates/asset_index.html cms/templates/widgets/header.html -msgid "Files & Uploads" -msgstr "Fᴉlǝs & Ʉdløɐds" +msgid "Files" +msgstr "Fᴉlǝs" #: cms/templates/certificates.html msgid "Course Certificates" @@ -22967,6 +22911,10 @@ msgstr "Ⱥɹɔɥᴉʌǝd Ȼønɹsǝs" msgid "Libraries" msgstr "Łᴉbɹɐɹᴉǝs" +#: cms/templates/index.html +msgid "Taxonomies" +msgstr "Ŧɐxønøɯᴉǝs" + #: cms/templates/index.html msgid "Are you staff on an existing {studio_name} course?" msgstr "Ⱥɹǝ ʎøn sʇɐɟɟ øn ɐn ǝxᴉsʇᴉnƃ {studio_name} ɔønɹsǝ?" diff --git a/conf/locale/rtl/LC_MESSAGES/djangojs.mo b/conf/locale/rtl/LC_MESSAGES/djangojs.mo index 14aca7096c..e59d3dd99b 100644 Binary files a/conf/locale/rtl/LC_MESSAGES/djangojs.mo and b/conf/locale/rtl/LC_MESSAGES/djangojs.mo differ diff --git a/conf/locale/rtl/LC_MESSAGES/djangojs.po b/conf/locale/rtl/LC_MESSAGES/djangojs.po index 280f56e60c..8955ec2b9d 100644 --- a/conf/locale/rtl/LC_MESSAGES/djangojs.po +++ b/conf/locale/rtl/LC_MESSAGES/djangojs.po @@ -32,8 +32,8 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-10-01 20:36+0000\n" -"PO-Revision-Date: 2023-10-01 20:36:14.819628\n" +"POT-Creation-Date: 2023-10-22 20:35+0000\n" +"PO-Revision-Date: 2023-10-22 20:35:52.488124\n" "Last-Translator: \n" "Language-Team: openedx-translation \n" "Language: rtl\n" @@ -132,7 +132,6 @@ msgstr "Ʉdløɐdᴉnƃ" #: cms/templates/js/course-video-settings.underscore #: common/static/common/templates/discussion/templates.underscore #: common/static/common/templates/image-modal.underscore -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore msgid "Close" msgstr "Ȼløsǝ" @@ -4010,10 +4009,6 @@ msgstr "" "Ŧǝll øʇɥǝɹ lǝɐɹnǝɹs ɐ lᴉʇʇlǝ ɐbønʇ ʎønɹsǝlɟ: ʍɥǝɹǝ ʎøn lᴉʌǝ, ʍɥɐʇ ʎønɹ " "ᴉnʇǝɹǝsʇs ɐɹǝ, ʍɥʎ ʎøn'ɹǝ ʇɐʞᴉnƃ ɔønɹsǝs, øɹ ʍɥɐʇ ʎøn ɥødǝ ʇø lǝɐɹn." -#: openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js -msgid "Accomplishments Pagination" -msgstr "Ⱥɔɔøɯdlᴉsɥɯǝnʇs Ᵽɐƃᴉnɐʇᴉøn" - #: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js msgid "Account Settings page." msgstr "Ⱥɔɔønnʇ Sǝʇʇᴉnƃs dɐƃǝ." @@ -4046,10 +4041,6 @@ msgstr "Ᵽɹøɟᴉlǝ ᴉɯɐƃǝ ɟøɹ {username}" msgid "About Me" msgstr "Ⱥbønʇ Mǝ" -#: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js -msgid "Accomplishments" -msgstr "Ⱥɔɔøɯdlᴉsɥɯǝnʇs" - #: openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js msgid "Profile" msgstr "Ᵽɹøɟᴉlǝ" @@ -9848,32 +9839,6 @@ msgstr[1] "Łøɐd nǝxʇ {num_items} ɹǝsnlʇs" msgid "Sorry, no results were found." msgstr "Søɹɹʎ, nø ɹǝsnlʇs ʍǝɹǝ ɟønnd." -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -#, python-format -msgid "Share your \"%(display_name)s\" award" -msgstr "Sɥɐɹǝ ʎønɹ \"%(display_name)s\" ɐʍɐɹd" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -msgid "Share" -msgstr "Sɥɐɹǝ" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge.underscore -#, python-format -msgid "Earned %(created)s." -msgstr "Ɇɐɹnǝd %(created)s." - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "What's Your Next Accomplishment?" -msgstr "Wɥɐʇ's Ɏønɹ Nǝxʇ Ⱥɔɔøɯdlᴉsɥɯǝnʇ?" - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "Start working toward your next learning goal." -msgstr "Sʇɐɹʇ ʍøɹʞᴉnƃ ʇøʍɐɹd ʎønɹ nǝxʇ lǝɐɹnᴉnƃ ƃøɐl." - -#: openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore -msgid "Find a course" -msgstr "Fᴉnd ɐ ɔønɹsǝ" - #: openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore msgid "You are currently sharing a limited profile." msgstr "Ɏøn ɐɹǝ ɔnɹɹǝnʇlʎ sɥɐɹᴉnƃ ɐ lᴉɯᴉʇǝd dɹøɟᴉlǝ." @@ -9882,38 +9847,6 @@ msgstr "Ɏøn ɐɹǝ ɔnɹɹǝnʇlʎ sɥɐɹᴉnƃ ɐ lᴉɯᴉʇǝd dɹøɟᴉl msgid "This learner is currently sharing a limited profile." msgstr "Ŧɥᴉs lǝɐɹnǝɹ ᴉs ɔnɹɹǝnʇlʎ sɥɐɹᴉnƃ ɐ lᴉɯᴉʇǝd dɹøɟᴉlǝ." -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "Share on Mozilla Backpack" -msgstr "Sɥɐɹǝ øn Møzᴉllɐ Ƀɐɔʞdɐɔʞ" - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"To share your certificate on Mozilla Backpack, you must first have a " -"Backpack account. Complete the following steps to add your certificate to " -"Backpack." -msgstr "" -"Ŧø sɥɐɹǝ ʎønɹ ɔǝɹʇᴉɟᴉɔɐʇǝ øn Møzᴉllɐ Ƀɐɔʞdɐɔʞ, ʎøn ɯnsʇ ɟᴉɹsʇ ɥɐʌǝ ɐ " -"Ƀɐɔʞdɐɔʞ ɐɔɔønnʇ. Ȼøɯdlǝʇǝ ʇɥǝ ɟølløʍᴉnƃ sʇǝds ʇø ɐdd ʎønɹ ɔǝɹʇᴉɟᴉɔɐʇǝ ʇø " -"Ƀɐɔʞdɐɔʞ." - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"Create a {link_start}Mozilla Backpack{link_end} account, or log in to your " -"existing account" -msgstr "" -"Ȼɹǝɐʇǝ ɐ {link_start}Møzᴉllɐ Ƀɐɔʞdɐɔʞ{link_end} ɐɔɔønnʇ, øɹ løƃ ᴉn ʇø ʎønɹ " -"ǝxᴉsʇᴉnƃ ɐɔɔønnʇ" - -#: openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore -msgid "" -"{download_link_start}Download this image (right-click or option-click, save " -"as){link_end} and then {upload_link_start}upload{link_end} it to your " -"backpack." -msgstr "" -"{download_link_start}Đøʍnløɐd ʇɥᴉs ᴉɯɐƃǝ (ɹᴉƃɥʇ-ɔlᴉɔʞ øɹ ødʇᴉøn-ɔlᴉɔʞ, sɐʌǝ " -"ɐs){link_end} ɐnd ʇɥǝn {upload_link_start}ndløɐd{link_end} ᴉʇ ʇø ʎønɹ " -"bɐɔʞdɐɔʞ." - #: cms/templates/js/access-editor.underscore msgid "Limit Access" msgstr "Łᴉɯᴉʇ Ⱥɔɔǝss" diff --git a/conf/locale/ru/LC_MESSAGES/django.po b/conf/locale/ru/LC_MESSAGES/django.po index c14da1dafc..6d2ab4968c 100644 --- a/conf/locale/ru/LC_MESSAGES/django.po +++ b/conf/locale/ru/LC_MESSAGES/django.po @@ -20296,10 +20296,6 @@ msgstr "" "Ознакомьтесь с недавно запущенными курсами и новостями по вашим любимым " "предметам." -#: themes/edx.org/lms/templates/dashboard.html -msgid "Take advantage of free coaching!" -msgstr "" - #: themes/edx.org/lms/templates/dashboard.html msgid "Get Started" msgstr "" diff --git a/conf/locale/ru/LC_MESSAGES/djangojs.po b/conf/locale/ru/LC_MESSAGES/djangojs.po index 57e9096fc5..38aa24dafe 100644 --- a/conf/locale/ru/LC_MESSAGES/djangojs.po +++ b/conf/locale/ru/LC_MESSAGES/djangojs.po @@ -193,7 +193,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: ashed , 2022-2023\n" "Language-Team: Russian (http://app.transifex.com/open-edx/edx-platform/language/ru/)\n" diff --git a/conf/locale/sk/LC_MESSAGES/django.po b/conf/locale/sk/LC_MESSAGES/django.po index a782279aa1..b628d93dde 100644 --- a/conf/locale/sk/LC_MESSAGES/django.po +++ b/conf/locale/sk/LC_MESSAGES/django.po @@ -55,7 +55,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Slovak (https://app.transifex.com/open-edx/teams/6205/sk/)\n" @@ -3300,12 +3300,12 @@ msgid "Good" msgstr "" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6846,6 +6846,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6882,6 +6890,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/sk/LC_MESSAGES/djangojs.po b/conf/locale/sk/LC_MESSAGES/djangojs.po index 1e707b179a..30ad50119f 100644 --- a/conf/locale/sk/LC_MESSAGES/djangojs.po +++ b/conf/locale/sk/LC_MESSAGES/djangojs.po @@ -46,7 +46,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: \n" "Language-Team: Slovak (http://app.transifex.com/open-edx/edx-platform/language/sk/)\n" diff --git a/conf/locale/sw_KE/LC_MESSAGES/django.po b/conf/locale/sw_KE/LC_MESSAGES/django.po index 1500e6b165..ee60f36c44 100644 --- a/conf/locale/sw_KE/LC_MESSAGES/django.po +++ b/conf/locale/sw_KE/LC_MESSAGES/django.po @@ -85,7 +85,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Swahili (Kenya) (https://app.transifex.com/open-edx/teams/6205/sw_KE/)\n" @@ -3337,12 +3337,12 @@ msgid "Good" msgstr "Vizuri" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6890,6 +6890,14 @@ msgstr "Orodha ya {country} zisizokubaliwa kwenye {course}" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6926,6 +6934,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/sw_KE/LC_MESSAGES/djangojs.po b/conf/locale/sw_KE/LC_MESSAGES/djangojs.po index 52e1dcbeea..21be4a28b6 100644 --- a/conf/locale/sw_KE/LC_MESSAGES/djangojs.po +++ b/conf/locale/sw_KE/LC_MESSAGES/djangojs.po @@ -71,7 +71,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: YAHAYA MWAVURIZI , 2017\n" "Language-Team: Swahili (Kenya) (http://app.transifex.com/open-edx/edx-platform/language/sw_KE/)\n" diff --git a/conf/locale/th/LC_MESSAGES/django.po b/conf/locale/th/LC_MESSAGES/django.po index 91bee06809..d1d3abd290 100644 --- a/conf/locale/th/LC_MESSAGES/django.po +++ b/conf/locale/th/LC_MESSAGES/django.po @@ -116,7 +116,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Thai (https://app.transifex.com/open-edx/teams/6205/th/)\n" @@ -3226,12 +3226,12 @@ msgid "Good" msgstr "" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6705,6 +6705,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6741,6 +6749,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/th/LC_MESSAGES/djangojs.po b/conf/locale/th/LC_MESSAGES/djangojs.po index 727ea417a2..3c103e1416 100644 --- a/conf/locale/th/LC_MESSAGES/djangojs.po +++ b/conf/locale/th/LC_MESSAGES/djangojs.po @@ -73,7 +73,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: edx demo , 2019\n" "Language-Team: Thai (http://app.transifex.com/open-edx/edx-platform/language/th/)\n" diff --git a/conf/locale/tr_TR/LC_MESSAGES/django.po b/conf/locale/tr_TR/LC_MESSAGES/django.po index 99388c329a..8d02cfb30a 100644 --- a/conf/locale/tr_TR/LC_MESSAGES/django.po +++ b/conf/locale/tr_TR/LC_MESSAGES/django.po @@ -132,7 +132,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-10 20:42+0000\n" +"POT-Creation-Date: 2023-10-01 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Ali Işıngör , 2021\n" "Language-Team: Turkish (Turkey) (https://app.transifex.com/open-edx/teams/6205/tr_TR/)\n" @@ -3708,12 +3708,12 @@ msgid "Good" msgstr "İyi" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "size" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "onlara" @@ -7639,6 +7639,14 @@ msgstr "{course} dersi için {country} kara listede" msgid "Learner Pathways" msgstr "Öğrenci Patikaları" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7681,6 +7689,20 @@ msgstr "" "<{p}><{strong}>{username} sordu " "<{strong}>{post_title}" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/tr_TR/LC_MESSAGES/djangojs.po b/conf/locale/tr_TR/LC_MESSAGES/djangojs.po index b8e157cc2b..e7898c1222 100644 --- a/conf/locale/tr_TR/LC_MESSAGES/djangojs.po +++ b/conf/locale/tr_TR/LC_MESSAGES/djangojs.po @@ -110,7 +110,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Ali Işıngör , 2018,2020-2021,2023\n" "Language-Team: Turkish (Turkey) (http://app.transifex.com/open-edx/edx-platform/language/tr_TR/)\n" diff --git a/conf/locale/uk/LC_MESSAGES/django.po b/conf/locale/uk/LC_MESSAGES/django.po index 9df1d2c5ea..3bcc098c5d 100644 --- a/conf/locale/uk/LC_MESSAGES/django.po +++ b/conf/locale/uk/LC_MESSAGES/django.po @@ -18,6 +18,7 @@ # IvanPrimachenko , 2014 # Martin Horst , 2018 # Mykhailo Parfeniuk , 2016 +# Mykola Melnyk, 2023 # Natalia Vynogradenko , 2017-2018 # Nikolai Belochub, 2014 # cc322e8fc6cc292f0f00d8b7420e7410_e468d09 <95dee59251c2de5d1d21e213a66df19d_243275>, 2014 @@ -125,7 +126,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Danylo Shcherbak , 2020\n" "Language-Team: Ukrainian (https://app.transifex.com/open-edx/teams/6205/uk/)\n" @@ -3572,12 +3573,12 @@ msgid "Good" msgstr "Добре" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -7339,6 +7340,14 @@ msgstr "Занести до чорного списку країну {country} msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7375,6 +7384,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/uk/LC_MESSAGES/djangojs.po b/conf/locale/uk/LC_MESSAGES/djangojs.po index de9cd2e85d..bfadf2ecc6 100644 --- a/conf/locale/uk/LC_MESSAGES/djangojs.po +++ b/conf/locale/uk/LC_MESSAGES/djangojs.po @@ -102,7 +102,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Andrey Kryachko, 2018\n" "Language-Team: Ukrainian (http://app.transifex.com/open-edx/edx-platform/language/uk/)\n" diff --git a/conf/locale/vi/LC_MESSAGES/django.po b/conf/locale/vi/LC_MESSAGES/django.po index 6b2785be79..ffd88f1030 100644 --- a/conf/locale/vi/LC_MESSAGES/django.po +++ b/conf/locale/vi/LC_MESSAGES/django.po @@ -198,7 +198,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Le Minh Tri , 2020\n" "Language-Team: Vietnamese (https://app.transifex.com/open-edx/teams/6205/vi/)\n" @@ -3300,12 +3300,12 @@ msgid "Good" msgstr "" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6783,6 +6783,14 @@ msgstr "" msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6819,6 +6827,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/vi/LC_MESSAGES/djangojs.po b/conf/locale/vi/LC_MESSAGES/djangojs.po index 468215f6fd..64fdeb1dd0 100644 --- a/conf/locale/vi/LC_MESSAGES/djangojs.po +++ b/conf/locale/vi/LC_MESSAGES/djangojs.po @@ -113,7 +113,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Le Minh Tri , 2020\n" "Language-Team: Vietnamese (http://app.transifex.com/open-edx/edx-platform/language/vi/)\n" diff --git a/conf/locale/zh_CN/LC_MESSAGES/django.mo b/conf/locale/zh_CN/LC_MESSAGES/django.mo index 52436b2f38..9dd98ee075 100644 Binary files a/conf/locale/zh_CN/LC_MESSAGES/django.mo and b/conf/locale/zh_CN/LC_MESSAGES/django.mo differ diff --git a/conf/locale/zh_CN/LC_MESSAGES/django.po b/conf/locale/zh_CN/LC_MESSAGES/django.po index 5727a7015a..8829d9b568 100644 --- a/conf/locale/zh_CN/LC_MESSAGES/django.po +++ b/conf/locale/zh_CN/LC_MESSAGES/django.po @@ -403,7 +403,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: ifLab , 2019\n" "Language-Team: Chinese (China) (https://app.transifex.com/open-edx/teams/6205/zh_CN/)\n" @@ -525,13 +525,13 @@ msgstr "空白" #: cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py #: openedx/core/djangoapps/xblock/rest_api/views.py msgid "Invalid data" -msgstr "" +msgstr "无效的数据" #: cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py #: openedx/core/djangoapps/xblock/rest_api/views.py #, python-brace-format msgid "Invalid data ({details})" -msgstr "" +msgstr "无效的数据 ({details})" #: cms/templates/admin/base_site.html lms/templates/admin/base_site.html msgid "Django site admin" @@ -3629,12 +3629,12 @@ msgid "Good" msgstr "好" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6797,6 +6797,7 @@ msgstr "" msgid "Original usage key/ID of the thing that is in the clipboard." msgstr "" +#: openedx/core/djangoapps/content_tagging/models/base.py #: wiki/models/article.py msgid "owner" msgstr "所有者" @@ -7234,6 +7235,14 @@ msgstr "课程{course}的黑名单国家: {country}" msgid "Learner Pathways" msgstr "学习者路径" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7270,6 +7279,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " @@ -11951,19 +11974,19 @@ msgstr "课程团队用户不存在" #, python-brace-format msgid "" "Number of course outline regenerations successfully requested: {regenerates}" -msgstr "" +msgstr "成功请求的课程大纲重新生成数:{regenerates}" #: cms/djangoapps/contentstore/admin.py msgid "Regenerate selected course outlines" -msgstr "" +msgstr "重新生成选定的课程大纲" #: cms/djangoapps/contentstore/admin.py msgid "All course outline regenerations successfully requested." -msgstr "" +msgstr "已成功请求所有课程大纲重新生成。" #: cms/djangoapps/contentstore/admin.py msgid "Regenerate *all* course outlines" -msgstr "" +msgstr "重新生成*所有*课程大纲" #: cms/djangoapps/contentstore/asset_storage_handlers.py msgid "Upload completed" @@ -12025,11 +12048,11 @@ msgstr "(未命名)" #: cms/djangoapps/contentstore/errors.py msgid "Aborting import because a course with this id: {} already exists." -msgstr "" +msgstr "正在中止导入,因为具有此 ID 的课程:{} 已经存在。" #: cms/djangoapps/contentstore/errors.py msgid "Permission denied. You do not have write access to this course." -msgstr "" +msgstr "您没有对此课程的写入权限。" #: cms/djangoapps/contentstore/errors.py #, python-brace-format @@ -12038,7 +12061,7 @@ msgstr "无法在包中找到{0}文件。" #: cms/djangoapps/contentstore/errors.py msgid "Uploaded Tar file not found. Try again." -msgstr "" +msgstr "找不到上传文件。再试一次。" #: cms/djangoapps/contentstore/errors.py #: cms/djangoapps/contentstore/views/import_export.py @@ -12047,11 +12070,11 @@ msgstr "我们仅支持上传.tar.gz格式文件。" #: cms/djangoapps/contentstore/errors.py msgid "Aborting import since a library with this id already exists." -msgstr "" +msgstr "中止导入,因为具有此id的文件已存在。" #: cms/djangoapps/contentstore/errors.py msgid "Course olx validation failed. Please check your email." -msgstr "" +msgstr "课程验证失败。请查看你的邮箱。" #: cms/djangoapps/contentstore/errors.py msgid "Permission denied" @@ -12059,11 +12082,11 @@ msgstr "没有权限" #: cms/djangoapps/contentstore/errors.py msgid "Unknown error while importing course." -msgstr "" +msgstr "导入课程时出现未知错误。" #: cms/djangoapps/contentstore/errors.py msgid "An Unknown error occurred during the unpacking step." -msgstr "" +msgstr "解压包过程中发生未知错误。" #: cms/djangoapps/contentstore/errors.py #, python-brace-format @@ -12091,6 +12114,7 @@ msgid "" "Non writable git url provided. Expecting something like: " "git@github.com:openedx/openedx-demo-course.git" msgstr "" +"提供的GIT URL地址不正确。正确内容比如如下:git@github.com:openedx/openedx-demo-course.git" #: cms/djangoapps/contentstore/git_export_utils.py msgid "" @@ -12158,39 +12182,39 @@ msgstr "" #: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py msgid "Marks a field as deprecated." -msgstr "" +msgstr "标记的字段已弃用。" #: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py msgid "User-friendly display name for the field" -msgstr "" +msgstr "该用户名的字段显示" #: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py msgid "Help text that describes the setting." -msgstr "" +msgstr "描述设置的帮助文本。" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "Tab type" -msgstr "" +msgstr "标签类型" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "Default name for the tab displayed to users" -msgstr "" +msgstr "向用户显示的选项卡的默认名称" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "True if it's possible to hide the tab for a course" -msgstr "" +msgstr "此课程可能设置隐藏" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "True the tab is hidden for the course" -msgstr "" +msgstr "课程设置为隐藏" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "True if it's possible to reorder the tab in the list of tabs" -msgstr "" +msgstr "可在选项卡列表中可能设置重新排序选项卡," #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "True if this tab should be displayed only for instructors" -msgstr "" +msgstr "此选项卡只应为教师" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "Name of the tab displayed to users. Overrides title." diff --git a/conf/locale/zh_CN/LC_MESSAGES/djangojs.po b/conf/locale/zh_CN/LC_MESSAGES/djangojs.po index 88327c771f..843d2345e1 100644 --- a/conf/locale/zh_CN/LC_MESSAGES/djangojs.po +++ b/conf/locale/zh_CN/LC_MESSAGES/djangojs.po @@ -230,7 +230,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: jsgang , 2015-2017,2020\n" "Language-Team: Chinese (China) (http://app.transifex.com/open-edx/edx-platform/language/zh_CN/)\n" diff --git a/conf/locale/zh_HANS/LC_MESSAGES/django.mo b/conf/locale/zh_HANS/LC_MESSAGES/django.mo index 52436b2f38..9dd98ee075 100644 Binary files a/conf/locale/zh_HANS/LC_MESSAGES/django.mo and b/conf/locale/zh_HANS/LC_MESSAGES/django.mo differ diff --git a/conf/locale/zh_HANS/LC_MESSAGES/django.po b/conf/locale/zh_HANS/LC_MESSAGES/django.po index 5727a7015a..8829d9b568 100644 --- a/conf/locale/zh_HANS/LC_MESSAGES/django.po +++ b/conf/locale/zh_HANS/LC_MESSAGES/django.po @@ -403,7 +403,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: ifLab , 2019\n" "Language-Team: Chinese (China) (https://app.transifex.com/open-edx/teams/6205/zh_CN/)\n" @@ -525,13 +525,13 @@ msgstr "空白" #: cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py #: openedx/core/djangoapps/xblock/rest_api/views.py msgid "Invalid data" -msgstr "" +msgstr "无效的数据" #: cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py #: openedx/core/djangoapps/xblock/rest_api/views.py #, python-brace-format msgid "Invalid data ({details})" -msgstr "" +msgstr "无效的数据 ({details})" #: cms/templates/admin/base_site.html lms/templates/admin/base_site.html msgid "Django site admin" @@ -3629,12 +3629,12 @@ msgid "Good" msgstr "好" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6797,6 +6797,7 @@ msgstr "" msgid "Original usage key/ID of the thing that is in the clipboard." msgstr "" +#: openedx/core/djangoapps/content_tagging/models/base.py #: wiki/models/article.py msgid "owner" msgstr "所有者" @@ -7234,6 +7235,14 @@ msgstr "课程{course}的黑名单国家: {country}" msgid "Learner Pathways" msgstr "学习者路径" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -7270,6 +7279,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " @@ -11951,19 +11974,19 @@ msgstr "课程团队用户不存在" #, python-brace-format msgid "" "Number of course outline regenerations successfully requested: {regenerates}" -msgstr "" +msgstr "成功请求的课程大纲重新生成数:{regenerates}" #: cms/djangoapps/contentstore/admin.py msgid "Regenerate selected course outlines" -msgstr "" +msgstr "重新生成选定的课程大纲" #: cms/djangoapps/contentstore/admin.py msgid "All course outline regenerations successfully requested." -msgstr "" +msgstr "已成功请求所有课程大纲重新生成。" #: cms/djangoapps/contentstore/admin.py msgid "Regenerate *all* course outlines" -msgstr "" +msgstr "重新生成*所有*课程大纲" #: cms/djangoapps/contentstore/asset_storage_handlers.py msgid "Upload completed" @@ -12025,11 +12048,11 @@ msgstr "(未命名)" #: cms/djangoapps/contentstore/errors.py msgid "Aborting import because a course with this id: {} already exists." -msgstr "" +msgstr "正在中止导入,因为具有此 ID 的课程:{} 已经存在。" #: cms/djangoapps/contentstore/errors.py msgid "Permission denied. You do not have write access to this course." -msgstr "" +msgstr "您没有对此课程的写入权限。" #: cms/djangoapps/contentstore/errors.py #, python-brace-format @@ -12038,7 +12061,7 @@ msgstr "无法在包中找到{0}文件。" #: cms/djangoapps/contentstore/errors.py msgid "Uploaded Tar file not found. Try again." -msgstr "" +msgstr "找不到上传文件。再试一次。" #: cms/djangoapps/contentstore/errors.py #: cms/djangoapps/contentstore/views/import_export.py @@ -12047,11 +12070,11 @@ msgstr "我们仅支持上传.tar.gz格式文件。" #: cms/djangoapps/contentstore/errors.py msgid "Aborting import since a library with this id already exists." -msgstr "" +msgstr "中止导入,因为具有此id的文件已存在。" #: cms/djangoapps/contentstore/errors.py msgid "Course olx validation failed. Please check your email." -msgstr "" +msgstr "课程验证失败。请查看你的邮箱。" #: cms/djangoapps/contentstore/errors.py msgid "Permission denied" @@ -12059,11 +12082,11 @@ msgstr "没有权限" #: cms/djangoapps/contentstore/errors.py msgid "Unknown error while importing course." -msgstr "" +msgstr "导入课程时出现未知错误。" #: cms/djangoapps/contentstore/errors.py msgid "An Unknown error occurred during the unpacking step." -msgstr "" +msgstr "解压包过程中发生未知错误。" #: cms/djangoapps/contentstore/errors.py #, python-brace-format @@ -12091,6 +12114,7 @@ msgid "" "Non writable git url provided. Expecting something like: " "git@github.com:openedx/openedx-demo-course.git" msgstr "" +"提供的GIT URL地址不正确。正确内容比如如下:git@github.com:openedx/openedx-demo-course.git" #: cms/djangoapps/contentstore/git_export_utils.py msgid "" @@ -12158,39 +12182,39 @@ msgstr "" #: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py msgid "Marks a field as deprecated." -msgstr "" +msgstr "标记的字段已弃用。" #: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py msgid "User-friendly display name for the field" -msgstr "" +msgstr "该用户名的字段显示" #: cms/djangoapps/contentstore/rest_api/v0/serializers/advanced_settings.py msgid "Help text that describes the setting." -msgstr "" +msgstr "描述设置的帮助文本。" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "Tab type" -msgstr "" +msgstr "标签类型" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "Default name for the tab displayed to users" -msgstr "" +msgstr "向用户显示的选项卡的默认名称" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "True if it's possible to hide the tab for a course" -msgstr "" +msgstr "此课程可能设置隐藏" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "True the tab is hidden for the course" -msgstr "" +msgstr "课程设置为隐藏" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "True if it's possible to reorder the tab in the list of tabs" -msgstr "" +msgstr "可在选项卡列表中可能设置重新排序选项卡," #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "True if this tab should be displayed only for instructors" -msgstr "" +msgstr "此选项卡只应为教师" #: cms/djangoapps/contentstore/rest_api/v0/serializers/tabs.py msgid "Name of the tab displayed to users. Overrides title." diff --git a/conf/locale/zh_HANS/LC_MESSAGES/djangojs.po b/conf/locale/zh_HANS/LC_MESSAGES/djangojs.po index 88327c771f..843d2345e1 100644 --- a/conf/locale/zh_HANS/LC_MESSAGES/djangojs.po +++ b/conf/locale/zh_HANS/LC_MESSAGES/djangojs.po @@ -230,7 +230,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: jsgang , 2015-2017,2020\n" "Language-Team: Chinese (China) (http://app.transifex.com/open-edx/edx-platform/language/zh_CN/)\n" diff --git a/conf/locale/zh_TW/LC_MESSAGES/django.po b/conf/locale/zh_TW/LC_MESSAGES/django.po index 43c41e5e9c..40972435e7 100644 --- a/conf/locale/zh_TW/LC_MESSAGES/django.po +++ b/conf/locale/zh_TW/LC_MESSAGES/django.po @@ -177,7 +177,7 @@ msgid "" msgstr "" "Project-Id-Version: 0.1a\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:43+0000\n" +"POT-Creation-Date: 2023-10-08 20:43+0000\n" "PO-Revision-Date: 2019-01-20 20:43+0000\n" "Last-Translator: Waheed Ahmed , 2019\n" "Language-Team: Chinese (Taiwan) (https://app.transifex.com/open-edx/teams/6205/zh_TW/)\n" @@ -3341,12 +3341,12 @@ msgid "Good" msgstr "好" #. Translators: Replier commented on "your" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "your" msgstr "" #. Translators: Replier commented on "their" response to your post -#: lms/djangoapps/discussion/rest_api/utils.py +#: lms/djangoapps/discussion/rest_api/discussions_notifications.py msgid "their" msgstr "" @@ -6871,6 +6871,14 @@ msgstr "課程{course}的黑名單國家: {country} " msgid "Learner Pathways" msgstr "" +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification App" +msgstr "" + +#: openedx/core/djangoapps/notifications/admin.py +msgid "Notification Type" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py #, python-brace-format msgid "" @@ -6907,6 +6915,20 @@ msgid "" "<{strong}>{post_title}" msgstr "" +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} responded to a post you’re " +"following: <{strong}>{post_title}" +msgstr "" + +#: openedx/core/djangoapps/notifications/base_notification.py +#, python-brace-format +msgid "" +"<{p}><{strong}>{replier_name} commented on {author_name}'s " +"response in a post you’re following <{strong}>{post_title}" +msgstr "" + #: openedx/core/djangoapps/notifications/base_notification.py msgid "" "Notifications for responses and comments on your posts, and the ones you’re " diff --git a/conf/locale/zh_TW/LC_MESSAGES/djangojs.po b/conf/locale/zh_TW/LC_MESSAGES/djangojs.po index 15d153824a..91dd729e09 100644 --- a/conf/locale/zh_TW/LC_MESSAGES/djangojs.po +++ b/conf/locale/zh_TW/LC_MESSAGES/djangojs.po @@ -132,7 +132,7 @@ msgid "" msgstr "" "Project-Id-Version: edx-platform\n" "Report-Msgid-Bugs-To: openedx-translation@googlegroups.com\n" -"POT-Creation-Date: 2023-09-17 20:42+0000\n" +"POT-Creation-Date: 2023-10-08 20:42+0000\n" "PO-Revision-Date: 2014-06-11 15:18+0000\n" "Last-Translator: Andrew Lau , 2017\n" "Language-Team: Chinese (Taiwan) (http://app.transifex.com/open-edx/edx-platform/language/zh_TW/)\n" diff --git a/docs/decisions/0018-standarize-django-po-files.rst b/docs/decisions/0018-standarize-django-po-files.rst new file mode 100644 index 0000000000..4020b7c493 --- /dev/null +++ b/docs/decisions/0018-standarize-django-po-files.rst @@ -0,0 +1,155 @@ +Standardize ``django.po`` and ``djangojs.po`` files +=================================================== + +Status +------ + +Accepted + +Context +------- + +- The edx-platform splits its translations into ``mako-studio.po``, + ``django-partial.po``, and 9 other files. This was done with the + intention of supporting more languages in the LMS than in + the CMS (Studio). + +- The edx-platform uses the ``i18n_tools segment`` to split the ``django.po`` + and ``djangojs.po`` files into smaller files. This is done with the goal of + making it easier for translators to translate the files. + +- When pulling from Transifex or cutting a new release, the translations are + bundled into 2 files (``django.po`` and ``djangojs.po``). + +- The `FC-0012 project `_ + which implements `Translation Infrastructure update OEP-58`_ + is in progress. Upon completion, all translation files will live in + the new `openedx-translations Transifex project`_. + +- Consequently, repositories like ``edx-platform`` will pull translations via + `openedx-atlas`_ command line tool as described in the `OEP-58`_ proposal. + +- Several special steps would be needed to for the + `extract-translation-source-files.yml`_ GitHub workflow in + the `openedx-translations GitHub repository`_ + to extract the files from the ``edx-platform`` repo. + This seems redundant and and it should be possible to simplify the process. + +Decision: Only use two files for edx-platform translations (``django.po`` and ``djangojs.po``) +---------------------------------------------------------------------------------------------- + +edX Platform will push only two files (``django.po`` and +``djangojs.``) to the `openedx-translations Transifex project`_. + +Consequences +------------ + +Pros: + +- Translators will need to locate only two resources for the ``edx-platform`` + repo as opposed to 11 resources. +- Simplify the ``edx-platform`` translation extraction scripts in the + `extract-translation-source-files.yml`_ GitHub workflow. +- The release cut process will be simplified as well by removing the + translation merger step altogether. +- Translators will need one workflow for both latest (master) and named + releases for the ``edx-platform`` repo. +- Simplifies the work of both the Build Test Release and Transifex working groups. + +Cons: + +- It will be harder for translators to focus on learner-facing text and de-prioritize + educator-facing text. + + +Rejected Alternatives +--------------------- + +Combine into four files +^^^^^^^^^^^^^^^^^^^^^^^ + +This option tries to keep the original idea of splitting to allow translators +to focus on learner-facing text and de-prioritize educator-facing text. + +The four files will be ``platform.po``, ``platform-js.po``, ``studio.po`` +and ``studio-js.po``. + +It will not concern the translators with the technical split +of the files into: ``wiki.po``, ``mako.po``, and ``django-partial.po``, etc. + + +In this option we'll create two new configuration files +``config.extract-oep58.yaml`` and ``config.pull-oep58.yaml`` to combine the +pofiles into the four files and then combine them into two files respectively: + +.. code:: yaml + + # config.extract-oep58.yaml + # This file is used by the ``make extract_translations`` when + # the OPENEDX_COMBINE_FILES environment variable is enabled. + generate_merge: + platform.po: + - django-partial.po + - mako.po + - wiki.po + - edx_proctoring_proctortrack.po + platform-js.po: + - djangojs-partial.po + - djangojs-account-settings-view.po + - underscore.po + studio.po: + - django-studio.po + - mako-studio.po + studio-js.po: + - djangojs-studio.po + - underscore-studio.po + +A corresponding ``Makefile`` change is needed: + +.. code:: make + + extract_translations: ## extract localizable strings from sources + i18n_tool extract -v + if [ -z "$$OPENEDX_COMBINE_FILES" ]; then \ + i18n_tool generate --config=config.extract-oep58.yaml --verbose 1; + fi + +The other file would be ran after ``make pull_translations`` which will +be used to make the final ``django.po`` and ``djangojs.po`` files that are +usable by Django: + +.. code:: yaml + + # config.pull-oep58.yaml + # This file is used by the ``make pull_translations`` when + # the OPENEDX_ATLAS_PULL environment variable is enabled. + generate_merge: + django.po: + - platform.po + - studio.po + djangojs.po: + - platform-js.po + - studio-js.po + + + pull_translations: ## extract localizable strings from sources + if [ -z "$$OPENEDX_ATLAS_PULL" ]; then \ + atlas pull translations/edx-platform/conf/locale + i18n_tool --config=config.pull-oep58.yaml generate --verbose 1; + fi + + +This option involves multiple merge and split steps which adds complexity +for developers. Based on the `feedback in the decision pull request`_, +splitting the resources was a lesser used feature in the Open edX community. +Therefore, this option is rejected because the added complexity of this +option isn't justified. + + +.. _extract-translation-source-files.yml: https://github.com/openedx/openedx-translations/blob/2566e0c9a30d033e5dd8d05d4c12601c8e37b4ef/.github/workflows/extract-translation-source-files.yml +.. _openedx-translations GitHub repository: https://github.com/openedx/openedx-translations +.. _openedx-translations Transifex project: https://app.transifex.com/open-edx/openedx-translations/ +.. _OEP-58: https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification +.. _openedx-atlas: https://github.com/openedx/openedx-atlas/ +.. _Translation Infrastructure update OEP-58: https://open-edx-proposals.readthedocs.io/en/latest/architectural-decisions/oep-0058-arch-translations-management.html#specification +.. _feedback in the decision pull request: https://github.com/openedx/edx-platform/pull/32994#issuecomment-1677390405 diff --git a/docs/lms-openapi.yaml b/docs/lms-openapi.yaml index 202b983212..9d95b947ae 100644 --- a/docs/lms-openapi.yaml +++ b/docs/lms-openapi.yaml @@ -58,114 +58,6 @@ paths: in: path required: true type: string - /badges/v1/assertions/user/{username}/: - get: - operationId: badges_v1_assertions_user_read - summary: '**Use Cases**' - description: |- - Request a list of assertions for a user, optionally constrained to a course. - - **Example Requests** - - GET /api/badges/v1/assertions/user/{username}/ - - **Response Values** - - Body comprised of a list of objects with the following fields: - - * badge_class: The badge class the assertion was awarded for. Represented as an object - with the following fields: - * slug: The identifier for the badge class - * issuing_component: The software component responsible for issuing this badge. - * display_name: The display name of the badge. - * course_id: The course key of the course this badge is scoped to, or null if it isn't scoped to a course. - * description: A description of the award and its significance. - * criteria: A description of what is needed to obtain this award. - * image_url: A URL to the icon image used to represent this award. - * image_url: The baked assertion image derived from the badge_class icon-- contains metadata about the award - in its headers. - * assertion_url: The URL to the OpenBadges BadgeAssertion object, for verification by compatible tools - and software. - - **Params** - - * slug (optional): The identifier for a particular badge class to filter by. - * issuing_component (optional): The issuing component for a particular badge class to filter by - (requires slug to have been specified, or this will be ignored.) If slug is provided and this is not, - assumes the issuing_component should be empty. - * course_id (optional): Returns assertions that were awarded as part of a particular course. If slug is - provided, and this field is not specified, assumes that the target badge has an empty course_id field. - '*' may be used to get all badges with the specified slug, issuing_component combination across all courses. - - **Returns** - - * 200 on success, with a list of Badge Assertion objects. - * 403 if a user who does not have permission to masquerade as - another user specifies a username other than their own. - * 404 if the specified user does not exist - - { - "count": 7, - "previous": null, - "num_pages": 1, - "results": [ - { - "badge_class": { - "slug": "special_award", - "issuing_component": "openedx__course", - "display_name": "Very Special Award", - "course_id": "course-v1:edX+DemoX+Demo_Course", - "description": "Awarded for people who did something incredibly special", - "criteria": "Do something incredibly special.", - "image": "http://example.com/media/badge_classes/badges/special_xdpqpBv_9FYOZwN.png" - }, - "image_url": "http://badges.example.com/media/issued/cd75b69fc1c979fcc1697c8403da2bdf.png", - "assertion_url": "http://badges.example.com/public/assertions/07020647-e772-44dd-98b7-d13d34335ca6" - }, - ... - ] - } - parameters: - - name: page - in: query - description: A page number within the paginated result set. - required: false - type: integer - - name: page_size - in: query - description: Number of results to return per page. - required: false - type: integer - responses: - '200': - description: '' - schema: - required: - - count - - results - type: object - properties: - count: - type: integer - next: - type: string - format: uri - x-nullable: true - previous: - type: string - format: uri - x-nullable: true - results: - type: array - items: - $ref: '#/definitions/BadgeAssertion' - tags: - - badges - parameters: - - name: username - in: path - required: true - type: string /bookmarks/v1/bookmarks/: get: operationId: bookmarks_v1_bookmarks_list @@ -9477,75 +9369,6 @@ paths: required: true type: string definitions: - BadgeClass: - required: - - slug - - display_name - - description - - criteria - type: object - properties: - slug: - title: Slug - type: string - format: slug - pattern: ^[-a-zA-Z0-9_]+$ - maxLength: 255 - minLength: 1 - issuing_component: - title: Issuing component - type: string - format: slug - pattern: ^[-a-zA-Z0-9_]+$ - default: '' - maxLength: 50 - display_name: - title: Display name - type: string - maxLength: 255 - minLength: 1 - course_id: - title: Course id - type: string - maxLength: 255 - description: - title: Description - type: string - minLength: 1 - criteria: - title: Criteria - type: string - minLength: 1 - image_url: - title: Image url - type: string - readOnly: true - format: uri - BadgeAssertion: - required: - - image_url - - assertion_url - type: object - properties: - badge_class: - $ref: '#/definitions/BadgeClass' - image_url: - title: Image url - type: string - format: uri - maxLength: 200 - minLength: 1 - assertion_url: - title: Assertion url - type: string - format: uri - maxLength: 200 - minLength: 1 - created: - title: Created - type: string - format: date-time - readOnly: true CCXCourse: required: - master_course_id diff --git a/docs/references/docstrings/lms_index.rst b/docs/references/docstrings/lms_index.rst index 108a65cd39..43676bfdc1 100644 --- a/docs/references/docstrings/lms_index.rst +++ b/docs/references/docstrings/lms_index.rst @@ -9,7 +9,6 @@ Studio. :maxdepth: 2 lms/modules - lms/djangoapps/badges/modules lms/djangoapps/branding/modules lms/djangoapps/bulk_email/modules lms/djangoapps/courseware/modules diff --git a/lms/djangoapps/badges/admin.py b/lms/djangoapps/badges/admin.py deleted file mode 100644 index 096ac6f92c..0000000000 --- a/lms/djangoapps/badges/admin.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Admin registration for Badge Models -""" - - -from config_models.admin import ConfigurationModelAdmin -from django.contrib import admin - -from lms.djangoapps.badges.models import ( - BadgeAssertion, - BadgeClass, - CourseCompleteImageConfiguration, - CourseEventBadgesConfiguration -) - -admin.site.register(CourseCompleteImageConfiguration) -admin.site.register(BadgeClass) -admin.site.register(BadgeAssertion) -# Use the standard Configuration Model Admin handler for this model. -admin.site.register(CourseEventBadgesConfiguration, ConfigurationModelAdmin) diff --git a/lms/djangoapps/badges/api/__init__.py b/lms/djangoapps/badges/api/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/badges/api/serializers.py b/lms/djangoapps/badges/api/serializers.py deleted file mode 100644 index 2bcdd740eb..0000000000 --- a/lms/djangoapps/badges/api/serializers.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Serializers for Badges -""" - - -from rest_framework import serializers - -from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass - - -class BadgeClassSerializer(serializers.ModelSerializer): - """ - Serializer for BadgeClass model. - """ - image_url = serializers.ImageField(source='image') - - class Meta: - model = BadgeClass - fields = ('slug', 'issuing_component', 'display_name', 'course_id', 'description', 'criteria', 'image_url') - - -class BadgeAssertionSerializer(serializers.ModelSerializer): - """ - Serializer for the BadgeAssertion model. - """ - badge_class = BadgeClassSerializer(read_only=True) - - class Meta: - model = BadgeAssertion - fields = ('badge_class', 'image_url', 'assertion_url', 'created') diff --git a/lms/djangoapps/badges/api/tests.py b/lms/djangoapps/badges/api/tests.py deleted file mode 100644 index e0031c29de..0000000000 --- a/lms/djangoapps/badges/api/tests.py +++ /dev/null @@ -1,218 +0,0 @@ -""" -Tests for the badges API views. -""" - - -from ddt import data, ddt, unpack -from django.conf import settings -from django.test.utils import override_settings - -from common.djangoapps.student.tests.factories import UserFactory -from common.djangoapps.util.testing import UrlResetMixin -from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory -from openedx.core.lib.api.test_utils import ApiTestCase -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 - -FEATURES_WITH_BADGES_ENABLED = settings.FEATURES.copy() -FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True - - -@override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED) -class UserAssertionTestCase(UrlResetMixin, ModuleStoreTestCase, ApiTestCase): - """ - Mixin for badge API tests. - """ - - def setUp(self): - super().setUp() - self.course = CourseFactory.create() - self.user = UserFactory.create() - # Password defined by factory. - self.client.login(username=self.user.username, password='test') - - def url(self): - """ - Return the URL to look up the current user's assertions. - """ - return f'/api/badges/v1/assertions/user/{self.user.username}/' - - def check_class_structure(self, badge_class, json_class): - """ - Check a JSON response against a known badge class. - """ - assert badge_class.issuing_component == json_class['issuing_component'] - assert badge_class.slug == json_class['slug'] - assert badge_class.image.url in json_class['image_url'] - assert badge_class.description == json_class['description'] - assert badge_class.criteria == json_class['criteria'] - assert (badge_class.course_id and str(badge_class.course_id)) == json_class['course_id'] - - def check_assertion_structure(self, assertion, json_assertion): - """ - Check a JSON response against a known assertion object. - """ - assert assertion.image_url == json_assertion['image_url'] - assert assertion.assertion_url == json_assertion['assertion_url'] - self.check_class_structure(assertion.badge_class, json_assertion['badge_class']) - - def get_course_id(self, wildcard, badge_class): - """ - Used for tests which may need to test for a course_id or a wildcard. - """ - if wildcard: - return '*' - else: - return str(badge_class.course_id) - - def create_badge_class(self, check_course, **kwargs): - """ - Create a badge class, using a course id if it's relevant to the URL pattern. - """ - if check_course: - return RandomBadgeClassFactory.create(course_id=self.course.location.course_key, **kwargs) - return RandomBadgeClassFactory.create(**kwargs) - - def get_qs_args(self, check_course, wildcard, badge_class): - """ - Get a dictionary to be serialized into querystring params based on class settings. - """ - qs_args = { - 'issuing_component': badge_class.issuing_component, - 'slug': badge_class.slug, - } - if check_course: - qs_args['course_id'] = self.get_course_id(wildcard, badge_class) - return qs_args - - -class TestUserBadgeAssertions(UserAssertionTestCase): - """ - Test the general badge assertions retrieval view. - """ - - def test_get_assertions(self): - """ - Verify we can get all of a user's badge assertions. - """ - for dummy in range(3): - BadgeAssertionFactory(user=self.user) - # Add in a course scoped badge-- these should not be excluded from the full listing. - BadgeAssertionFactory(user=self.user, badge_class=BadgeClassFactory(course_id=self.course.location.course_key)) - # Should not be included. - for dummy in range(3): - self.create_badge_class(False) - response = self.get_json(self.url()) - assert len(response['results']) == 4 - - def test_assertion_structure(self): - badge_class = self.create_badge_class(False) - assertion = BadgeAssertionFactory.create(user=self.user, badge_class=badge_class) - response = self.get_json(self.url()) - self.check_assertion_structure(assertion, response['results'][0]) - - -class TestUserCourseBadgeAssertions(UserAssertionTestCase): - """ - Test the Badge Assertions view with the course_id filter. - """ - - def test_get_assertions(self): - """ - Verify we can get assertions via the course_id and username. - """ - course_key = self.course.location.course_key - badge_class = BadgeClassFactory.create(course_id=course_key) - for dummy in range(3): - BadgeAssertionFactory.create(user=self.user, badge_class=badge_class) - # Should not be included, as they don't share the target badge class. - for dummy in range(3): - BadgeAssertionFactory.create(user=self.user) - # Also should not be included, as they don't share the same user. - for dummy in range(6): - BadgeAssertionFactory.create(badge_class=badge_class) - response = self.get_json(self.url(), data={'course_id': str(course_key)}) - assert len(response['results']) == 3 - unused_course = CourseFactory.create() - response = self.get_json(self.url(), data={'course_id': str(unused_course.location.course_key)}) - assert len(response['results']) == 0 - - def test_assertion_structure(self): - """ - Verify the badge assertion structure is as expected when a course is involved. - """ - course_key = self.course.location.course_key - badge_class = BadgeClassFactory.create(course_id=course_key) - assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user) - response = self.get_json(self.url()) - self.check_assertion_structure(assertion, response['results'][0]) - - -@ddt -class TestUserBadgeAssertionsByClass(UserAssertionTestCase): - """ - Test the Badge Assertions view with the badge class filter. - """ - - @unpack - @data((False, False), (True, False), (True, True)) - def test_get_assertions(self, check_course, wildcard): - """ - Verify we can get assertions via the badge class and username. - """ - badge_class = self.create_badge_class(check_course) - for dummy in range(3): - BadgeAssertionFactory.create(user=self.user, badge_class=badge_class) - if badge_class.course_id: - # Also create a version of this badge under a different course. - alt_class = BadgeClassFactory.create( - slug=badge_class.slug, issuing_component=badge_class.issuing_component, - course_id=CourseFactory.create().location.course_key - ) - BadgeAssertionFactory.create(user=self.user, badge_class=alt_class) - # Same badge class, but different user. Should not show up in the list. - for dummy in range(5): - BadgeAssertionFactory.create(badge_class=badge_class) - # Different badge class AND different user. Certainly shouldn't show up in the list! - for dummy in range(6): - BadgeAssertionFactory.create() - - response = self.get_json( - self.url(), - data=self.get_qs_args(check_course, wildcard, badge_class), - ) - if wildcard: - expected_length = 4 - else: - expected_length = 3 - assert len(response['results']) == expected_length - unused_class = self.create_badge_class(check_course, slug='unused_slug', issuing_component='unused_component') - - response = self.get_json( - self.url(), - data=self.get_qs_args(check_course, wildcard, unused_class), - ) - assert len(response['results']) == 0 - - def check_badge_class_assertion(self, check_course, wildcard, badge_class): - """ - Given a badge class, create an assertion for the current user and fetch it, checking the structure. - """ - assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=self.user) - response = self.get_json( - self.url(), - data=self.get_qs_args(check_course, wildcard, badge_class), - ) - self.check_assertion_structure(assertion, response['results'][0]) - - @unpack - @data((False, False), (True, False), (True, True)) - def test_assertion_structure(self, check_course, wildcard): - self.check_badge_class_assertion(check_course, wildcard, self.create_badge_class(check_course)) - - @unpack - @data((False, False), (True, False), (True, True)) - def test_empty_issuing_component(self, check_course, wildcard): - self.check_badge_class_assertion( - check_course, wildcard, self.create_badge_class(check_course, issuing_component='') - ) diff --git a/lms/djangoapps/badges/api/urls.py b/lms/djangoapps/badges/api/urls.py deleted file mode 100644 index 95129fafcd..0000000000 --- a/lms/djangoapps/badges/api/urls.py +++ /dev/null @@ -1,14 +0,0 @@ -""" -URLs for badges API -""" - - -from django.conf import settings -from django.urls import re_path - -from .views import UserBadgeAssertions - -urlpatterns = [ - re_path('^assertions/user/' + settings.USERNAME_PATTERN + '/$', - UserBadgeAssertions.as_view(), name='user_assertions'), -] diff --git a/lms/djangoapps/badges/api/views.py b/lms/djangoapps/badges/api/views.py deleted file mode 100644 index b5b5b9cac4..0000000000 --- a/lms/djangoapps/badges/api/views.py +++ /dev/null @@ -1,139 +0,0 @@ -""" -API views for badges -""" - - -from edx_rest_framework_extensions.auth.session.authentication import \ - SessionAuthenticationAllowInactiveUser -from opaque_keys import InvalidKeyError -from opaque_keys.edx.django.models import CourseKeyField -from opaque_keys.edx.keys import CourseKey -from rest_framework import generics -from rest_framework.exceptions import APIException - -from lms.djangoapps.badges.models import BadgeAssertion -from openedx.core.djangoapps.user_api.permissions import is_field_shared_factory -from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser - -from .serializers import BadgeAssertionSerializer - - -class InvalidCourseKeyError(APIException): - """ - Raised the course key given isn't valid. - """ - status_code = 400 - default_detail = "The course key provided was invalid." - - -class UserBadgeAssertions(generics.ListAPIView): - """ - **Use Cases** - - Request a list of assertions for a user, optionally constrained to a course. - - **Example Requests** - - GET /api/badges/v1/assertions/user/{username}/ - - **Response Values** - - Body comprised of a list of objects with the following fields: - - * badge_class: The badge class the assertion was awarded for. Represented as an object - with the following fields: - * slug: The identifier for the badge class - * issuing_component: The software component responsible for issuing this badge. - * display_name: The display name of the badge. - * course_id: The course key of the course this badge is scoped to, or null if it isn't scoped to a course. - * description: A description of the award and its significance. - * criteria: A description of what is needed to obtain this award. - * image_url: A URL to the icon image used to represent this award. - * image_url: The baked assertion image derived from the badge_class icon-- contains metadata about the award - in its headers. - * assertion_url: The URL to the OpenBadges BadgeAssertion object, for verification by compatible tools - and software. - - **Params** - - * slug (optional): The identifier for a particular badge class to filter by. - * issuing_component (optional): The issuing component for a particular badge class to filter by - (requires slug to have been specified, or this will be ignored.) If slug is provided and this is not, - assumes the issuing_component should be empty. - * course_id (optional): Returns assertions that were awarded as part of a particular course. If slug is - provided, and this field is not specified, assumes that the target badge has an empty course_id field. - '*' may be used to get all badges with the specified slug, issuing_component combination across all courses. - - **Returns** - - * 200 on success, with a list of Badge Assertion objects. - * 403 if a user who does not have permission to masquerade as - another user specifies a username other than their own. - * 404 if the specified user does not exist - - { - "count": 7, - "previous": null, - "num_pages": 1, - "results": [ - { - "badge_class": { - "slug": "special_award", - "issuing_component": "openedx__course", - "display_name": "Very Special Award", - "course_id": "course-v1:edX+DemoX+Demo_Course", - "description": "Awarded for people who did something incredibly special", - "criteria": "Do something incredibly special.", - "image": "http://example.com/media/badge_classes/badges/special_xdpqpBv_9FYOZwN.png" - }, - "image_url": "http://badges.example.com/media/issued/cd75b69fc1c979fcc1697c8403da2bdf.png", - "assertion_url": "http://badges.example.com/public/assertions/07020647-e772-44dd-98b7-d13d34335ca6" - }, - ... - ] - } - """ - serializer_class = BadgeAssertionSerializer - authentication_classes = ( - BearerAuthenticationAllowInactiveUser, - SessionAuthenticationAllowInactiveUser - ) - permission_classes = (is_field_shared_factory("accomplishments_shared"),) - - def filter_queryset(self, queryset): - """ - Return most recent to least recent badge. - """ - return queryset.order_by('-created') - - def get_queryset(self): - """ - Get all badges for the username specified. - """ - queryset = BadgeAssertion.objects.filter(user__username=self.kwargs['username']) - provided_course_id = self.request.query_params.get('course_id') - if provided_course_id == '*': - # We might want to get all the matching course scoped badges to see how many courses - # a user managed to get a specific award on. - course_id = None - elif provided_course_id: - try: - course_id = CourseKey.from_string(provided_course_id) - except InvalidKeyError: - raise InvalidCourseKeyError # lint-amnesty, pylint: disable=raise-missing-from - elif 'slug' not in self.request.query_params: - # Need to get all badges for the user. - course_id = None - else: - # Django won't let us use 'None' for querying a ForeignKey field. We have to use this special - # 'Empty' value to indicate we're looking only for badges without a course key set. - course_id = CourseKeyField.Empty - - if course_id is not None: - queryset = queryset.filter(badge_class__course_id=course_id) - if self.request.query_params.get('slug'): - queryset = queryset.filter( - badge_class__slug=self.request.query_params['slug'], - badge_class__issuing_component=self.request.query_params.get('issuing_component', '') - ) - return queryset diff --git a/lms/djangoapps/badges/apps.py b/lms/djangoapps/badges/apps.py index 4380a609b2..778c1b5790 100644 --- a/lms/djangoapps/badges/apps.py +++ b/lms/djangoapps/badges/apps.py @@ -13,9 +13,3 @@ class BadgesConfig(AppConfig): Application Configuration for Badges. """ name = 'lms.djangoapps.badges' - - def ready(self): - """ - Connect signal handlers. - """ - from . import handlers # pylint: disable=unused-import diff --git a/lms/djangoapps/badges/backends/__init__.py b/lms/djangoapps/badges/backends/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/badges/backends/badgr.py b/lms/djangoapps/badges/backends/badgr.py deleted file mode 100644 index 7569c41a2a..0000000000 --- a/lms/djangoapps/badges/backends/badgr.py +++ /dev/null @@ -1,324 +0,0 @@ -""" -Badge Awarding backend for Badgr-Server. -""" - - -import base64 -import datetime -import json -import hashlib -import logging -import mimetypes - -import requests -from cryptography.fernet import Fernet -from django.conf import settings -from django.core.exceptions import ImproperlyConfigured -from eventtracking import tracker -from lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module -from requests.packages.urllib3.exceptions import HTTPError # lint-amnesty, pylint: disable=import-error - -from edx_django_utils.cache import TieredCache - -from lms.djangoapps.badges.backends.base import BadgeBackend -from lms.djangoapps.badges.models import BadgeAssertion - -MAX_SLUG_LENGTH = 255 -LOGGER = logging.getLogger(__name__) - - -class BadgrBackend(BadgeBackend): - """ - Backend for Badgr-Server by Concentric Sky. http://info.badgr.io/ - """ - badges = [] - - def __init__(self): - super().__init__() - if None in (settings.BADGR_USERNAME, - settings.BADGR_PASSWORD, - settings.BADGR_TOKENS_CACHE_KEY, - settings.BADGR_ISSUER_SLUG, - settings.BADGR_BASE_URL): - error_msg = ( - "One or more of the required settings are not defined. " - "Required settings: BADGR_USERNAME, BADGR_PASSWORD, " - "BADGR_TOKENS_CACHE_KEY, BADGR_ISSUER_SLUG, BADGR_BASE_URL.") - LOGGER.error(error_msg) - raise ImproperlyConfigured(error_msg) - - @lazy - def _base_url(self): - """ - Base URL for API requests that contain the issuer slug. - """ - return f"{settings.BADGR_BASE_URL}/v2/issuers/{settings.BADGR_ISSUER_SLUG}" - - @lazy - def _badge_create_url(self): - """ - URL for generating a new Badge specification - """ - return f"{self._base_url}/badgeclasses" - - def _badge_url(self, slug): - """ - Get the URL for a course's badge in a given mode. - """ - return f"{settings.BADGR_BASE_URL}/v2/badgeclasses/{slug}" - - def _assertion_url(self, slug): - """ - URL for generating a new assertion. - """ - return f"{self._badge_url(slug)}/assertions" - - def _slugify(self, badge_class): - """ - Get a compatible badge slug from the specification. - """ - slug = badge_class.issuing_component + badge_class.slug - if badge_class.issuing_component and badge_class.course_id: - # Make this unique to the course, and down to 64 characters. - # We don't do this to badges without issuing_component set for backwards compatibility. - slug = hashlib.sha256((slug + str(badge_class.course_id)).encode('utf-8')).hexdigest() - if len(slug) > MAX_SLUG_LENGTH: - # Will be 64 characters. - slug = hashlib.sha256(slug).hexdigest() - return slug - - def _log_if_raised(self, response, data): - """ - Log server response if there was an error. - """ - try: - response.raise_for_status() - except HTTPError: - LOGGER.error( - "Encountered an error when contacting the Badgr-Server. Request sent to %r with headers %r.\n" - "and data values %r\n" - "Response status was %s.\n%s", - response.request.url, response.request.headers, - data, - response.status_code, response.content - ) - raise - - def _create_badge(self, badge_class): - """ - Create the badge class on Badgr. - """ - image = badge_class.image - # We don't want to bother validating the file any further than making sure we can detect its MIME type, - # for HTTP. The Badgr-Server should tell us if there's anything in particular wrong with it. - content_type, __ = mimetypes.guess_type(image.name) - if not content_type: - raise ValueError( - "Could not determine content-type of image! Make sure it is a properly named .png file. " - "Filename was: {}".format(image.name) - ) - with open(image.path, 'rb') as image_file: - files = {'image': (image.name, image_file, content_type)} - data = { - 'name': badge_class.display_name, - 'criteriaUrl': badge_class.criteria, - 'description': badge_class.description, - } - result = requests.post( - self._badge_create_url, headers=self._get_headers(), - data=data, files=files, timeout=settings.BADGR_TIMEOUT) - self._log_if_raised(result, data) - try: - result_json = result.json() - badgr_badge_class = result_json['result'][0] - badgr_server_slug = badgr_badge_class.get('entityId') - badge_class.badgr_server_slug = badgr_server_slug - badge_class.save() - except Exception as excep: # pylint: disable=broad-except - LOGGER.error( - 'Error on saving Badgr Server Slug of badge_class slug ' - '"{0}" with response json "{1}" : {2}'.format( - badge_class.slug, result.json(), excep)) - - def _send_assertion_created_event(self, user, assertion): - """ - Send an analytics event to record the creation of a badge assertion. - """ - tracker.emit( - 'edx.badge.assertion.created', { - 'user_id': user.id, - 'badge_slug': assertion.badge_class.slug, - 'badge_badgr_server_slug': assertion.badge_class.badgr_server_slug, - 'badge_name': assertion.badge_class.display_name, - 'issuing_component': assertion.badge_class.issuing_component, - 'course_id': str(assertion.badge_class.course_id), - 'enrollment_mode': assertion.badge_class.mode, - 'assertion_id': assertion.id, - 'assertion_image_url': assertion.image_url, - 'assertion_json_url': assertion.assertion_url, - 'issuer': assertion.data.get('issuer'), - } - ) - - def _create_assertion(self, badge_class, user, evidence_url): - """ - Register an assertion with the Badgr server for a particular user for a specific class. - """ - data = { - "recipient": { - "identity": user.email, - "type": "email" - }, - "evidence": [ - { - "url": evidence_url - } - ], - "notify": settings.BADGR_ENABLE_NOTIFICATIONS, - } - response = requests.post( - self._assertion_url(badge_class.badgr_server_slug), - headers=self._get_headers(), - json=data, - timeout=settings.BADGR_TIMEOUT - ) - self._log_if_raised(response, data) - assertion, __ = BadgeAssertion.objects.get_or_create(user=user, badge_class=badge_class) - try: - response_json = response.json() - assertion.data = response_json['result'][0] - assertion.image_url = assertion.data['image'] - assertion.assertion_url = assertion.data['openBadgeId'] - assertion.backend = 'BadgrBackend' - assertion.save() - self._send_assertion_created_event(user, assertion) - return assertion - - except Exception as exc: # pylint: disable=broad-except - LOGGER.error( - 'Error saving BadgeAssertion for user: "{0}" ' - 'with response from server: {1};' - 'Encountered exception: {2}'.format( - user.email, response.text, exc)) - - @staticmethod - def _fernet_setup(): - """ - Set up the Fernet class for encrypting/decrypting tokens. - Fernet keys must always be URL-safe base64 encoded 32-byte binary - strings. Use the SECRET_KEY for creating the encryption key. - """ - fernet_key = base64.urlsafe_b64encode( - settings.SECRET_KEY.ljust(64).encode('utf-8')[:32] - ) - return Fernet(fernet_key) - - def _encrypt_token(self, token): - """ - Encrypt a token - """ - fernet = self._fernet_setup() - return fernet.encrypt(token.encode('utf-8')) - - def _decrypt_token(self, token): - """ - Decrypt a token - """ - fernet = self._fernet_setup() - return fernet.decrypt(token).decode() - - def _get_and_cache_oauth_tokens(self, refresh_token=None): - """ - Get or renew OAuth tokens. If a refresh_token is provided, - use it to renew tokens, otherwise create new ones. - Once tokens are created/renewed, encrypt the values and cache them. - """ - data = { - 'username': settings.BADGR_USERNAME, - 'password': settings.BADGR_PASSWORD, - } - if refresh_token: - data = { - 'grant_type': 'refresh_token', - 'refresh_token': refresh_token - } - - oauth_url = "{}/o/token".format(settings.BADGR_BASE_URL) - - response = requests.post( - oauth_url, data=data, timeout=settings.BADGR_TIMEOUT - ) - self._log_if_raised(response, data) - try: - data = response.json() - result = { - 'access_token': self._encrypt_token(data['access_token']), - 'refresh_token': self._encrypt_token(data['refresh_token']), - 'expires_at': datetime.datetime.utcnow() + datetime.timedelta( - seconds=data['expires_in']) - } - # The refresh_token is long-lived, we want to be able to retrieve - # it from cache as long as possible. - # Set the cache timeout to None so the cache key never expires - # (https://docs.djangoproject.com/en/2.2/topics/cache/#cache-arguments) - TieredCache.set_all_tiers( - settings.BADGR_TOKENS_CACHE_KEY, result, None) - return result - except (KeyError, json.decoder.JSONDecodeError) as json_error: - raise requests.RequestException(response=response) from json_error - - def _get_access_token(self): - """ - Get an access token from cache if one is present and valid. If a - token is cached but expired, renew it. If all fails or a token has - not yet been cached, create a new one. - """ - tokens = {} - cached_response = TieredCache.get_cached_response( - settings.BADGR_TOKENS_CACHE_KEY) - if cached_response.is_found: - cached_tokens = cached_response.value - # add a 5 seconds buffer to the cutoff timestamp to make sure - # the token will not expire while in use - expiry_cutoff = ( - datetime.datetime.utcnow() + datetime.timedelta(seconds=5)) - if cached_tokens.get('expires_at') > expiry_cutoff: - tokens = cached_tokens - else: - # renew the tokens with the cached `refresh_token` - refresh_token = self._decrypt_token(cached_tokens.get( - 'refresh_token')) - tokens = self._get_and_cache_oauth_tokens( - refresh_token=refresh_token) - - # if no tokens are cached or something went wrong with - # retreiving/renewing them, go and create new tokens - if not tokens: - tokens = self._get_and_cache_oauth_tokens() - return self._decrypt_token(tokens.get('access_token')) - - def _get_headers(self): - """ - Headers to send along with the request-- used for authentication. - """ - access_token = self._get_access_token() - return {'Authorization': 'Bearer {}'.format(access_token)} - - def _ensure_badge_created(self, badge_class): - """ - Verify a badge has been created for this badge class, and create it if not. - """ - slug = badge_class.badgr_server_slug - if slug in BadgrBackend.badges: - return - response = requests.get(self._badge_url(slug), headers=self._get_headers(), timeout=settings.BADGR_TIMEOUT) - if response.status_code != 200: - self._create_badge(badge_class) - BadgrBackend.badges.append(slug) - - def award(self, badge_class, user, evidence_url=None): - """ - Make sure the badge class has been created on the backend, and then award the badge class to the user. - """ - self._ensure_badge_created(badge_class) - return self._create_assertion(badge_class, user, evidence_url) diff --git a/lms/djangoapps/badges/backends/base.py b/lms/djangoapps/badges/backends/base.py deleted file mode 100644 index 868ef0df74..0000000000 --- a/lms/djangoapps/badges/backends/base.py +++ /dev/null @@ -1,18 +0,0 @@ -""" -Base class for badge backends. -""" - - -from abc import ABCMeta, abstractmethod - - -class BadgeBackend(metaclass=ABCMeta): - """ - Defines the interface for badging backends. - """ - - @abstractmethod - def award(self, badge_class, user, evidence_url=None): - """ - Create a badge assertion for the user using this backend. - """ diff --git a/lms/djangoapps/badges/backends/tests/__init__.py b/lms/djangoapps/badges/backends/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/badges/backends/tests/dummy_backend.py b/lms/djangoapps/badges/backends/tests/dummy_backend.py deleted file mode 100644 index 042ffe9b24..0000000000 --- a/lms/djangoapps/badges/backends/tests/dummy_backend.py +++ /dev/null @@ -1,15 +0,0 @@ -""" -Dummy backend, for use in testing. -""" - - -from lms.djangoapps.badges.backends.base import BadgeBackend -from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory - - -class DummyBackend(BadgeBackend): - """ - Dummy backend that creates assertions without contacting any real-world backend. - """ - def award(self, badge_class, user, evidence_url=None): - return BadgeAssertionFactory(badge_class=badge_class, user=user) diff --git a/lms/djangoapps/badges/backends/tests/test_badgr_backend.py b/lms/djangoapps/badges/backends/tests/test_badgr_backend.py deleted file mode 100644 index 7e9f2d367a..0000000000 --- a/lms/djangoapps/badges/backends/tests/test_badgr_backend.py +++ /dev/null @@ -1,301 +0,0 @@ -""" -Tests for BadgrBackend -""" - -import datetime -from unittest.mock import Mock, call, patch - -import json -import ddt -import httpretty -from django.test.utils import override_settings -from lazy.lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module - -from edx_django_utils.cache import TieredCache -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory -from common.djangoapps.track.tests import FROZEN_TIME, EventTrackingTestCase -from lms.djangoapps.badges.backends.badgr import BadgrBackend -from lms.djangoapps.badges.models import BadgeAssertion -from lms.djangoapps.badges.tests.factories import BadgeClassFactory -from openedx.core.lib.tests.assertions.events import assert_event_matches -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 - -BADGR_SETTINGS = { - 'BADGR_BASE_URL': 'https://example.com', - 'BADGR_ISSUER_SLUG': 'test-issuer', - 'BADGR_USERNAME': 'example@example.com', - 'BADGR_PASSWORD': 'password', - 'BADGR_TOKENS_CACHE_KEY': 'badgr-test-cache-key' -} - -# Should be the hashed result of test_slug as the slug, and test_component as the component -EXAMPLE_SLUG = '9e915d55bb304a73d20c453531d3c27f81574218413c23903823d20d11b587ae' -BADGR_SERVER_SLUG = 'test_badgr_server_slug' - - -# pylint: disable=protected-access -@ddt.ddt -@override_settings(**BADGR_SETTINGS) -@httpretty.activate -class BadgrBackendTestCase(ModuleStoreTestCase, EventTrackingTestCase): - """ - Tests the BadgeHandler object - """ - - def setUp(self): - """ - Create a course and user to test with. - """ - super().setUp() - # Need key to be deterministic to test slugs. - self.course = CourseFactory.create( - org='edX', course='course_test', run='test_run', display_name='Badged', - start=datetime.datetime(year=2015, month=5, day=19), - end=datetime.datetime(year=2015, month=5, day=20) - ) - self.user = UserFactory.create(email='example@example.com') - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.location.course_key, mode='honor') - # Need to empty this on each run. - BadgrBackend.badges = [] - self.badge_class = BadgeClassFactory.create(course_id=self.course.location.course_key) - self.legacy_badge_class = BadgeClassFactory.create( - course_id=self.course.location.course_key, issuing_component='' - ) - self.no_course_badge_class = BadgeClassFactory.create() - TieredCache.dangerous_clear_all_tiers() - httpretty.httpretty.reset() - - @lazy - def handler(self): - """ - Lazily loads a BadgeHandler object for the current course. Can't do this on setUp because the settings - overrides aren't in place. - """ - return BadgrBackend() - - def _mock_badgr_tokens_api(self, result): - assert httpretty.is_enabled() - responses = [httpretty.Response(body=json.dumps(result), - content_type='application/json')] - httpretty.register_uri(httpretty.POST, - 'https://example.com/o/token', - responses=responses) - - def test_urls(self): - """ - Make sure the handler generates the correct URLs for different API tasks. - """ - assert self.handler._base_url == 'https://example.com/v2/issuers/test-issuer' - # lint-amnesty, pylint: disable=no-member - assert self.handler._badge_create_url == 'https://example.com/v2/issuers/test-issuer/badgeclasses' - # lint-amnesty, pylint: disable=no-member - assert self.handler._badge_url('test_slug_here') ==\ - 'https://example.com/v2/badgeclasses/test_slug_here' - assert self.handler._assertion_url('another_test_slug') ==\ - 'https://example.com/v2/badgeclasses/another_test_slug/assertions' - - def check_headers(self, headers): - """ - Verify the a headers dict from a requests call matches the proper auth info. - """ - assert headers == {'Authorization': 'Bearer 12345'} - - def test_get_headers(self): - """ - Check to make sure the handler generates appropriate HTTP headers. - """ - self.handler._get_access_token = Mock(return_value='12345') - self.check_headers(self.handler._get_headers()) # lint-amnesty, pylint: disable=no-member - - @patch('requests.post') - def test_create_badge(self, post): - """ - Verify badge spec creation works. - """ - self.handler._get_access_token = Mock(return_value='12345') - with self.allow_transaction_exception(): - self.handler._create_badge(self.badge_class) - args, kwargs = post.call_args - assert args[0] == 'https://example.com/v2/issuers/test-issuer/badgeclasses' - assert kwargs['files']['image'][0] == self.badge_class.image.name - assert kwargs['files']['image'][2] == 'image/png' - self.check_headers(kwargs['headers']) - assert kwargs['data'] ==\ - {'name': 'Test Badge', - 'criteriaUrl': 'https://example.com/syllabus', - 'description': "Yay! It's a test badge."} - - def test_ensure_badge_created_cache(self): - """ - Make sure ensure_badge_created doesn't call create_badge if we know the badge is already there. - """ - BadgrBackend.badges.append(BADGR_SERVER_SLUG) - self.handler._create_badge = Mock() - self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member - assert not self.handler._create_badge.called - - @ddt.unpack - @ddt.data( - ('badge_class', EXAMPLE_SLUG), - ('legacy_badge_class', 'test_slug'), - ('no_course_badge_class', 'test_componenttest_slug') - ) - def test_slugs(self, badge_class_type, slug): - assert self.handler._slugify(getattr(self, badge_class_type)) == slug - # lint-amnesty, pylint: disable=no-member - - @patch('requests.get') - def test_ensure_badge_created_checks(self, get): - response = Mock() - response.status_code = 200 - get.return_value = response - assert 'test_componenttest_slug' not in BadgrBackend.badges - self.handler._get_access_token = Mock(return_value='12345') - self.handler._create_badge = Mock() - self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member - assert get.called - args, kwargs = get.call_args - assert args[0] == ( - 'https://example.com/v2/badgeclasses/' + - BADGR_SERVER_SLUG) - self.check_headers(kwargs['headers']) - assert BADGR_SERVER_SLUG in BadgrBackend.badges - assert not self.handler._create_badge.called - - @patch('requests.get') - def test_ensure_badge_created_creates(self, get): - response = Mock() - response.status_code = 404 - get.return_value = response - assert BADGR_SERVER_SLUG not in BadgrBackend.badges - self.handler._get_access_token = Mock(return_value='12345') - self.handler._create_badge = Mock() - self.handler._ensure_badge_created(self.badge_class) # lint-amnesty, pylint: disable=no-member - assert self.handler._create_badge.called - assert self.handler._create_badge.call_args == call(self.badge_class) - assert BADGR_SERVER_SLUG in BadgrBackend.badges - - @patch('requests.post') - def test_badge_creation_event(self, post): - result = { - 'result': [{ - 'openBadgeId': 'http://www.example.com/example', - 'image': 'http://www.example.com/example.png', - 'issuer': 'https://example.com/v2/issuers/test-issuer' - }] - } - response = Mock() - response.json.return_value = result - post.return_value = response - self.recreate_tracker() - self.handler._get_access_token = Mock(return_value='12345') - self.handler._create_assertion(self.badge_class, self.user, 'https://example.com/irrefutable_proof') # lint-amnesty, pylint: disable=no-member - args, kwargs = post.call_args - assert args[0] == (( - 'https://example.com/v2/badgeclasses/' + - BADGR_SERVER_SLUG) + - '/assertions') - self.check_headers(kwargs['headers']) - assertion = BadgeAssertion.objects.get(user=self.user, badge_class__course_id=self.course.location.course_key) - assert assertion.data == result['result'][0] - assert assertion.image_url == 'http://www.example.com/example.png' - assert assertion.assertion_url == 'http://www.example.com/example' - assert kwargs['json'] == {"recipient": {"identity": 'example@example.com', "type": "email"}, - "evidence": [{"url": 'https://example.com/irrefutable_proof'}], - "notify": False} - assert_event_matches({ - 'name': 'edx.badge.assertion.created', - 'data': { - 'user_id': self.user.id, - 'course_id': str(self.course.location.course_key), - 'enrollment_mode': 'honor', - 'assertion_id': assertion.id, - 'badge_name': 'Test Badge', - 'badge_slug': 'test_slug', - 'badge_badgr_server_slug': BADGR_SERVER_SLUG, - 'issuing_component': 'test_component', - 'assertion_image_url': 'http://www.example.com/example.png', - 'assertion_json_url': 'http://www.example.com/example', - 'issuer': 'https://example.com/v2/issuers/test-issuer', - }, - 'context': {}, - 'timestamp': FROZEN_TIME - }, self.get_event()) - - def test_get_new_tokens(self): - result = { - 'access_token': '12345', - 'refresh_token': '67890', - 'expires_in': 86400, - } - self._mock_badgr_tokens_api(result) - self.handler._get_and_cache_oauth_tokens() - assert 'o/token' in httpretty.httpretty.last_request.path - assert httpretty.httpretty.last_request.parsed_body == { - 'username': ['example@example.com'], - 'password': ['password']} - - def test_renew_tokens(self): - result = { - 'access_token': '12345', - 'refresh_token': '67890', - 'expires_in': 86400, - } - self._mock_badgr_tokens_api(result) - self.handler._get_and_cache_oauth_tokens(refresh_token='67890') - assert 'o/token' in httpretty.httpretty.last_request.path - assert httpretty.httpretty.last_request.parsed_body == { - 'grant_type': ['refresh_token'], - 'refresh_token': ['67890']} - - def test_get_access_token_from_cache_valid(self): - encrypted_access_token = self.handler._encrypt_token('12345') - encrypted_refresh_token = self.handler._encrypt_token('67890') - tokens = { - 'access_token': encrypted_access_token, - 'refresh_token': encrypted_refresh_token, - 'expires_at': datetime.datetime.utcnow() + datetime.timedelta(seconds=20) - } - TieredCache.set_all_tiers('badgr-test-cache-key', tokens, None) - - access_token = self.handler._get_access_token() - assert access_token == self.handler._decrypt_token( - tokens.get('access_token')) - - def test_get_access_token_from_cache_expired(self): - encrypted_access_token = self.handler._encrypt_token('12345') - encrypted_refresh_token = self.handler._encrypt_token('67890') - tokens = { - 'access_token': encrypted_access_token, - 'refresh_token': encrypted_refresh_token, - 'expires_at': datetime.datetime.utcnow() - } - TieredCache.set_all_tiers('badgr-test-cache-key', tokens, None) - result = { - 'access_token': '12345', - 'refresh_token': '67890', - 'expires_in': 86400, - } - self._mock_badgr_tokens_api(result) - access_token = self.handler._get_access_token() - assert access_token == result.get('access_token') - assert 'o/token' in httpretty.httpretty.last_request.path - assert httpretty.httpretty.last_request.parsed_body == { - 'grant_type': ['refresh_token'], - 'refresh_token': [self.handler._decrypt_token( - tokens.get('refresh_token'))]} - - def test_get_access_token_from_cache_none(self): - result = { - 'access_token': '12345', - 'refresh_token': '67890', - 'expires_in': 86400, - } - self._mock_badgr_tokens_api(result) - access_token = self.handler._get_access_token() - assert access_token == result.get('access_token') - assert 'o/token' in httpretty.httpretty.last_request.path - assert httpretty.httpretty.last_request.parsed_body == { - 'username': ['example@example.com'], - 'password': ['password']} diff --git a/lms/djangoapps/badges/events/course_complete.py b/lms/djangoapps/badges/events/course_complete.py index 853465dfb8..654e3859ef 100644 --- a/lms/djangoapps/badges/events/course_complete.py +++ b/lms/djangoapps/badges/events/course_complete.py @@ -6,14 +6,9 @@ Helper functions for the course complete event that was originally included with import hashlib import logging -from django.urls import reverse from django.utils.text import slugify from django.utils.translation import gettext_lazy as _ -from lms.djangoapps.badges.models import BadgeAssertion, BadgeClass, CourseCompleteImageConfiguration -from lms.djangoapps.badges.utils import requires_badges_enabled, site_prefix -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order - LOGGER = logging.getLogger(__name__) @@ -25,9 +20,7 @@ def course_slug(course_key, mode): """ Legacy: Not to be used as a model for constructing badge slugs. Included for compatibility with the original badge type, awarded on course completion. - Slug ought to be deterministic and limited in size so it's not too big for Badgr. - Badgr's max slug length is 255. """ # Seven digits should be enough to realistically avoid collisions. That's what git services use. @@ -65,65 +58,4 @@ def evidence_url(user_id, course_key): Generates a URL to the user's Certificate HTML view, along with a GET variable that will signal the evidence visit event. """ - course_id = str(course_key) - # avoid circular import problems - from lms.djangoapps.certificates.models import GeneratedCertificate - cert = GeneratedCertificate.eligible_certificates.get(user__id=int(user_id), course_id=course_id) - return site_prefix() + reverse( - 'certificates:render_cert_by_uuid', kwargs={'certificate_uuid': cert.verify_uuid}) + '?evidence_visit=1' - - -def criteria(course_key): - """ - Constructs the 'criteria' URL from the course about page. - """ - about_path = reverse('about_course', kwargs={'course_id': str(course_key)}) - return f'{site_prefix()}{about_path}' - - -def get_completion_badge(course_id, user): - """ - Given a course key and a user, find the user's enrollment mode - and get the Course Completion badge. - """ - from common.djangoapps.student.models import CourseEnrollment - badge_classes = CourseEnrollment.objects.filter( - user=user, course_id=course_id - ).order_by('-is_active') - if not badge_classes: - return None - mode = badge_classes[0].mode - course = modulestore().get_course(course_id) - if not course.issue_badges: - return None - return BadgeClass.get_badge_class( - slug=course_slug(course_id, mode), - issuing_component='', - criteria=criteria(course_id), - description=badge_description(course, mode), - course_id=course_id, - mode=mode, - display_name=course.display_name, - image_file_handle=CourseCompleteImageConfiguration.image_for_mode(mode) - ) - - -@requires_badges_enabled -def course_badge_check(user, course_key): - """ - Takes a GeneratedCertificate instance, and checks to see if a badge exists for this course, creating - it if not, should conditions be right. - """ - if not modulestore().get_course(course_key).issue_badges: - LOGGER.info("Course is not configured to issue badges.") - return - badge_class = get_completion_badge(course_key, user) - if not badge_class: - # We're not configured to make a badge for this course mode. - return - if BadgeAssertion.objects.filter(user=user, badge_class=badge_class): - LOGGER.info("Completion badge already exists for this user on this course.") - # Badge already exists. Skip. - return - evidence = evidence_url(user.id, course_key) - badge_class.award(user, evidence_url=evidence) + return diff --git a/lms/djangoapps/badges/events/course_meta.py b/lms/djangoapps/badges/events/course_meta.py deleted file mode 100644 index bef7b8dd79..0000000000 --- a/lms/djangoapps/badges/events/course_meta.py +++ /dev/null @@ -1,82 +0,0 @@ -""" -Events which have to do with a user doing something with more than one course, such -as enrolling in a certain number, completing a certain number, or completing a specific set of courses. -""" - - -from lms.djangoapps.badges.models import BadgeClass, CourseEventBadgesConfiguration -from lms.djangoapps.badges.utils import requires_badges_enabled - - -def award_badge(config, count, user): - """ - Given one of the configurations for enrollments or completions, award - the appropriate badge if one is configured. - - config is a dictionary with integer keys and course keys as values. - count is the key to retrieve from this dictionary. - user is the user to award the badge to. - - Example config: - {3: 'slug_for_badge_for_three_enrollments', 5: 'slug_for_badge_with_five_enrollments'} - """ - slug = config.get(count) - if not slug: - return - badge_class = BadgeClass.get_badge_class( - slug=slug, issuing_component='openedx__course', create=False, - ) - if not badge_class: - return - if not badge_class.get_for_user(user): - badge_class.award(user) - - -def award_enrollment_badge(user): - """ - Awards badges based on the number of courses a user is enrolled in. - """ - config = CourseEventBadgesConfiguration.current().enrolled_settings - enrollments = user.courseenrollment_set.filter(is_active=True).count() - award_badge(config, enrollments, user) - - -@requires_badges_enabled -def completion_check(user): - """ - Awards badges based upon the number of courses a user has 'completed'. - Courses are never truly complete, but they can be closed. - - For this reason we use checks on certificates to find out if a user has - completed courses. This badge will not work if certificate generation isn't - enabled and run. - """ - from lms.djangoapps.certificates.data import CertificateStatuses - config = CourseEventBadgesConfiguration.current().completed_settings - certificates = user.generatedcertificate_set.filter(status__in=CertificateStatuses.PASSED_STATUSES).count() - award_badge(config, certificates, user) - - -@requires_badges_enabled -def course_group_check(user, course_key): - """ - Awards a badge if a user has completed every course in a defined set. - """ - from lms.djangoapps.certificates.data import CertificateStatuses - config = CourseEventBadgesConfiguration.current().course_group_settings - awards = [] - for slug, keys in config.items(): - if course_key in keys: - certs = user.generatedcertificate_set.filter( - status__in=CertificateStatuses.PASSED_STATUSES, - course_id__in=keys, - ) - if len(certs) == len(keys): - awards.append(slug) - - for slug in awards: - badge_class = BadgeClass.get_badge_class( - slug=slug, issuing_component='openedx__course', create=False, - ) - if badge_class and not badge_class.get_for_user(user): - badge_class.award(user) diff --git a/lms/djangoapps/badges/events/tests/__init__.py b/lms/djangoapps/badges/events/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/badges/events/tests/test_course_complete.py b/lms/djangoapps/badges/events/tests/test_course_complete.py deleted file mode 100644 index 582415d9c1..0000000000 --- a/lms/djangoapps/badges/events/tests/test_course_complete.py +++ /dev/null @@ -1,71 +0,0 @@ -""" -Tests for the course completion helper functions. -""" -from datetime import datetime -from uuid import uuid4 - -from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.badges.events import course_complete -from lms.djangoapps.certificates.models import GeneratedCertificate -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 - - -class CourseCompleteTestCase(ModuleStoreTestCase): - """ - Tests for the course completion helper functions. - """ - - def setUp(self): - super().setUp() - # Need key to be deterministic to test slugs. - self.course = CourseFactory.create( - org='edX', course='course_test', run='test_run', display_name='Badged', - start=datetime(year=2015, month=5, day=19), - end=datetime(year=2015, month=5, day=20) - ) - self.course_key = self.course.location.course_key - - def test_slug(self): - """ - Verify slug generation is working as expected. If this test fails, the algorithm has changed, and it will cause - the handler to lose track of all badges it made in the past. - """ - assert course_complete.course_slug(self.course_key, 'honor') ==\ - 'course-v1edxcourse_testtest_run_honor_2055051' - assert course_complete.course_slug(self.course_key, 'verified') ==\ - 'course-v1edxcourse_testtest_run_verified_d550ad7' - - def test_dated_description(self): - """ - Verify that a course with start/end dates contains a description with them. - """ - assert course_complete.badge_description(self.course, 'honor') ==\ - 'Completed the course "Badged" (honor, 2015-05-19 - 2015-05-20)' - - def test_self_paced_description(self): - """ - Verify that a badge created for a course with no end date gets a different description. - """ - self.course.end = None - assert course_complete.badge_description(self.course, 'honor') == 'Completed the course "Badged" (honor)' - - def test_evidence_url(self): - """ - Make sure the evidence URL points to the right place. - """ - user = UserFactory.create() - cert = GeneratedCertificate.eligible_certificates.create( - user=user, - course_id=self.course_key, - download_uuid=uuid4(), - grade="0.95", - key='the_key', - distinction=True, - status='downloadable', - mode='honor', - name=user.profile.name, - verify_uuid=uuid4().hex - ) - assert f'https://edx.org/certificates/{cert.verify_uuid}?evidence_visit=1' ==\ - course_complete.evidence_url(user.id, self.course_key) diff --git a/lms/djangoapps/badges/events/tests/test_course_meta.py b/lms/djangoapps/badges/events/tests/test_course_meta.py deleted file mode 100644 index a4c6138529..0000000000 --- a/lms/djangoapps/badges/events/tests/test_course_meta.py +++ /dev/null @@ -1,184 +0,0 @@ -""" -Tests the course meta badging events -""" - - -from unittest.mock import patch - -from ddt import data, ddt, unpack -from django.conf import settings -from django.test.utils import override_settings - -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.badges.tests.factories import CourseEventBadgesConfigurationFactory, RandomBadgeClassFactory -from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.models import GeneratedCertificate -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 - - -@ddt -@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) -@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend') -class CourseEnrollmentBadgeTest(ModuleStoreTestCase): - """ - Tests the event which awards badges based on number of courses a user is enrolled in. - """ - - def setUp(self): - super().setUp() - self.badge_classes = [ - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - ] - nums = ['3', '5', '8'] - entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])] - enrollment_config = '\r'.join(entries) - self.config = CourseEventBadgesConfigurationFactory(courses_enrolled=enrollment_config) - - def test_no_match(self): - """ - Make sure a badge isn't created before a user's reached any checkpoint. - """ - user = UserFactory() - course = CourseFactory() - CourseEnrollment.enroll(user, course_key=course.location.course_key) - assert not user.badgeassertion_set.all() - - @unpack - @data((1, 3), (2, 5), (3, 8)) - def test_checkpoint_matches(self, checkpoint, required_badges): - """ - Make sure the proper badges are awarded at the right checkpoints. - """ - user = UserFactory() - courses = [CourseFactory() for _i in range(required_badges)] - for course in courses: - CourseEnrollment.enroll(user, course_key=course.location.course_key) - assertions = user.badgeassertion_set.all().order_by('id') - assert user.badgeassertion_set.all().count() == checkpoint - assert assertions[(checkpoint - 1)].badge_class == self.badge_classes[(checkpoint - 1)] - - -@ddt -@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) -@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend') -class CourseCompletionBadgeTest(ModuleStoreTestCase): - """ - Tests the event which awards badges based on the number of courses completed. - """ - - def setUp(self): - super().setUp() - self.badge_classes = [ - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - ] - nums = ['2', '6', '9'] - entries = [','.join(pair) for pair in zip(nums, [badge.slug for badge in self.badge_classes])] - completed_config = '\r'.join(entries) - self.config = CourseEventBadgesConfigurationFactory.create(courses_completed=completed_config) - self.config.clean_fields() - - def test_no_match(self): - """ - Make sure a badge isn't created before a user's reached any checkpoint. - """ - user = UserFactory() - course = CourseFactory() - GeneratedCertificate( - user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable - ).save() - assert not user.badgeassertion_set.all() - - @unpack - @data((1, 2), (2, 6), (3, 9)) - def test_checkpoint_matches(self, checkpoint, required_badges): - """ - Make sure the proper badges are awarded at the right checkpoints. - """ - user = UserFactory() - courses = [CourseFactory() for _i in range(required_badges)] - for course in courses: - GeneratedCertificate( - user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable - ).save() - assertions = user.badgeassertion_set.all().order_by('id') - assert user.badgeassertion_set.all().count() == checkpoint - assert assertions[(checkpoint - 1)].badge_class == self.badge_classes[(checkpoint - 1)] - - -@patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) -@override_settings(BADGING_BACKEND='lms.djangoapps.badges.backends.tests.dummy_backend.DummyBackend') -class CourseGroupBadgeTest(ModuleStoreTestCase): - """ - Tests the event which awards badges when a user completes a set of courses. - """ - - def setUp(self): - super().setUp() - self.badge_classes = [ - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - RandomBadgeClassFactory( - issuing_component='openedx__course' - ), - ] - self.courses = [] - for _badge_class in self.badge_classes: - self.courses.append([CourseFactory().location.course_key for _i in range(3)]) # lint-amnesty, pylint: disable=no-member - lines = [badge_class.slug + ',' + ','.join([str(course_key) for course_key in keys]) - for badge_class, keys in zip(self.badge_classes, self.courses)] - config = '\r'.join(lines) - self.config = CourseEventBadgesConfigurationFactory(course_groups=config) - self.config_map = dict(list(zip(self.badge_classes, self.courses))) - - def test_no_match(self): - """ - Make sure a badge isn't created before a user's completed any course groups. - """ - user = UserFactory() - course = CourseFactory() - GeneratedCertificate( - user=user, course_id=course.location.course_key, status=CertificateStatuses.downloadable - ).save() - assert not user.badgeassertion_set.all() - - def test_group_matches(self): - """ - Make sure the proper badges are awarded when groups are completed. - """ - user = UserFactory() - items = list(self.config_map.items()) - for badge_class, course_keys in items: - for i, key in enumerate(course_keys): - GeneratedCertificate( - user=user, course_id=key, status=CertificateStatuses.downloadable - ).save() - # We don't award badges until all three are set. - if i + 1 == len(course_keys): - assert badge_class.get_for_user(user) - else: - assert not badge_class.get_for_user(user) - classes = [badge.badge_class.id for badge in user.badgeassertion_set.all()] - source_classes = [badge.id for badge in self.badge_classes] - assert classes == source_classes diff --git a/lms/djangoapps/badges/handlers.py b/lms/djangoapps/badges/handlers.py deleted file mode 100644 index d65448128a..0000000000 --- a/lms/djangoapps/badges/handlers.py +++ /dev/null @@ -1,20 +0,0 @@ -""" -Badges related signal handlers. -""" - - -from django.dispatch import receiver - -from common.djangoapps.student.models import EnrollStatusChange -from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE -from lms.djangoapps.badges.events.course_meta import award_enrollment_badge -from lms.djangoapps.badges.utils import badges_enabled - - -@receiver(ENROLL_STATUS_CHANGE) -def award_badge_on_enrollment(sender, event=None, user=None, **kwargs): # pylint: disable=unused-argument - """ - Awards enrollment badge to the given user on new enrollments. - """ - if badges_enabled and event == EnrollStatusChange.enroll: - award_enrollment_badge(user) diff --git a/lms/djangoapps/badges/models.py b/lms/djangoapps/badges/models.py index 9af3901749..005cf1569a 100644 --- a/lms/djangoapps/badges/models.py +++ b/lms/djangoapps/badges/models.py @@ -3,24 +3,8 @@ Database models for the badges app """ -from importlib import import_module - -from config_models.models import ConfigurationModel -from django.conf import settings -from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.exceptions import ValidationError -from django.db import models from django.utils.translation import gettext_lazy as _ -from jsonfield import JSONField -from lazy import lazy # lint-amnesty, pylint: disable=no-name-in-module -from model_utils.models import TimeStampedModel -from opaque_keys import InvalidKeyError -from opaque_keys.edx.django.models import CourseKeyField -from opaque_keys.edx.keys import CourseKey - -from lms.djangoapps.badges.utils import deserialize_count_specs -from openedx.core.djangolib.markup import HTML, Text -from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order def validate_badge_image(image): @@ -39,298 +23,3 @@ def validate_lowercase(string): """ if not string.islower(): raise ValidationError(_("This value must be all lowercase.")) - - -class CourseBadgesDisabledError(Exception): - """ - Exception raised when Course Badges aren't enabled, but an attempt to fetch one is made anyway. - """ - - -class BadgeClass(models.Model): - """ - Specifies a badge class to be registered with a backend. - - .. no_pii: - """ - slug = models.SlugField(max_length=255, validators=[validate_lowercase]) - badgr_server_slug = models.SlugField(max_length=255, default='', blank=True) - issuing_component = models.SlugField(max_length=50, default='', blank=True, validators=[validate_lowercase]) - display_name = models.CharField(max_length=255) - course_id = CourseKeyField(max_length=255, blank=True, default=None) - description = models.TextField() - criteria = models.TextField() - # Mode a badge was awarded for. Included for legacy/migration purposes. - mode = models.CharField(max_length=100, default='', blank=True) - image = models.ImageField(upload_to='badge_classes', validators=[validate_badge_image]) - - def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned - return HTML("").format( - slug=HTML(self.slug), issuing_component=HTML(self.issuing_component) - ) - - @classmethod - def get_badge_class( - cls, slug, issuing_component, display_name=None, description=None, criteria=None, image_file_handle=None, - mode='', course_id=None, create=True - ): - # TODO method should be renamed to getorcreate instead - """ - Looks up a badge class by its slug, issuing component, and course_id and returns it should it exist. - If it does not exist, and create is True, creates it according to the arguments. Otherwise, returns None. - - The expectation is that an XBlock or platform developer should not need to concern themselves with whether - or not a badge class has already been created, but should just feed all requirements to this function - and it will 'do the right thing'. It should be the exception, rather than the common case, that a badge class - would need to be looked up without also being created were it missing. - """ - slug = slug.lower() - issuing_component = issuing_component.lower() - if course_id and not modulestore().get_course(course_id).issue_badges: - raise CourseBadgesDisabledError("This course does not have badges enabled.") - if not course_id: - course_id = CourseKeyField.Empty - try: - return cls.objects.get(slug=slug, issuing_component=issuing_component, course_id=course_id) - except cls.DoesNotExist: - if not create: - return None - badge_class = cls( - slug=slug, - issuing_component=issuing_component, - display_name=display_name, - course_id=course_id, - mode=mode, - description=description, - criteria=criteria, - ) - badge_class.image.save(image_file_handle.name, image_file_handle) - badge_class.full_clean() - badge_class.save() - return badge_class - - @lazy - def backend(self): - """ - Loads the badging backend. - """ - module, klass = settings.BADGING_BACKEND.rsplit('.', 1) - module = import_module(module) - return getattr(module, klass)() - - def get_for_user(self, user): - """ - Get the assertion for this badge class for this user, if it has been awarded. - """ - return self.badgeassertion_set.filter(user=user) - - def award(self, user, evidence_url=None): - """ - Contacts the backend to have a badge assertion created for this badge class for this user. - """ - return self.backend.award(self, user, evidence_url=evidence_url) # lint-amnesty, pylint: disable=no-member - - def save(self, **kwargs): # lint-amnesty, pylint: disable=arguments-differ - """ - Slugs must always be lowercase. - """ - self.slug = self.slug and self.slug.lower() - self.issuing_component = self.issuing_component and self.issuing_component.lower() - super().save(**kwargs) - - class Meta: - app_label = "badges" - unique_together = (('slug', 'issuing_component', 'course_id'),) - verbose_name_plural = "Badge Classes" - - -class BadgeAssertion(TimeStampedModel): - """ - Tracks badges on our side of the badge baking transaction - - .. no_pii: - """ - user = models.ForeignKey(User, on_delete=models.CASCADE) - badge_class = models.ForeignKey(BadgeClass, on_delete=models.CASCADE) - data = JSONField() - backend = models.CharField(max_length=50) - image_url = models.URLField() - assertion_url = models.URLField() - - def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned - return HTML("<{username} Badge Assertion for {slug} for {issuing_component}").format( - username=HTML(self.user.username), - slug=HTML(self.badge_class.slug), - issuing_component=HTML(self.badge_class.issuing_component), - ) - - @classmethod - def assertions_for_user(cls, user, course_id=None): - """ - Get all assertions for a user, optionally constrained to a course. - """ - if course_id: - return cls.objects.filter(user=user, badge_class__course_id=course_id) - return cls.objects.filter(user=user) - - class Meta: - app_label = "badges" - - -# Abstract model doesn't index this, so we have to. -BadgeAssertion._meta.get_field('created').db_index = True - - -class CourseCompleteImageConfiguration(models.Model): - """ - Contains the icon configuration for badges for a specific course mode. - - .. no_pii: - """ - mode = models.CharField( - max_length=125, - help_text=_('The course mode for this badge image. For example, "verified" or "honor".'), - unique=True, - ) - icon = models.ImageField( - # Actual max is 256KB, but need overhead for badge baking. This should be more than enough. - help_text=_( - "Badge images must be square PNG files. The file size should be under 250KB." - ), - upload_to='course_complete_badges', - validators=[validate_badge_image] - ) - default = models.BooleanField( - help_text=_( - "Set this value to True if you want this image to be the default image for any course modes " - "that do not have a specified badge image. You can have only one default image." - ), - default=False, - ) - - def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned - return HTML("").format( - mode=HTML(self.mode), - default=HTML(" (default)") if self.default else HTML('') - ) - - def clean(self): - """ - Make sure there's not more than one default. - """ - if self.default and CourseCompleteImageConfiguration.objects.filter(default=True).exclude(id=self.id): - raise ValidationError(_("There can be only one default image.")) - - @classmethod - def image_for_mode(cls, mode): - """ - Get the image for a particular mode. - """ - try: - return cls.objects.get(mode=mode).icon - except cls.DoesNotExist: - # Fall back to default, if there is one. - return cls.objects.get(default=True).icon - - class Meta: - app_label = "badges" - - -class CourseEventBadgesConfiguration(ConfigurationModel): - """ - Determines the settings for meta course awards-- such as completing a certain - number of courses or enrolling in a certain number of them. - - .. no_pii: - """ - courses_completed = models.TextField( - blank=True, default='', - help_text=_( - "On each line, put the number of completed courses to award a badge for, a comma, and the slug of a " - "badge class you have created that has the issuing component 'openedx__course'. " - "For example: 3,enrolled_3_courses" - ) - ) - courses_enrolled = models.TextField( - blank=True, default='', - help_text=_( - "On each line, put the number of enrolled courses to award a badge for, a comma, and the slug of a " - "badge class you have created that has the issuing component 'openedx__course'. " - "For example: 3,enrolled_3_courses" - ) - ) - course_groups = models.TextField( - blank=True, default='', - help_text=_( - "Each line is a comma-separated list. The first item in each line is the slug of a badge class you " - "have created that has an issuing component of 'openedx__course'. The remaining items in each line are " - "the course keys the learner needs to complete to be awarded the badge. For example: " - "slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second" - ) - ) - - def __str__(self): # lint-amnesty, pylint: disable=invalid-str-returned - return HTML("").format( - Text("Enabled") if self.enabled else Text("Disabled") - ) - - @property - def completed_settings(self): - """ - Parses the settings from the courses_completed field. - """ - return deserialize_count_specs(self.courses_completed) - - @property - def enrolled_settings(self): - """ - Parses the settings from the courses_completed field. - """ - return deserialize_count_specs(self.courses_enrolled) - - @property - def course_group_settings(self): - """ - Parses the course group settings. In example, the format is: - - slug_for_compsci_courses_group_badge,course-v1:CompSci+Course+First,course-v1:CompsSci+Course+Second - """ - specs = self.course_groups.strip() - if not specs: - return {} - specs = [line.split(',', 1) for line in specs.splitlines()] - return { - slug.strip().lower(): [CourseKey.from_string(key.strip()) for key in keys.strip().split(',')] - for slug, keys in specs - } - - def clean_fields(self, exclude=tuple()): - """ - Verify the settings are parseable. - """ - errors = {} - error_message = _("Please check the syntax of your entry.") - if 'courses_completed' not in exclude: - try: - self.completed_settings - except (ValueError, InvalidKeyError): - errors['courses_completed'] = [str(error_message)] - if 'courses_enrolled' not in exclude: - try: - self.enrolled_settings - except (ValueError, InvalidKeyError): - errors['courses_enrolled'] = [str(error_message)] - if 'course_groups' not in exclude: - store = modulestore() - try: - for key_list in self.course_group_settings.values(): - for course_key in key_list: - if not store.get_course(course_key): - ValueError(f"The course {course_key} does not exist.") - except (ValueError, InvalidKeyError): - errors['course_groups'] = [str(error_message)] - if errors: - raise ValidationError(errors) - - class Meta: - app_label = "badges" diff --git a/lms/djangoapps/badges/service.py b/lms/djangoapps/badges/service.py deleted file mode 100644 index 2870ac7df4..0000000000 --- a/lms/djangoapps/badges/service.py +++ /dev/null @@ -1,30 +0,0 @@ -""" -Badging service for XBlocks -""" - - -from lms.djangoapps.badges.models import BadgeClass - - -class BadgingService: - """ - A class that provides functions for managing badges which XBlocks can use. - - If course_enabled is True, course-level badges are permitted for this course. - - If it is False, any badges that are awarded should be non-course specific. - """ - course_badges_enabled = False - - def __init__(self, course_id=None, modulestore=None): - """ - Sets the 'course_badges_enabled' parameter. - """ - if not (course_id and modulestore): - return - - course = modulestore.get_course(course_id) - if course: - self.course_badges_enabled = course.issue_badges - - get_badge_class = BadgeClass.get_badge_class diff --git a/lms/djangoapps/badges/tests/__init__.py b/lms/djangoapps/badges/tests/__init__.py deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/djangoapps/badges/tests/factories.py b/lms/djangoapps/badges/tests/factories.py deleted file mode 100644 index f84053e329..0000000000 --- a/lms/djangoapps/badges/tests/factories.py +++ /dev/null @@ -1,88 +0,0 @@ -""" -Factories for Badge tests -""" - - -from random import random - -import factory -from django.core.files.base import ContentFile -from factory.django import ImageField - -from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.badges.models import ( # lint-amnesty, pylint: disable=line-too-long - BadgeAssertion, - BadgeClass, - CourseCompleteImageConfiguration, - CourseEventBadgesConfiguration -) - - -def generate_dummy_image(_unused): - """ - Used for image fields to create a sane default. - """ - return ContentFile( - ImageField()._make_data( # pylint: disable=protected-access - {'color': 'blue', 'width': 50, 'height': 50, 'format': 'PNG'} - ), 'test.png' - ) - - -class CourseCompleteImageConfigurationFactory(factory.django.DjangoModelFactory): - """ - Factory for BadgeImageConfigurations - """ - class Meta: - model = CourseCompleteImageConfiguration - - mode = 'honor' - icon = factory.LazyAttribute(generate_dummy_image) - - -class BadgeClassFactory(factory.django.DjangoModelFactory): - """ - Factory for BadgeClass - """ - class Meta: - model = BadgeClass - - slug = 'test_slug' - badgr_server_slug = 'test_badgr_server_slug' - issuing_component = 'test_component' - display_name = 'Test Badge' - description = "Yay! It's a test badge." - criteria = 'https://example.com/syllabus' - mode = 'honor' - image = factory.LazyAttribute(generate_dummy_image) - - -class RandomBadgeClassFactory(BadgeClassFactory): - """ - Same as BadgeClassFactory, but randomize the slug. - """ - slug = factory.lazy_attribute(lambda _: 'test_slug_' + str(random()).replace('.', '_')) - - -class BadgeAssertionFactory(factory.django.DjangoModelFactory): - """ - Factory for BadgeAssertions - """ - class Meta: - model = BadgeAssertion - - user = factory.SubFactory(UserFactory) - badge_class = factory.SubFactory(RandomBadgeClassFactory) - data = {} - assertion_url = 'http://example.com/example.json' - image_url = 'http://example.com/image.png' - - -class CourseEventBadgesConfigurationFactory(factory.django.DjangoModelFactory): - """ - Factory for CourseEventsBadgesConfiguration - """ - class Meta: - model = CourseEventBadgesConfiguration - - enabled = True diff --git a/lms/djangoapps/badges/tests/test_models.py b/lms/djangoapps/badges/tests/test_models.py deleted file mode 100644 index e57c2e1b23..0000000000 --- a/lms/djangoapps/badges/tests/test_models.py +++ /dev/null @@ -1,311 +0,0 @@ -""" -Tests for the Badges app models. -""" - - -import contextlib -from unittest.mock import Mock, patch - -import pytest -from django.core.exceptions import ValidationError -from django.core.files.images import ImageFile -from django.core.files.storage import default_storage -from django.db.utils import IntegrityError -from django.test import TestCase -from django.test.utils import override_settings -from path import Path - -from common.djangoapps.student.tests.factories import UserFactory -from lms.djangoapps.badges.models import ( - BadgeAssertion, - BadgeClass, - CourseBadgesDisabledError, - CourseCompleteImageConfiguration, - validate_badge_image -) -from lms.djangoapps.badges.tests.factories import BadgeAssertionFactory, BadgeClassFactory, RandomBadgeClassFactory -from lms.djangoapps.certificates.tests.test_models import TEST_DATA_ROOT, TEST_DATA_DIR -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 - - -@contextlib.contextmanager -def get_image(name): - """ - Get one of the test images from the test data directory. - """ - with open(f'{TEST_DATA_DIR}/badges/{name}.png', mode='rb') as fimage: - yield ImageFile(fimage) - - -@override_settings(MEDIA_ROOT=TEST_DATA_ROOT) -class BadgeImageConfigurationTest(TestCase): - """ - Test the validation features of BadgeImageConfiguration. - """ - - def tearDown(self): # lint-amnesty, pylint: disable=super-method-not-called - tmp_path = Path(TEST_DATA_ROOT / 'course_complete_badges') - Path.rmtree_p(tmp_path) - - def test_no_double_default(self): - """ - Verify that creating two configurations as default is not permitted. - """ - with get_image('good') as image_handle: - CourseCompleteImageConfiguration(mode='test', icon=ImageFile(image_handle), default=True).save() - with get_image('good') as image_handle: - pytest.raises(ValidationError, CourseCompleteImageConfiguration(mode='test2', icon=ImageFile(image_handle), - default=True).full_clean) - - def test_runs_validator(self): - """ - Verify that the image validator is triggered when cleaning the model. - """ - with get_image('unbalanced') as image_handle: - pytest.raises( - ValidationError, - CourseCompleteImageConfiguration(mode='test2', icon=ImageFile(image_handle)).full_clean - ) - - -class DummyBackend: - """ - Dummy badge backend, used for testing. - """ - award = Mock() - - -@override_settings(MEDIA_ROOT=TEST_DATA_ROOT) -class BadgeClassTest(ModuleStoreTestCase): - """ - Test BadgeClass functionality - """ - - def setUp(self): - super().setUp() - self.addCleanup(self.cleanup_uploads) - - def cleanup_uploads(self): - """ - Remove all files uploaded as badges. - """ - upload_to = BadgeClass._meta.get_field('image').upload_to - if default_storage.exists(upload_to): - (_, files) = default_storage.listdir(upload_to) - for uploaded_file in files: - default_storage.delete(upload_to + '/' + uploaded_file) - - # Need full path to make sure class names line up. - @override_settings(BADGING_BACKEND='lms.djangoapps.badges.tests.test_models.DummyBackend') - def test_backend(self): - """ - Verify the BadgeClass fetches the backend properly. - """ - assert isinstance(BadgeClass().backend, DummyBackend) - - def test_get_badge_class_preexisting(self): - """ - Verify fetching a badge first grabs existing badges. - """ - premade_badge_class = BadgeClassFactory.create() - # Ignore additional parameters. This class already exists. - with get_image('good') as image_handle: - badge_class = BadgeClass.get_badge_class( - slug='test_slug', issuing_component='test_component', description='Attempted override', - criteria='test', display_name='Testola', image_file_handle=image_handle - ) - # These defaults are set on the factory. - assert badge_class.criteria == 'https://example.com/syllabus' - assert badge_class.display_name == 'Test Badge' - assert badge_class.description == "Yay! It's a test badge." - # File name won't always be the same. - assert badge_class.image.path == premade_badge_class.image.path - - def test_unique_for_course(self): - """ - Verify that the course_id is used in fetching existing badges or creating new ones. - """ - course_key = CourseFactory.create().location.course_key - premade_badge_class = BadgeClassFactory.create(course_id=course_key) - with get_image('good') as image_handle: - badge_class = BadgeClass.get_badge_class( - slug='test_slug', issuing_component='test_component', description='Attempted override', - criteria='test', display_name='Testola', image_file_handle=image_handle - ) - with get_image('good') as image_handle: - course_badge_class = BadgeClass.get_badge_class( - slug='test_slug', issuing_component='test_component', description='Attempted override', - criteria='test', display_name='Testola', image_file_handle=image_handle, - course_id=course_key, - ) - assert badge_class.id != course_badge_class.id - assert course_badge_class.id == premade_badge_class.id - - def test_get_badge_class_course_disabled(self): - """ - Verify attempting to fetch a badge class for a course which does not issue badges raises an - exception. - """ - course_key = CourseFactory.create(metadata={'issue_badges': False}).location.course_key - with pytest.raises(CourseBadgesDisabledError): - with get_image('good') as image_handle: - BadgeClass.get_badge_class( - slug='test_slug', issuing_component='test_component', description='Attempted override', - criteria='test', display_name='Testola', image_file_handle=image_handle, - course_id=course_key, - ) - - def test_get_badge_class_create(self): - """ - Verify fetching a badge creates it if it doesn't yet exist. - """ - with get_image('good') as image_handle: - badge_class = BadgeClass.get_badge_class( - slug='new_slug', issuing_component='new_component', description='This is a test', - criteria='https://example.com/test_criteria', display_name='Super Badge', - image_file_handle=image_handle - ) - # This should have been saved before being passed back. - assert badge_class.id - assert badge_class.slug == 'new_slug' - assert badge_class.issuing_component == 'new_component' - assert badge_class.description == 'This is a test' - assert badge_class.criteria == 'https://example.com/test_criteria' - assert badge_class.display_name == 'Super Badge' - assert 'good' in badge_class.image.name.rsplit('/', 1)[(- 1)] - - def test_get_badge_class_nocreate(self): - """ - Test returns None if the badge class does not exist. - """ - badge_class = BadgeClass.get_badge_class( - slug='new_slug', issuing_component='new_component', create=False - ) - assert badge_class is None - # Run this twice to verify there wasn't a background creation of the badge. - badge_class = BadgeClass.get_badge_class( - slug='new_slug', issuing_component='new_component', description=None, - criteria=None, display_name=None, - image_file_handle=None, create=False - ) - assert badge_class is None - - def test_get_badge_class_image_validate(self): - """ - Verify handing a broken image to get_badge_class raises a validation error upon creation. - """ - # TODO Test should be updated, this doc doesn't makes sense, the object eventually gets created - with get_image('unbalanced') as image_handle: - self.assertRaises( - ValidationError, - BadgeClass.get_badge_class, - slug='new_slug', issuing_component='new_component', description='This is a test', - criteria='https://example.com/test_criteria', display_name='Super Badge', - image_file_handle=image_handle - ) - - def test_get_badge_class_data_validate(self): - """ - Verify handing incomplete data for required fields when making a badge class raises an Integrity error. - """ - with pytest.raises(IntegrityError), self.allow_transaction_exception(): - with get_image('good') as image_handle: - BadgeClass.get_badge_class( - slug='new_slug', issuing_component='new_component', image_file_handle=image_handle - ) - - def test_get_for_user(self): - """ - Make sure we can get an assertion for a user if there is one. - """ - user = UserFactory.create() - badge_class = BadgeClassFactory.create() - assert not badge_class.get_for_user(user) - assertion = BadgeAssertionFactory.create(badge_class=badge_class, user=user) - assert list(badge_class.get_for_user(user)) == [assertion] - - @override_settings( - BADGING_BACKEND='lms.djangoapps.badges.backends.badgr.BadgrBackend', - BADGR_USERNAME='example@example.com', - BADGR_PASSWORD='password', - BADGR_TOKENS_CACHE_KEY='badgr-test-cache-key') - @patch('lms.djangoapps.badges.backends.badgr.BadgrBackend.award') - def test_award(self, mock_award): - """ - Verify that the award command calls the award function on the backend with the right parameters. - """ - user = UserFactory.create() - badge_class = BadgeClassFactory.create() - badge_class.award(user, evidence_url='http://example.com/evidence') - assert mock_award.called - mock_award.assert_called_with(badge_class, user, evidence_url='http://example.com/evidence') - - def test_runs_validator(self): - """ - Verify that the image validator is triggered when cleaning the model. - """ - with get_image('unbalanced') as image_handle: - pytest.raises( - ValidationError, - BadgeClass( - slug='test', issuing_component='test2', criteria='test3', - description='test4', image=ImageFile(image_handle)).full_clean - ) - - -class BadgeAssertionTest(ModuleStoreTestCase): - """ - Tests for the BadgeAssertion model - """ - def test_assertions_for_user(self): - """ - Verify that grabbing all assertions for a user behaves as expected. - - This function uses object IDs because for some reason Jenkins trips up - on its assertCountEqual check here despite the items being equal. - """ - user = UserFactory() - assertions = [BadgeAssertionFactory.create(user=user).id for _i in range(3)] - course = CourseFactory.create() - course_key = course.location.course_key - course_badges = [RandomBadgeClassFactory(course_id=course_key) for _i in range(3)] - course_assertions = [ - BadgeAssertionFactory.create(user=user, badge_class=badge_class).id for badge_class in course_badges - ] - assertions.extend(course_assertions) - assertions.sort() - assertions_for_user = [badge.id for badge in BadgeAssertion.assertions_for_user(user)] - assertions_for_user.sort() - assert assertions_for_user == assertions - course_scoped_assertions = [ - badge.id for badge in BadgeAssertion.assertions_for_user(user, course_id=course_key) - ] - course_scoped_assertions.sort() - assert course_scoped_assertions == course_assertions - - -class ValidBadgeImageTest(TestCase): - """ - Tests the badge image field validator. - """ - def test_good_image(self): - """ - Verify that saving a valid badge image is no problem. - """ - with get_image('good') as image_handle: - validate_badge_image(ImageFile(image_handle)) - - def test_unbalanced_image(self): - """ - Verify that setting an image with an uneven width and height raises an error. - """ - with get_image('unbalanced') as image_handle: - self.assertRaises(ValidationError, validate_badge_image, ImageFile(image_handle)) - - def test_large_image(self): - """ - Verify that setting an image that is too big raises an error. - """ - with get_image('large') as image_handle: - self.assertRaises(ValidationError, validate_badge_image, ImageFile(image_handle)) diff --git a/lms/djangoapps/badges/utils.py b/lms/djangoapps/badges/utils.py deleted file mode 100644 index 150834e04c..0000000000 --- a/lms/djangoapps/badges/utils.py +++ /dev/null @@ -1,48 +0,0 @@ -""" -Utility functions used by the badging app. -""" - - -from django.conf import settings - - -def site_prefix(): - """ - Get the prefix for the site URL-- protocol and server name. - """ - scheme = "https" if settings.HTTPS == "on" else "http" - return f'{scheme}://{settings.SITE_NAME}' - - -def requires_badges_enabled(function): - """ - Decorator that bails a function out early if badges aren't enabled. - """ - def wrapped(*args, **kwargs): - """ - Wrapped function which bails out early if bagdes aren't enabled. - """ - if not badges_enabled(): - return - return function(*args, **kwargs) - return wrapped - - -def badges_enabled(): - """ - returns a boolean indicating whether or not openbadges are enabled. - """ - return settings.FEATURES.get('ENABLE_OPENBADGES', False) - - -def deserialize_count_specs(text): - """ - Takes a string in the format of: - int,course_key - int,course_key - - And returns a dictionary with the keys as the numbers and the values as the course keys. - """ - specs = text.splitlines() - specs = [line.split(',') for line in specs if line.strip()] - return {int(num): slug.strip().lower() for num, slug in specs} diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py index a618545bcd..fe0f70fbdc 100644 --- a/lms/djangoapps/bulk_email/tests/test_course_optout.py +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -40,7 +40,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): # load initial content (since we don't run migrations as part of tests): call_command("loaddata", "course_email_template.json") - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self.send_mail_url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) self.success_content = { @@ -70,7 +70,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): self.client.logout() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.navigate_to_email_view() test_email = { @@ -92,7 +92,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): """ self.client.logout() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) unsubscribe_link = get_unsubscribed_link(self.student.username, str(self.course.id)) response = self.client.post(unsubscribe_link, {'unsubscribe': True}) @@ -122,7 +122,7 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): assert CourseEnrollment.is_enrolled(self.student, self.course.id) - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.navigate_to_email_view() test_email = { @@ -155,7 +155,7 @@ class TestACEOptoutCourseEmails(ModuleStoreTestCase): self.student = UserFactory.create() CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self._set_email_optout(False) self.policy = CourseEmailOptout() diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index b67864381b..ba95bd26dd 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -87,7 +87,7 @@ class EmailSendFromDashboardTestCase(SharedModuleStoreTestCase): """ Log in self.client as user. """ - self.client.login(username=user.username, password="test") + self.client.login(username=user.username, password=self.TEST_PASSWORD) def goto_instructor_dash_email_view(self): """ diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index 3f3ffe9b69..06e9a1dce4 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -53,7 +53,7 @@ class TestEmailErrors(ModuleStoreTestCase): course_title = "ẗëṡẗ title イ乇丂イ ᄊ乇丂丂ムg乇 キo尺 ムレレ тэѕт мэѕѕаБэ" self.course = CourseFactory.create(display_name=course_title) self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) # load initial content (since we don't run migrations as part of tests): call_command("loaddata", "course_email_template.json") diff --git a/lms/djangoapps/bulk_email/tests/test_signals.py b/lms/djangoapps/bulk_email/tests/test_signals.py index ea369fa805..1a3715284b 100644 --- a/lms/djangoapps/bulk_email/tests/test_signals.py +++ b/lms/djangoapps/bulk_email/tests/test_signals.py @@ -33,7 +33,7 @@ class TestOptoutCourseEmailsBySignal(ModuleStoreTestCase): # load initial content (since we don't run migrations as part of tests): call_command("loaddata", "course_email_template.json") - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self.send_mail_url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) self.success_content = { @@ -78,7 +78,7 @@ class TestOptoutCourseEmailsBySignal(ModuleStoreTestCase): force_optout_all(sender=self.__class__, user=self.student) # Try to send a bulk course email - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.send_test_email() # Assert that self.student.email not in mail.to, outbox should only contain "myself" target diff --git a/lms/djangoapps/ccx/api/v0/tests/test_views.py b/lms/djangoapps/ccx/api/v0/tests/test_views.py index d3daf72244..7279b94263 100644 --- a/lms/djangoapps/ccx/api/v0/tests/test_views.py +++ b/lms/djangoapps/ccx/api/v0/tests/test_views.py @@ -33,7 +33,7 @@ from lms.djangoapps.instructor.access import allow_access, list_with_level from lms.djangoapps.instructor.enrollment import enroll_email, get_email_params from openedx.core.lib.courses import get_course_by_id -USER_PASSWORD = 'test' +USER_PASSWORD = 'Password1234' class CcxRestApiTest(CcxTestCase, APITestCase): @@ -760,7 +760,7 @@ class CcxDetailTest(CcxRestApiTest): """ # create a staff user staff_user = UserFactory.create( - username='test_staff_user', email='test_staff_user@openedx.org', password='test', + username='test_staff_user', email='test_staff_user@openedx.org', password=USER_PASSWORD, ) # add staff role to the staff user CourseStaffRole(self.master_course_key).add_users(staff_user) @@ -779,7 +779,7 @@ class CcxDetailTest(CcxRestApiTest): """ # create an instructor user instructor_user = UserFactory.create( - username='test_instructor_user', email='test_instructor_user@openedx.org', password='test', + username='test_instructor_user', email='test_instructor_user@openedx.org', password=USER_PASSWORD, ) # add instructor role to the instructor user CourseInstructorRole(self.master_course_key).add_users(instructor_user) @@ -798,7 +798,7 @@ class CcxDetailTest(CcxRestApiTest): """ # create an coach user coach_user = UserFactory.create( - username='test_coach_user', email='test_coach_user@openedx.org', password='test', + username='test_coach_user', email='test_coach_user@openedx.org', password=USER_PASSWORD, ) # add coach role to the coach user CourseCcxCoachRole(self.master_course_key).add_users(coach_user) diff --git a/lms/djangoapps/ccx/tests/test_views.py b/lms/djangoapps/ccx/tests/test_views.py index b18ed5c2aa..e993d5dc0b 100644 --- a/lms/djangoapps/ccx/tests/test_views.py +++ b/lms/djangoapps/ccx/tests/test_views.py @@ -129,13 +129,14 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): ccx = self.make_ccx() ccx_key = CCXLocator.from_course_locator(self.course.id, ccx.id) self.url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_key}) + self.TEST_PASSWORD = 'Password1234' def test_staff_access_coach_dashboard(self): """ User is staff, should access coach dashboard. """ staff = self.make_staff() - self.client.login(username=staff.username, password="test") + self.client.login(username=staff.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) assert response.status_code == 200 @@ -145,7 +146,7 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): User is instructor, should access coach dashboard. """ instructor = self.make_instructor() - self.client.login(username=instructor.username, password="test") + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) # Now access URL response = self.client.get(self.url) @@ -155,8 +156,8 @@ class TestAdminAccessCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): """ Assert user with no access must not see dashboard. """ - user = UserFactory.create(password="test") - self.client.login(username=user.username, password="test") + user = UserFactory.create(password=self.TEST_PASSWORD) + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) assert response.status_code == 403 @@ -211,12 +212,12 @@ class TestCCXProgressChanges(CcxTestCase, LoginEnrollmentTestCase): """ assert signal and schedule update. """ - student = UserFactory.create(is_staff=False, password="test") + student = UserFactory.create(is_staff=False, password=self.TEST_PASSWORD) CourseEnrollment.enroll(student, ccx_course_key) assert CourseEnrollment.objects.filter(course_id=ccx_course_key, user=student).exists() # login as student - self.client.login(username=student.username, password="test") + self.client.login(username=student.username, password=self.TEST_PASSWORD) progress_page_response = self.client.get( reverse('progress', kwargs={'course_id': ccx_course_key}) ) @@ -236,7 +237,7 @@ class TestCCXProgressChanges(CcxTestCase, LoginEnrollmentTestCase): self.make_coach() ccx = self.make_ccx() ccx_course_key = CCXLocator.from_course_locator(self.course.id, str(ccx.id)) - self.client.login(username=self.coach.username, password="test") + self.client.login(username=self.coach.username, password=self.TEST_PASSWORD) url = reverse('ccx_coach_dashboard', kwargs={'course_id': ccx_course_key}) response = self.client.get(url) @@ -295,7 +296,7 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): """ super().setUp() # Login with the instructor account - self.client.login(username=self.coach.username, password="test") + self.client.login(username=self.coach.username, password=self.TEST_PASSWORD) # adding staff to master course. staff = UserFactory() @@ -315,8 +316,8 @@ class TestCoachDashboard(CcxTestCase, LoginEnrollmentTestCase): ccx = self.make_ccx() # create session of non-coach user - user = UserFactory.create(password="test") - self.client.login(username=user.username, password="test") + user = UserFactory.create(password=self.TEST_PASSWORD) + self.client.login(username=user.username, password=self.TEST_PASSWORD) url = reverse( 'ccx_coach_dashboard', kwargs={'course_id': CCXLocator.from_course_locator(self.course.id, ccx.id)}) @@ -841,7 +842,7 @@ class TestCoachDashboardSchedule(CcxTestCase, LoginEnrollmentTestCase, ModuleSto self.mstore = modulestore() # Login with the instructor account - self.client.login(username=self.coach.username, password="test") + self.client.login(username=self.coach.username, password=self.TEST_PASSWORD) # adding staff to master course. staff = UserFactory() @@ -981,7 +982,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro # Create instructor account self.coach = coach = AdminFactory.create() - self.client.login(username=coach.username, password="test") + self.client.login(username=coach.username, password=self.TEST_PASSWORD) # Create CCX role = CourseCcxCoachRole(self._course.id) @@ -1009,7 +1010,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro self.course = get_course_by_id(self.ccx_key, depth=None) CourseOverview.load_from_module_store(self.course.id) setup_students_and_grades(self) - self.client.login(username=coach.username, password="test") + self.client.login(username=coach.username, password=self.TEST_PASSWORD) self.addCleanup(RequestCache.clear_all_namespaces) from xmodule.modulestore.django import SignalHandler @@ -1068,7 +1069,7 @@ class TestCCXGrades(FieldOverrideTestMixin, SharedModuleStoreTestCase, LoginEnro get_course.return_value = self.course self.addCleanup(patch_context.stop) - self.client.login(username=self.student.username, password="test") # lint-amnesty, pylint: disable=no-member + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) # lint-amnesty, pylint: disable=no-member url = reverse( 'progress', kwargs={'course_id': self.ccx_key} @@ -1194,7 +1195,7 @@ class TestStudentViewsWithCCX(ModuleStoreTestCase): # Create a Split Mongo course and enroll a student user in it. self.student_password = "foobar" - self.student = UserFactory.create(username="test", password=self.student_password, is_staff=False) + self.student = UserFactory.create(username=self.TEST_PASSWORD, password=self.student_password, is_staff=False) self.split_course = SampleCourseFactory.create(default_store=ModuleStoreEnum.Type.split) CourseEnrollment.enroll(self.student, self.split_course.id) diff --git a/lms/djangoapps/ccx/tests/utils.py b/lms/djangoapps/ccx/tests/utils.py index 4d36353b9a..4f8c34e14c 100644 --- a/lms/djangoapps/ccx/tests/utils.py +++ b/lms/djangoapps/ccx/tests/utils.py @@ -68,7 +68,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase): """ super().setUp() # Create instructor account - self.coach = UserFactory.create(password="test") + self.coach = UserFactory.create() # create an instance of modulestore self.mstore = modulestore() @@ -76,7 +76,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase): """ create staff user. """ - staff = UserFactory.create(password="test") + staff = UserFactory.create() role = CourseStaffRole(self.course.id) role.add_users(staff) @@ -86,7 +86,7 @@ class CcxTestCase(EmailTemplateTagMixin, SharedModuleStoreTestCase): """ create instructor user. """ - instructor = UserFactory.create(password="test") + instructor = UserFactory.create() role = CourseInstructorRole(self.course.id) role.add_users(instructor) diff --git a/lms/djangoapps/certificates/api.py b/lms/djangoapps/certificates/api.py index c76e34a95b..c93bc85a42 100644 --- a/lms/djangoapps/certificates/api.py +++ b/lms/djangoapps/certificates/api.py @@ -49,6 +49,8 @@ from lms.djangoapps.certificates.utils import ( certificate_status_for_student as _certificate_status_for_student, ) from lms.djangoapps.instructor import access +from lms.djangoapps.utils import _get_key +from opaque_keys.edx.keys import CourseKey from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order @@ -920,3 +922,34 @@ def _has_passed_or_is_allowlisted(course, student, course_grade): has_passed = course_grade and course_grade.passed return has_passed or is_allowlisted + + +def invalidate_certificate(user_id, course_key_or_id, source): + """ + Invalidate the user certificate in a given course if it exists and the user is not on the allowlist for this + course run. + + This function is called in services.py and handlers.py within the certificates folder. As of now, + The call in services.py occurs when an exam attempt is rejected in the legacy exams backend, edx-proctoring. + The call in handlers.py is occurs when an exam attempt is rejected in the newer exams backend, edx-exams. + """ + course_key = _get_key(course_key_or_id, CourseKey) + if _is_on_certificate_allowlist(user_id, course_key): + log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.') + return False + + try: + generated_certificate = GeneratedCertificate.objects.get( + user=user_id, + course_id=course_key + ) + generated_certificate.invalidate(source=source) + except ObjectDoesNotExist: + log.warning( + 'Invalidation failed because a certificate for user %d in course %s does not exist.', + user_id, + course_key + ) + return False + + return True diff --git a/lms/djangoapps/certificates/config.py b/lms/djangoapps/certificates/config.py index 6df62ae563..92b20b17bb 100644 --- a/lms/djangoapps/certificates/config.py +++ b/lms/djangoapps/certificates/config.py @@ -23,6 +23,7 @@ AUTO_CERTIFICATE_GENERATION = WaffleSwitch(f"{WAFFLE_NAMESPACE}.auto_certificate # .. toggle_description: When True, the system will publish `CERTIFICATE_CREATED` signals to the event bus. The # `CERTIFICATE_CREATED` signal is emit when a certificate has been awarded to a learner and the creation process has # completed. +# .. toggle_warning: Will be deprecated in favor of SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS # .. toggle_use_cases: temporary # .. toggle_creation_date: 2023-04-11 # .. toggle_target_removal_date: 2023-07-31 @@ -36,6 +37,7 @@ SEND_CERTIFICATE_CREATED_SIGNAL = SettingToggle('SEND_CERTIFICATE_CREATED_SIGNAL # .. toggle_description: When True, the system will publish `CERTIFICATE_REVOKED` signals to the event bus. The # `CERTIFICATE_REVOKED` signal is emit when a certificate has been revoked from a learner and the revocation process # has completed. +# .. toggle_warning: Will be deprecated in favor of SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS # .. toggle_use_cases: temporary # .. toggle_creation_date: 2023-09-15 # .. toggle_target_removal_date: 2024-01-01 diff --git a/lms/djangoapps/certificates/handlers.py b/lms/djangoapps/certificates/handlers.py new file mode 100644 index 0000000000..8d45468497 --- /dev/null +++ b/lms/djangoapps/certificates/handlers.py @@ -0,0 +1,28 @@ +""" +Handlers for credits +""" +import logging + +from django.contrib.auth import get_user_model +from django.dispatch import receiver +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED + +from lms.djangoapps.certificates.api import invalidate_certificate + +User = get_user_model() + +log = logging.getLogger(__name__) + + +@receiver(EXAM_ATTEMPT_REJECTED) +def handle_exam_attempt_rejected_event(sender, signal, **kwargs): + """ + Consume `EXAM_ATTEMPT_REJECTED` events from the event bus. + Pass the received data to invalidate_certificate in the services.py file in this folder. + """ + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + course_key = event_data.course_key + + # Note that the course_key is the same as the course_key_or_id, and is being passed in as the course_key param + invalidate_certificate(user_data.id, course_key, source='exam_event') diff --git a/lms/djangoapps/certificates/models.py b/lms/djangoapps/certificates/models.py index 7b5677a690..fa16112f3a 100644 --- a/lms/djangoapps/certificates/models.py +++ b/lms/djangoapps/certificates/models.py @@ -26,8 +26,6 @@ from simple_history.models import HistoricalRecords from common.djangoapps.student import models_api as student_api from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.util.milestones_helpers import fulfill_course_milestone, is_prerequisite_courses_enabled -from lms.djangoapps.badges.events.course_complete import course_badge_check -from lms.djangoapps.badges.events.course_meta import completion_check, course_group_check from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.instructor_task.models import InstructorTask from openedx.core.djangoapps.signals.signals import COURSE_CERT_AWARDED, COURSE_CERT_CHANGED, COURSE_CERT_REVOKED @@ -1245,31 +1243,6 @@ class CertificateTemplateAsset(TimeStampedModel): app_label = "certificates" -@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) -# pylint: disable=unused-argument -def create_course_badge(sender, user, course_key, status, **kwargs): - """ - Standard signal hook to create course badges when a certificate has been generated. - """ - course_badge_check(user, course_key) - - -@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) -def create_completion_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument - """ - Standard signal hook to create 'x courses completed' badges when a certificate has been generated. - """ - completion_check(user) - - -@receiver(COURSE_CERT_AWARDED, sender=GeneratedCertificate) -def create_course_group_badge(sender, user, course_key, status, **kwargs): # pylint: disable=unused-argument - """ - Standard signal hook to create badges when a user has completed a prespecified set of courses. - """ - course_group_check(user, course_key) - - class CertificateGenerationCommandConfiguration(ConfigurationModel): """ Manages configuration for a run of the cert_generation management command. diff --git a/lms/djangoapps/certificates/services.py b/lms/djangoapps/certificates/services.py index 29ee5a05d3..508bb3ad6d 100644 --- a/lms/djangoapps/certificates/services.py +++ b/lms/djangoapps/certificates/services.py @@ -2,17 +2,7 @@ Certificate service """ - -import logging - -from django.core.exceptions import ObjectDoesNotExist -from opaque_keys.edx.keys import CourseKey - -from lms.djangoapps.certificates.generation_handler import is_on_certificate_allowlist -from lms.djangoapps.certificates.models import GeneratedCertificate -from lms.djangoapps.utils import _get_key - -log = logging.getLogger(__name__) +from lms.djangoapps.certificates.api import invalidate_certificate class CertificateService: @@ -21,27 +11,6 @@ class CertificateService: """ def invalidate_certificate(self, user_id, course_key_or_id): - """ - Invalidate the user certificate in a given course if it exists and the user is not on the allowlist for this - course run. - """ - course_key = _get_key(course_key_or_id, CourseKey) - if is_on_certificate_allowlist(user_id, course_key): - log.info(f'User {user_id} is on the allowlist for {course_key}. The certificate will not be invalidated.') - return False - - try: - generated_certificate = GeneratedCertificate.objects.get( - user=user_id, - course_id=course_key - ) - generated_certificate.invalidate(source='certificate_service') - except ObjectDoesNotExist: - log.warning( - 'Invalidation failed because a certificate for user %d in course %s does not exist.', - user_id, - course_key - ) - return False - - return True + # The original code for this function was moved to this helper function to be call-able + # By both the legacy and current exams backends (edx-proctoring and edx-exams). + return invalidate_certificate(user_id, course_key_or_id, source='certificate_service') diff --git a/lms/djangoapps/certificates/signals.py b/lms/djangoapps/certificates/signals.py index 4d43f0ed49..b6858c1445 100644 --- a/lms/djangoapps/certificates/signals.py +++ b/lms/djangoapps/certificates/signals.py @@ -3,10 +3,12 @@ Signal handler for enabling/disabling self-generated certificates based on the c """ import logging +from django.conf import settings from django.db.models.signals import post_save from django.dispatch import receiver from openedx_events.event_bus import get_producer +from edx_django_utils.monitoring import set_custom_attribute from common.djangoapps.course_modes import api as modes_api from common.djangoapps.student.models import CourseEnrollment @@ -27,6 +29,7 @@ from lms.djangoapps.certificates.models import ( from lms.djangoapps.certificates.api import auto_certificate_generation_enabled from lms.djangoapps.verify_student.services import IDVerificationService from openedx.core.djangoapps.content.course_overviews.signals import COURSE_PACING_CHANGED +from openedx.core.lib.events import determine_producer_config_for_signal_and_topic from openedx.core.djangoapps.signals.signals import ( COURSE_GRADE_NOW_FAILED, COURSE_GRADE_NOW_PASSED, @@ -161,12 +164,43 @@ def _listen_for_enrollment_mode_change(sender, user, course_key, mode, **kwargs) return False +def _determine_producer_config_for_signal_and_topic(signal, topic): + """ + Utility method to determine the setting for the given signal and topic in EVENT_BUS_PRODUCER_CONFIG + + Records to New Relic for later analysis. + + Parameters + signal (OpenEdxPublicSignal): The signal being sent to the event bus + topic (string): The topic to which the signal is being sent (without environment prefix) + + Returns + True if the signal is enabled for that topic in EVENT_BUS_PRODUCER_CONFIG + False if the signal is explicitly disabled for that topic in EVENT_BUS_PRODUCER_CONFIG + None if the signal/topic pair is not present in EVENT_BUS_PRODUCER_CONFIG + """ + event_type_producer_configs = getattr(settings, "EVENT_BUS_PRODUCER_CONFIG", + {}).get(signal.event_type, {}) + topic_config = event_type_producer_configs.get(topic, {}) + topic_setting = topic_config.get('enabled', None) + set_custom_attribute(f'producer_config_setting_{topic}_{signal.event_type}', + topic_setting if topic_setting is not None else 'Unset') + return topic_setting + + @receiver(CERTIFICATE_CREATED) def listen_for_certificate_created_event(sender, signal, **kwargs): # pylint: disable=unused-argument """ Publish `CERTIFICATE_CREATED` events to the event bus. """ + # temporary: defer to EVENT_BUS_PRODUCER_CONFIG if present + producer_config_setting = determine_producer_config_for_signal_and_topic(CERTIFICATE_CREATED, + 'learning-certificate-lifecycle') + if producer_config_setting is True: + log.info("Producing certificate-created event via config") + return if SEND_CERTIFICATE_CREATED_SIGNAL.is_enabled(): + log.info("Producing certificate-created event via manual send") get_producer().send( signal=CERTIFICATE_CREATED, topic='learning-certificate-lifecycle', @@ -181,7 +215,14 @@ def listen_for_certificate_revoked_event(sender, signal, **kwargs): # pylint: d """ Publish `CERTIFICATE_REVOKED` events to the event bus. """ + # temporary: defer to EVENT_BUS_PRODUCER_CONFIG if present + producer_config_setting = determine_producer_config_for_signal_and_topic(CERTIFICATE_REVOKED, + 'learning-certificate-lifecycle') + if producer_config_setting is True: + log.info("Producing certificate-revoked event via config") + return if SEND_CERTIFICATE_REVOKED_SIGNAL.is_enabled(): + log.info("Producing certificate-revoked event via manual send") get_producer().send( signal=CERTIFICATE_REVOKED, topic='learning-certificate-lifecycle', diff --git a/lms/djangoapps/certificates/tests/test_handlers.py b/lms/djangoapps/certificates/tests/test_handlers.py new file mode 100644 index 0000000000..241d9500bd --- /dev/null +++ b/lms/djangoapps/certificates/tests/test_handlers.py @@ -0,0 +1,87 @@ +""" +Unit tests for certificates signals +""" +from datetime import datetime, timezone +from unittest import mock +from uuid import uuid4 + +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.certificates.handlers import handle_exam_attempt_rejected_event + + +class ExamCompletionEventBusTests(TestCase): + """ + Tests completion events from the event bus. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course') + cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection' + cls.usage_key = UsageKey.from_string(cls.subsection_id) + cls.student_user = UserFactory( + username='student_user', + ) + + @staticmethod + def _get_exam_event_data(student_user, course_key, usage_key, exam_type, requesting_user=None): + """ create ExamAttemptData object for exam based event """ + if requesting_user: + requesting_user_data = UserData( + id=requesting_user.id, + is_active=True, + pii=None + ) + else: + requesting_user_data = None + + return ExamAttemptData( + student_user=UserData( + id=student_user.id, + is_active=True, + pii=UserPersonalData( + username=student_user.username, + email=student_user.email, + ), + ), + course_key=course_key, + usage_key=usage_key, + requesting_user=requesting_user_data, + exam_type=exam_type, + ) + + @staticmethod + def _get_exam_event_metadata(event_signal): + """ create metadata object for event """ + return EventsMetadata( + event_type=event_signal.event_type, + id=uuid4(), + minorversion=0, + source='openedx/lms/web', + sourcehost='lms.test', + time=datetime.now(timezone.utc) + ) + + @mock.patch('lms.djangoapps.certificates.handlers.invalidate_certificate') + def test_exam_attempt_rejected_event(self, mock_api_function): + """ + Assert that CertificateService api's invalidate_certificate is called upon consuming the event + """ + exam_event_data = self._get_exam_event_data(self.student_user, + self.course_key, + self.usage_key, + exam_type='proctored') + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_REJECTED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + handle_exam_attempt_rejected_event(None, EXAM_ATTEMPT_REJECTED, **event_kwargs) + mock_api_function.assert_called_once_with(self.student_user.id, self.course_key, source='exam_event') diff --git a/lms/djangoapps/certificates/tests/test_webview_views.py b/lms/djangoapps/certificates/tests/test_webview_views.py index 1fee639502..7949b84f13 100644 --- a/lms/djangoapps/certificates/tests/test_webview_views.py +++ b/lms/djangoapps/certificates/tests/test_webview_views.py @@ -20,12 +20,6 @@ from common.djangoapps.student.roles import CourseStaffRole from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from common.djangoapps.track.tests import EventTrackingTestCase from common.djangoapps.util.date_utils import strftime_localized -from lms.djangoapps.badges.events.course_complete import get_completion_badge -from lms.djangoapps.badges.tests.factories import ( - BadgeAssertionFactory, - BadgeClassFactory, - CourseCompleteImageConfigurationFactory -) from lms.djangoapps.certificates.config import AUTO_CERTIFICATE_GENERATION from lms.djangoapps.certificates.models import ( CertificateGenerationCourseSetting, @@ -58,8 +52,6 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, p FEATURES_WITH_CERTS_ENABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_ENABLED['CERTIFICATES_HTML_VIEW'] = True -FEATURES_WITH_BADGES_ENABLED = FEATURES_WITH_CERTS_ENABLED.copy() -FEATURES_WITH_BADGES_ENABLED['ENABLE_OPENBADGES'] = True FEATURES_WITH_CERTS_DISABLED = settings.FEATURES.copy() FEATURES_WITH_CERTS_DISABLED['CERTIFICATES_HTML_VIEW'] = False @@ -118,7 +110,6 @@ class CommonCertificatesTestCase(ModuleStoreTestCase): ) CertificateHtmlViewConfigurationFactory.create() LinkedInAddToProfileConfigurationFactory.create() - CourseCompleteImageConfigurationFactory.create() def _add_course_certificates(self, count=1, signatory_count=0, is_active=True): """ @@ -533,32 +524,7 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) self.assertContains(response, f'test_organization {self.course.number} Certificate |') self.assertContains(response, 'logo_test1.png') - @ddt.data(True, False) - @patch('lms.djangoapps.certificates.views.webview.get_completion_badge') - def test_fetch_badge_info(self, issue_badges, mock_get_completion_badge): - """ - Test: Fetch badge class info if badges are enabled. - """ - if issue_badges: - features = FEATURES_WITH_BADGES_ENABLED - else: - features = FEATURES_WITH_CERTS_ENABLED - with override_settings(FEATURES=features): - badge_class = BadgeClassFactory(course_id=self.course_id, mode=self.cert.mode) - mock_get_completion_badge.return_value = badge_class - - self._add_course_certificates(count=1, signatory_count=1, is_active=True) - test_url = get_certificate_url(user_id=self.user.id, course_id=self.cert.course_id, - uuid=self.cert.verify_uuid) - response = self.client.get(test_url) - assert response.status_code == 200 - - if issue_badges: - mock_get_completion_badge.assert_called() - else: - mock_get_completion_badge.assert_not_called() - - @override_settings(FEATURES=FEATURES_WITH_BADGES_ENABLED) + @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) @patch.dict("django.conf.settings.SOCIAL_SHARING_SETTINGS", { "CERTIFICATE_TWITTER": True, "CERTIFICATE_FACEBOOK": True, @@ -589,10 +555,6 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) test_org = organizations_api.add_organization(organization_data=test_organization_data) organizations_api.add_organization_course(organization_data=test_org, course_key=str(self.course.id)) self._add_course_certificates(count=1, signatory_count=1, is_active=True) - badge_class = get_completion_badge(course_id=self.course_id, user=self.user) - BadgeAssertionFactory.create( - user=self.user, badge_class=badge_class, - ) self.course.cert_html_view_overrides = { "logo_src": "/static/certificates/images/course_override_logo.png" } @@ -629,8 +591,6 @@ class CertificatesViewsTests(CommonCertificatesTestCase, CacheIsolationTestCase) partner_long_name=long_org_name, ), ) - # Test item from badge info - self.assertContains(response, "Add to Mozilla Backpack") # Test item from site configuration self.assertContains(response, "https://www.test-site.org/about-us") # Test course overrides @@ -1784,53 +1744,3 @@ class CertificateEventTests(CommonCertificatesTestCase, EventTrackingTestCase): }, actual_event['data'] ) - - @override_settings(FEATURES=FEATURES_WITH_CERTS_ENABLED) - def test_evidence_event_sent(self): - self._add_course_certificates(count=1, signatory_count=2) - - cert_url = get_certificate_url( - user_id=self.user.id, - course_id=self.course_id, - uuid=self.cert.verify_uuid - ) - test_url = f'{cert_url}?evidence_visit=1' - self.recreate_tracker() - badge_class = get_completion_badge(self.course_id, self.user) - assertion = BadgeAssertionFactory.create( - user=self.user, badge_class=badge_class, - backend='DummyBackend', - image_url='https://www.example.com/image.png', - assertion_url='https://www.example.com/assertion.json', - data={ - 'issuer': 'https://www.example.com/issuer.json', - } - ) - response = self.client.get(test_url) - - # There are two events being emitted in this flow. - # One for page hit (due to the tracker in the middleware) and - # one due to the certificate being visited. - # We are interested in the second one. - actual_event = self.get_event(1) - - assert response.status_code == 200 - assert_event_matches( - { - 'name': 'edx.badge.assertion.evidence_visited', - 'data': { - 'course_id': 'course-v1:testorg+run1+refundable_course', - 'assertion_id': assertion.id, - 'badge_generator': 'DummyBackend', - 'badge_name': 'refundable course', - 'issuing_component': '', - 'badge_slug': 'course-v1testorgrun1refundable_course_honor_927f3ad', - 'assertion_json_url': 'https://www.example.com/assertion.json', - 'assertion_image_url': 'https://www.example.com/image.png', - 'user_id': self.user.id, - 'issuer': 'https://www.example.com/issuer.json', - 'enrollment_mode': 'honor', - }, - }, - actual_event - ) diff --git a/lms/djangoapps/certificates/tests/tests.py b/lms/djangoapps/certificates/tests/tests.py index ee3bd5c2b1..a6b6362792 100644 --- a/lms/djangoapps/certificates/tests/tests.py +++ b/lms/djangoapps/certificates/tests/tests.py @@ -12,9 +12,8 @@ from milestones.tests.utils import MilestonesTestCaseMixin from pytz import UTC from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory +from common.djangoapps.student.tests.factories import UserFactory from common.djangoapps.util.milestones_helpers import milestones_achieved_by_user, set_prerequisite_courses -from lms.djangoapps.badges.tests.factories import CourseCompleteImageConfigurationFactory from lms.djangoapps.certificates.api import certificate_info_for_user, certificate_status_for_student from lms.djangoapps.certificates.models import ( CertificateStatuses, @@ -204,20 +203,3 @@ class CertificatesModelTest(ModuleStoreTestCase, MilestonesTestCaseMixin): completed_milestones = milestones_achieved_by_user(student, str(pre_requisite_course.id)) assert len(completed_milestones) == 1 assert completed_milestones[0]['namespace'] == str(pre_requisite_course.id) - - @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) - @patch('lms.djangoapps.badges.backends.badgr.BadgrBackend', spec=True) - def test_badge_callback(self, handler): - student = UserFactory() - course = CourseFactory.create(org='edx', number='998', display_name='Test Course', issue_badges=True) - CourseCompleteImageConfigurationFactory() - CourseEnrollmentFactory(user=student, course_id=course.location.course_key, mode='honor') - cert = GeneratedCertificateFactory.create( - user=student, - course_id=course.id, - status=CertificateStatuses.generating, - mode='verified' - ) - cert.status = CertificateStatuses.downloadable - cert.save() - assert handler.return_value.award.called diff --git a/lms/djangoapps/certificates/views/webview.py b/lms/djangoapps/certificates/views/webview.py index 4d99b7d772..28c7423948 100644 --- a/lms/djangoapps/certificates/views/webview.py +++ b/lms/djangoapps/certificates/views/webview.py @@ -15,7 +15,6 @@ from django.http import Http404, HttpResponse, HttpResponseRedirect from django.template import RequestContext from django.utils import translation from django.utils.encoding import smart_str -from eventtracking import tracker from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from openedx_filters.learning.filters import CertificateRenderStarted @@ -27,8 +26,6 @@ from common.djangoapps.edxmako.template import Template from common.djangoapps.student.models import LinkedInAddToProfileConfiguration from common.djangoapps.util.date_utils import strftime_localized from common.djangoapps.util.views import handle_500 -from lms.djangoapps.badges.events.course_complete import get_completion_badge -from lms.djangoapps.badges.utils import badges_enabled from lms.djangoapps.certificates.api import ( certificates_viewable_for_course, display_date_for_certificate, @@ -396,42 +393,6 @@ def _track_certificate_events(request, course, user, user_certificate): """ Tracks web certificate view related events. """ - # Badge Request Event Tracking Logic - course_key = course.location.course_key - - if 'evidence_visit' in request.GET: - badge_class = get_completion_badge(course_key, user) - if not badge_class: - log.warning('Visit to evidence URL for badge, but badges not configured for course "%s"', course_key) - badges = [] - else: - badges = badge_class.get_for_user(user) - if badges: - # There should only ever be one of these. - badge = badges[0] - tracker.emit( - 'edx.badge.assertion.evidence_visited', - { - 'badge_name': badge.badge_class.display_name, - 'badge_slug': badge.badge_class.slug, - 'badge_generator': badge.backend, - 'issuing_component': badge.badge_class.issuing_component, - 'user_id': user.id, - 'course_id': str(course_key), - 'enrollment_mode': badge.badge_class.mode, - 'assertion_id': badge.id, - 'assertion_image_url': badge.image_url, - 'assertion_json_url': badge.assertion_url, - 'issuer': badge.data.get('issuer'), - } - ) - else: - log.warning( - "Could not find badge for %s on course %s.", - user.id, - course_key, - ) - # track certificate evidence_visited event for analytics when certificate_user and accessing_user are different if request.user and request.user.id != user.id: emit_certificate_event('evidence_visited', user, str(course.id), event_data={ @@ -441,18 +402,6 @@ def _track_certificate_events(request, course, user, user_certificate): }) -def _update_badge_context(context, course, user): - """ - Updates context with badge info. - """ - badge = None - if badges_enabled() and course.issue_badges: - badges = get_completion_badge(course.location.course_key, user).get_for_user(user) - if badges: - badge = badges[0] - context['badge'] = badge - - def _update_organization_context(context, course): """ Updates context with organization related info. @@ -630,9 +579,6 @@ def render_html_view(request, course_id, certificate=None): # pylint: disable=t # Append/Override the existing view context values with certificate specific values _update_certificate_context(context, course, course_overview, user_certificate, platform_name) - # Append badge info - _update_badge_context(context, course, user) - # Add certificate header/footer data to current context context.update(get_certificate_header_context(is_secure=request.is_secure())) context.update(get_certificate_footer_context()) diff --git a/lms/djangoapps/commerce/api/v0/views.py b/lms/djangoapps/commerce/api/v0/views.py index b0fa4fcc6e..1022173503 100644 --- a/lms/djangoapps/commerce/api/v0/views.py +++ b/lms/djangoapps/commerce/api/v0/views.py @@ -9,7 +9,6 @@ from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthenticat from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from requests.exceptions import HTTPError -from rest_framework.authentication import SessionAuthentication from rest_framework.permissions import IsAuthenticated from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT from rest_framework.views import APIView @@ -164,7 +163,6 @@ class BasketOrderView(APIView): Retrieve the order associated with a basket. """ - authentication_classes = (SessionAuthentication,) permission_classes = (IsAuthenticated,) def get(self, request, *_args, **kwargs): diff --git a/lms/djangoapps/commerce/api/v1/tests/test_views.py b/lms/djangoapps/commerce/api/v1/tests/test_views.py index 47de5d8ff5..0d9557c31a 100644 --- a/lms/djangoapps/commerce/api/v1/tests/test_views.py +++ b/lms/djangoapps/commerce/api/v1/tests/test_views.py @@ -23,7 +23,7 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, p from ....tests.mocks import mock_order_endpoint from ....tests.test_views import UserMixin -PASSWORD = 'test' +PASSWORD = 'Password1234' JSON_CONTENT_TYPE = 'application/json' diff --git a/lms/djangoapps/commerce/tests/test_views.py b/lms/djangoapps/commerce/tests/test_views.py index 9af2ee7d97..410d2520cc 100644 --- a/lms/djangoapps/commerce/tests/test_views.py +++ b/lms/djangoapps/commerce/tests/test_views.py @@ -3,6 +3,9 @@ from common.djangoapps.student.tests.factories import UserFactory +TEST_PASSWORD = "Password1234" + + class UserMixin: """ Mixin for tests involving users. """ @@ -12,4 +15,4 @@ class UserMixin: def _login(self): """ Log into LMS. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=TEST_PASSWORD) diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py index 72e5e430f6..e2426708d6 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_views.py +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -60,7 +60,7 @@ class TestBlocksView(SharedModuleStoreTestCase): self.admin_user = AdminFactory.create() self.data_researcher = UserFactory.create() CourseDataResearcherRole(self.course_key).add_users(self.data_researcher) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) CourseEnrollmentFactory.create(user=self.user, course_id=self.course_key) # default values for url and query_params @@ -248,7 +248,7 @@ class TestBlocksView(SharedModuleStoreTestCase): self.client.logout() self.verify_response(403, cacheable=False) # Verify response for a staff user. - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) self.verify_response(cacheable=False) def test_non_existent_course(self): @@ -269,7 +269,7 @@ class TestBlocksView(SharedModuleStoreTestCase): self.verify_response(400) def test_no_user_staff_all_blocks(self): - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) self.query_params.pop('username') self.query_params['all_blocks'] = True self.verify_response() @@ -319,7 +319,7 @@ class TestBlocksView(SharedModuleStoreTestCase): - other_course_settings - course_visibility """ - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) response = self.verify_response(params={ 'all_blocks': True, 'requested_fields': ['other_course_settings', 'course_visibility'], @@ -351,7 +351,7 @@ class TestBlocksView(SharedModuleStoreTestCase): - other_course_settings - course_visibility """ - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) response = self.verify_response(params={ 'all_blocks': True, 'requested_fields': ['course_visibility'], @@ -370,7 +370,7 @@ class TestBlocksView(SharedModuleStoreTestCase): """ Test if data researcher has access to the api endpoint """ - self.client.login(username=self.data_researcher.username, password='test') + self.client.login(username=self.data_researcher.username, password=self.TEST_PASSWORD) self.verify_response(params={ 'all_blocks': True, @@ -556,7 +556,7 @@ class TestBlockMetadataView(SharedModuleStoreTestCase): # pylint: disable=test- def setUp(self): super().setUp() self.admin_user = AdminFactory.create() - self.client.login(username=self.admin_user.username, password='test') + self.client.login(username=self.admin_user.username, password=self.TEST_PASSWORD) self.usage_key = list(self.non_orphaned_block_usage_keys)[0] self.url = reverse( 'blocks_metadata', diff --git a/lms/djangoapps/course_api/tests/mixins.py b/lms/djangoapps/course_api/tests/mixins.py index 2940826c68..46671867b7 100644 --- a/lms/djangoapps/course_api/tests/mixins.py +++ b/lms/djangoapps/course_api/tests/mixins.py @@ -8,7 +8,7 @@ from datetime import datetime from common.djangoapps.student.tests.factories import UserFactory, CourseEnrollmentFactory, CourseAccessRoleFactory from xmodule.modulestore.tests.factories import ToyCourseFactory # lint-amnesty, pylint: disable=wrong-import-order -TEST_PASSWORD = 'edx' +TEST_PASSWORD = 'Password1234' class CourseApiFactoryMixin: diff --git a/lms/djangoapps/course_blocks/transformers/tests/helpers.py b/lms/djangoapps/course_blocks/transformers/tests/helpers.py index 27e7a686ab..fb78946280 100644 --- a/lms/djangoapps/course_blocks/transformers/tests/helpers.py +++ b/lms/djangoapps/course_blocks/transformers/tests/helpers.py @@ -50,7 +50,7 @@ class CourseStructureTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase) """ super().setUp() # Set up users. - self.password = 'test' + self.password = self.TEST_PASSWORD self.user = UserFactory.create(password=self.password) self.staff = UserFactory.create(password=self.password, is_staff=True) @@ -253,7 +253,7 @@ class BlockParentsMapTestCase(TransformerRegistryTestMixin, ModuleStoreTestCase) parent_block.children.append(self.xblock_keys[i]) update_block(parent_block) - self.password = 'test' + self.password = self.TEST_PASSWORD self.student = UserFactory.create(is_staff=False, username='test_student', password=self.password) self.staff = UserFactory.create(is_staff=True, username='test_staff', password=self.password) CourseEnrollmentFactory.create( diff --git a/lms/djangoapps/course_home_api/progress/serializers.py b/lms/djangoapps/course_home_api/progress/serializers.py index 190cd76bf9..6bdc204434 100644 --- a/lms/djangoapps/course_home_api/progress/serializers.py +++ b/lms/djangoapps/course_home_api/progress/serializers.py @@ -145,3 +145,4 @@ class ProgressTabSerializer(VerifiedModeSerializer): username = serializers.CharField() user_has_passing_grade = serializers.BooleanField() verification_data = VerificationDataSerializer() + disable_progress_graph = serializers.BooleanField() diff --git a/lms/djangoapps/course_home_api/progress/views.py b/lms/djangoapps/course_home_api/progress/views.py index 8c21335dca..3783c19061 100644 --- a/lms/djangoapps/course_home_api/progress/views.py +++ b/lms/djangoapps/course_home_api/progress/views.py @@ -230,6 +230,7 @@ class ProgressTabView(RetrieveAPIView): block = modulestore().get_course(course_key) grading_policy = block.grading_policy + disable_progress_graph = block.disable_progress_graph verification_status = IDVerificationService.user_status(student) verification_link = None if verification_status['status'] is None or verification_status['status'] == 'expired': @@ -259,6 +260,7 @@ class ProgressTabView(RetrieveAPIView): 'username': username, 'user_has_passing_grade': user_has_passing_grade, 'verification_data': verification_data, + 'disable_progress_graph': disable_progress_graph, } context = self.get_serializer_context() context['staff_access'] = is_staff diff --git a/lms/djangoapps/course_wiki/middleware.py b/lms/djangoapps/course_wiki/middleware.py index 54884a23f2..37bef0b747 100644 --- a/lms/djangoapps/course_wiki/middleware.py +++ b/lms/djangoapps/course_wiki/middleware.py @@ -15,6 +15,8 @@ from openedx.core.lib.request_utils import course_id_from_url from openedx.features.enterprise_support.api import get_enterprise_consent_url from common.djangoapps.student.models import CourseEnrollment +from xmodule.modulestore.django import modulestore + class WikiAccessMiddleware(MiddlewareMixin): """ @@ -55,6 +57,20 @@ class WikiAccessMiddleware(MiddlewareMixin): course_id = course_id_from_url(request.path) wiki_path = request.path.partition('/wiki/')[2] + # if no wiki_path, can't get wiki_slug, so no point trying to look up + # course_id by wiki_slug + if not course_id and wiki_path: + # wiki path always starts with wiki_slug + wiki_slug = wiki_path.split('/')[0] + + course_ids = modulestore().get_courses_for_wiki(wiki_slug) + # the above can return multiple courses, and to avoid ambiguity and + # avoid pointing to wrong courses, we only set course_id if we've + # got an exact match, i.e. only one course was returned for a + # wiki_slug + if len(course_ids) == 1: + course_id = course_ids[0] + if course_id: # This is a /courses/org/name/run/wiki request course_path = f"/courses/{str(course_id)}" diff --git a/lms/djangoapps/course_wiki/tests/test_middleware.py b/lms/djangoapps/course_wiki/tests/test_middleware.py index d9fa62608c..264a24f5a2 100644 --- a/lms/djangoapps/course_wiki/tests/test_middleware.py +++ b/lms/djangoapps/course_wiki/tests/test_middleware.py @@ -34,3 +34,10 @@ class TestWikiAccessMiddleware(ModuleStoreTestCase): response = self.client.get('/courses/course-v1:edx+math101+2014/wiki/math101/') self.assertContains(response, '/courses/course-v1:edx+math101+2014/wiki/math101/_edit/') self.assertContains(response, '/courses/course-v1:edx+math101+2014/wiki/math101/_settings/') + + def test_finds_course_by_wiki_slug(self): + """Test that finds course by wiki slug, if course id is not present in the url.""" + response = self.client.get('/wiki/math101/') + request = response.wsgi_request + self.assertTrue(hasattr(request, 'course')) + self.assertEqual(request.course.id, self.course_math101.id) diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py index 8cdd93115d..7fc86947d4 100644 --- a/lms/djangoapps/course_wiki/tests/tests.py +++ b/lms/djangoapps/course_wiki/tests/tests.py @@ -24,7 +24,7 @@ class WikiRedirectTestCase(EnterpriseTestConsentRequired, LoginEnrollmentTestCas # Create two accounts self.student = 'view@test.com' self.instructor = 'view2@test.com' - self.password = 'foo' + self.password = self.TEST_PASSWORD for username, email in [('u1', self.student), ('u2', self.instructor)]: self.create_account(username, email, self.password) self.activate_user(email) diff --git a/lms/djangoapps/courseware/block_render.py b/lms/djangoapps/courseware/block_render.py index 650b4418b4..fc02e2662e 100644 --- a/lms/djangoapps/courseware/block_render.py +++ b/lms/djangoapps/courseware/block_render.py @@ -40,8 +40,6 @@ from xblock.exceptions import NoSuchHandlerError, NoSuchViewError from xblock.reference.plugins import FSService from xblock.runtime import KvsFieldData -from lms.djangoapps.badges.service import BadgingService -from lms.djangoapps.badges.utils import badges_enabled from lms.djangoapps.teams.services import TeamsService from openedx.core.lib.xblock_services.call_to_action import CallToActionService from xmodule.contentstore.django import contentstore @@ -630,7 +628,6 @@ def prepare_runtime_for_user( 'partitions': PartitionService(course_id=course_id, cache=DEFAULT_REQUEST_CACHE.data), 'settings': SettingsService(), 'user_tags': UserTagsService(user=user, course_id=course_id), - 'badging': BadgingService(course_id=course_id, modulestore=store) if badges_enabled() else None, 'teams': TeamsService(), 'teams_configuration': TeamsConfigurationService(), 'call_to_action': CallToActionService(), diff --git a/lms/djangoapps/courseware/rules.py b/lms/djangoapps/courseware/rules.py index 8202418f6f..07cbbab902 100644 --- a/lms/djangoapps/courseware/rules.py +++ b/lms/djangoapps/courseware/rules.py @@ -18,7 +18,7 @@ from xblock.core import XBlock from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.enrollments.api import is_enrollment_valid_for_proctoring from common.djangoapps.student.models import CourseAccessRole -from common.djangoapps.student.roles import CourseRole, OrgRole +from common.djangoapps.student.roles import CourseRole, OrgRole, strict_role_checking from xmodule.course_block import CourseBlock # lint-amnesty, pylint: disable=wrong-import-order from xmodule.error_block import ErrorBlock # lint-amnesty, pylint: disable=wrong-import-order @@ -47,10 +47,14 @@ class HasAccessRule(Rule): """ A rule that calls `has_access` to determine whether it passes """ - def __init__(self, action): + def __init__(self, action, strict=False): self.action = action + self.strict = strict def check(self, user, instance=None): + if self.strict: + with strict_role_checking(): + return has_access(user, self.action, instance) return has_access(user, self.action, instance) def query(self, user): diff --git a/lms/djangoapps/courseware/tests/helpers.py b/lms/djangoapps/courseware/tests/helpers.py index 3a2ddbc456..1ab77ff93d 100644 --- a/lms/djangoapps/courseware/tests/helpers.py +++ b/lms/djangoapps/courseware/tests/helpers.py @@ -128,7 +128,7 @@ class BaseTestXmodule(ModuleStoreTestCase): self.clients = {user.username: Client() for user in self.users} self.login_statuses = [ self.clients[user.username].login( - username=user.username, password='test') + username=user.username, password=self.TEST_PASSWORD) for user in self.users ] @@ -173,7 +173,7 @@ class LoginEnrollmentTestCase(TestCase): Create a user account, activate, and log in. """ self.email = 'foo@test.com' # lint-amnesty, pylint: disable=attribute-defined-outside-init - self.password = 'bar' # lint-amnesty, pylint: disable=attribute-defined-outside-init + self.password = 'Password1234' # lint-amnesty, pylint: disable=attribute-defined-outside-init self.username = 'test' # lint-amnesty, pylint: disable=attribute-defined-outside-init self.user = self.create_account( self.username, diff --git a/lms/djangoapps/courseware/tests/test_about.py b/lms/djangoapps/courseware/tests/test_about.py index f1f915fd02..d53d620d3e 100644 --- a/lms/djangoapps/courseware/tests/test_about.py +++ b/lms/djangoapps/courseware/tests/test_about.py @@ -284,7 +284,7 @@ class AboutWithCappedEnrollmentsTestCase(LoginEnrollmentTestCase, SharedModuleSt # pylint: disable=attribute-defined-outside-init # create a new account since the first account is already enrolled in the course self.email = 'foo_second@test.com' - self.password = 'bar' + self.password = 'Password1234' self.username = 'test_second' self.create_account(self.username, self.email, self.password) self.activate_user(self.email) diff --git a/lms/djangoapps/courseware/tests/test_access.py b/lms/djangoapps/courseware/tests/test_access.py index 8a4cbabad6..ba93ec6762 100644 --- a/lms/djangoapps/courseware/tests/test_access.py +++ b/lms/djangoapps/courseware/tests/test_access.py @@ -82,8 +82,8 @@ class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase) super().setUp() # Create ccx coach account - self.coach = AdminFactory.create(password="test") - self.client.login(username=self.coach.username, password="test") + self.coach = AdminFactory.create(password=self.TEST_PASSWORD) + self.client.login(username=self.coach.username, password=self.TEST_PASSWORD) # assign role to coach role = CourseCcxCoachRole(self.course.id) @@ -152,7 +152,7 @@ class CoachAccessTestCaseCCX(SharedModuleStoreTestCase, LoginEnrollmentTestCase) assert resp.status_code == 200 # Assert access of a student - self.client.login(username=student.username, password='test') + self.client.login(username=student.username, password=self.TEST_PASSWORD) resp = self.client.get(reverse('student_progress', args=[str(ccx_locator), self.coach.id])) assert resp.status_code == 404 diff --git a/lms/djangoapps/courseware/tests/test_block_render.py b/lms/djangoapps/courseware/tests/test_block_render.py index 80827afa63..668ce51712 100644 --- a/lms/djangoapps/courseware/tests/test_block_render.py +++ b/lms/djangoapps/courseware/tests/test_block_render.py @@ -75,8 +75,6 @@ from common.djangoapps.xblock_django.constants import ( ATTR_KEY_USER_IS_STAFF, ATTR_KEY_USER_ROLE, ) -from lms.djangoapps.badges.tests.factories import BadgeClassFactory -from lms.djangoapps.badges.tests.test_models import get_image from lms.djangoapps.courseware import block_render as render from lms.djangoapps.courseware.access_response import AccessResponse from lms.djangoapps.courseware.courses import get_course_info_section, get_course_with_access @@ -362,7 +360,7 @@ class BlockRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase): def test_session_authentication(self): """ Test that the xblock endpoint supports session authentication.""" - self.client.login(username=self.mock_user.username, password="test") + self.client.login(username=self.mock_user.username, password=self.TEST_PASSWORD) dispatch_url = self._get_dispatch_url() response = self.client.post(dispatch_url) assert 200 == response.status_code @@ -387,7 +385,7 @@ class BlockRenderTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase): """ Test that sending POST request without or invalid position argument don't raise server error """ - self.client.login(username=self.mock_user.username, password="test") + self.client.login(username=self.mock_user.username, password=self.TEST_PASSWORD) dispatch_url = self._get_dispatch_url() response = self.client.post(dispatch_url) assert 200 == response.status_code @@ -2377,46 +2375,6 @@ class LMSXBlockServiceBindingTest(LMSXBlockServiceMixin): self.block.runtime.service(self.block, 'user_tags').get_tag('fake_scope', key) -@ddt.ddt -class TestBadgingService(LMSXBlockServiceMixin): - """Test the badging service interface""" - - @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) - def test_service_rendered(self): - self._prepare_runtime() - assert self.block.runtime.service(self.block, 'badging') - - def test_no_service_rendered(self): - with pytest.raises(NoSuchServiceError): - self.block.runtime.service(self.block, 'badging') - - @ddt.data(True, False) - @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) - def test_course_badges_toggle(self, toggle): - self.course = CourseFactory.create(metadata={'issue_badges': toggle}) - self._prepare_runtime() - assert self.block.runtime.service(self.block, 'badging').course_badges_enabled is toggle - - @patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) - def test_get_badge_class(self): - self._prepare_runtime() - badge_service = self.block.runtime.service(self.block, 'badging') - premade_badge_class = BadgeClassFactory.create() - # Ignore additional parameters. This class already exists. - # We should get back the first class we created, rather than a new one. - with get_image('good') as image_handle: - badge_class = badge_service.get_badge_class( - slug='test_slug', issuing_component='test_component', description='Attempted override', - criteria='test', display_name='Testola', image_file_handle=image_handle - ) - # These defaults are set on the factory. - assert badge_class.criteria == 'https://example.com/syllabus' - assert badge_class.display_name == 'Test Badge' - assert badge_class.description == "Yay! It's a test badge." - # File name won't always be the same. - assert badge_class.image.path == premade_badge_class.image.path - - class TestI18nService(LMSXBlockServiceMixin): """ Test XBlockI18nService """ diff --git a/lms/djangoapps/courseware/tests/test_course_survey.py b/lms/djangoapps/courseware/tests/test_course_survey.py index d729add1b6..98b5842cd2 100644 --- a/lms/djangoapps/courseware/tests/test_course_survey.py +++ b/lms/djangoapps/courseware/tests/test_course_survey.py @@ -22,7 +22,7 @@ class SurveyViewsTests(LoginEnrollmentTestCase, SharedModuleStoreTestCase, XssTe """ All tests for the views.py file """ - STUDENT_INFO = [('view@test.com', 'foo')] + STUDENT_INFO = [('view@test.com', 'Password1234')] @classmethod def setUpClass(cls): diff --git a/lms/djangoapps/courseware/tests/test_discussion_xblock.py b/lms/djangoapps/courseware/tests/test_discussion_xblock.py index bbe0e68bdc..599f125a7e 100644 --- a/lms/djangoapps/courseware/tests/test_discussion_xblock.py +++ b/lms/djangoapps/courseware/tests/test_discussion_xblock.py @@ -366,7 +366,7 @@ class TestXBlockInCourse(SharedModuleStoreTestCase): """ Tests that course block api returns student_view_data for discussion xblock """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) url = reverse('blocks_in_block_tree', kwargs={'usage_key_string': str(self.course_usage_key)}) query_params = { 'depth': 'all', diff --git a/lms/djangoapps/courseware/tests/test_entrance_exam.py b/lms/djangoapps/courseware/tests/test_entrance_exam.py index 240a9494fc..338a7bedfc 100644 --- a/lms/djangoapps/courseware/tests/test_entrance_exam.py +++ b/lms/djangoapps/courseware/tests/test_entrance_exam.py @@ -136,7 +136,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest self.request = get_mock_request(UserFactory()) self.course = self.update_course(self.course, self.request.user.id) - self.client.login(username=self.request.user.username, password="test") + self.client.login(username=self.request.user.username, password=self.TEST_PASSWORD) CourseEnrollment.enroll(self.request.user, self.course.id) self.expected_locked_toc = ( @@ -257,7 +257,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest # hit skip entrance exam api in instructor app instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { 'unique_student_identifier': self.request.user.email, @@ -277,7 +277,7 @@ class EntranceExamTestCases(LoginEnrollmentTestCase, ModuleStoreTestCase, Milest self.client.logout() staff_user = StaffFactory(course_key=self.course.id) staff_user.is_staff = True - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) # assert staff has access to all toc self.request.user = staff_user diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index 43041b1437..9d211b05ea 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -88,7 +88,7 @@ class MasqueradeTestCase(SharedModuleStoreTestCase, LoginEnrollmentTestCase, Mas super().setUp() self.test_user = self.create_user() - self.login(self.test_user.email, 'test') + self.login(self.test_user.email, self.TEST_PASSWORD) self.enroll(self.course, True) def get_courseware_page(self): @@ -298,12 +298,12 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi def login_staff(self): """ Login as a staff user """ self.logout() - self.login(self.test_user.email, 'test') + self.login(self.test_user.email, self.TEST_PASSWORD) def login_student(self): """ Login as a student """ self.logout() - self.login(self.student_user.email, 'test') + self.login(self.student_user.email, self.TEST_PASSWORD) def submit_answer(self, response1, response2): """ @@ -351,7 +351,7 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi student = UserFactory.create(username=username) CourseEnrollment.enroll(student, self.course.id) self.logout() - self.login(student.email, 'test') + self.login(student.email, self.TEST_PASSWORD) # Answer correctly as the student, and check progress. self.submit_answer('Correct', 'Correct') assert self.get_progress_detail() == '2/2' @@ -378,7 +378,7 @@ class TestStaffMasqueradeAsSpecificStudent(StaffMasqueradeTestCase, ProblemSubmi # Verify the student state did not change. self.logout() - self.login(student.email, 'test') + self.login(student.email, self.TEST_PASSWORD) assert self.get_progress_detail() == '2/2' def test_masquerading_with_language_preference(self): diff --git a/lms/djangoapps/courseware/tests/test_split_module.py b/lms/djangoapps/courseware/tests/test_split_module.py index 7e6b88718e..5026f05ca2 100644 --- a/lms/djangoapps/courseware/tests/test_split_module.py +++ b/lms/djangoapps/courseware/tests/test_split_module.py @@ -59,7 +59,7 @@ class SplitTestBase(ModuleStoreTestCase): self.student = UserFactory.create() CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - self.client.login(username=self.student.username, password='test') + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self.included_usage_keys = None self.excluded_usage_keys = None @@ -309,7 +309,7 @@ class SplitTestPosition(SharedModuleStoreTestCase): self.student = UserFactory.create() CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) - self.client.login(username=self.student.username, password='test') + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) def test_changing_position_works(self): # Make a mock FieldDataCache for this course, so we can get the course block diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 8ffcea0298..4d55645d7a 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -154,7 +154,7 @@ class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase, Probl # create a test student self.course = CourseFactory.create(display_name=self.COURSE_NAME, number=self.COURSE_SLUG) self.student = 'view@test.com' - self.password = 'foo' + self.password = self.TEST_PASSWORD self.create_account('u1', self.student, self.password) self.activate_user(self.student) self.enroll(self.course) diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index db59fa373b..6ad7ef73de 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -404,7 +404,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi # login as instructor hit skip entrance exam api in instructor app instructor = InstructorFactory(course_key=self.course.id) self.client.logout() - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) url = reverse('mark_student_can_skip_entrance_exam', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { @@ -426,7 +426,7 @@ class EntranceExamsTabsTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase, Mi # Login as member of staff self.client.logout() staff_user = StaffFactory(course_key=self.course.id) - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) course_tab_list = get_course_tab_list(staff_user, self.course) assert len(course_tab_list) == 4 @@ -685,7 +685,7 @@ class CourseTabListTestCase(TabListTestCase): # Login as member of staff self.client.logout() staff_user = StaffFactory(course_key=self.course.id) - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) course_tab_list_staff = get_course_tab_list(staff_user, self.course) name_list_staff = [x.name for x in course_tab_list_staff] assert 'Static Tab Free' in name_list_staff diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 42f2275e0f..67ada18e85 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -29,7 +29,7 @@ class TestViewAuth(EnterpriseTestConsentRequired, ModuleStoreTestCase, LoginEnro Check that view authentication works properly. """ - ACCOUNT_INFO = [('view@test.com', 'foo'), ('view2@test.com', 'foo')] + ACCOUNT_INFO = [('view@test.com', 'Password1234'), ('view2@test.com', 'Password1234')] ENABLED_SIGNALS = ['course_published'] @staticmethod @@ -111,7 +111,7 @@ class TestViewAuth(EnterpriseTestConsentRequired, ModuleStoreTestCase, LoginEnro self.assert_request_status_code(302, url) def login(self, user): # lint-amnesty, pylint: disable=arguments-differ - return super().login(user.email, 'test') + return super().login(user.email, self.TEST_PASSWORD) def setUp(self): super().setUp() diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index c7d7ce7b17..bebd4a79fb 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -30,6 +30,7 @@ from opaque_keys.edx.keys import CourseKey, UsageKey from pytz import UTC from openedx.core.djangoapps.waffle_utils.models import WaffleFlagCourseOverrideModel from rest_framework import status +from rest_framework.test import APIClient from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.fields import Scope, String @@ -76,7 +77,10 @@ from lms.djangoapps.courseware.block_render import get_block, handle_xblock_call from lms.djangoapps.courseware.tests.factories import StudentModuleFactory from lms.djangoapps.courseware.tests.helpers import MasqueradeMixin, get_expiration_banner_text, set_preview_mode from lms.djangoapps.courseware.testutils import RenderXBlockTestMixin -from lms.djangoapps.courseware.toggles import COURSEWARE_OPTIMIZED_RENDER_XBLOCK +from lms.djangoapps.courseware.toggles import ( + COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, + COURSEWARE_OPTIMIZED_RENDER_XBLOCK, +) from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient from lms.djangoapps.courseware.views.views import ( BasePublicVideoXBlockView, @@ -712,7 +716,7 @@ class ViewsTestCase(BaseViewsTestCase): # log into a staff account admin = AdminFactory() - assert self.client.login(username=admin.username, password='test') + assert self.client.login(username=admin.username, password=TEST_PASSWORD) url = reverse('submission_history', kwargs={ 'course_id': str(self.course_key), @@ -727,7 +731,7 @@ class ViewsTestCase(BaseViewsTestCase): # log into a staff account admin = AdminFactory() - assert self.client.login(username=admin.username, password='test') + assert self.client.login(username=admin.username, password=TEST_PASSWORD) # try it with an existing user and a malicious location url = reverse('submission_history', kwargs={ @@ -751,7 +755,7 @@ class ViewsTestCase(BaseViewsTestCase): # log into a staff account admin = AdminFactory.create() - assert self.client.login(username=admin.username, password='test') + assert self.client.login(username=admin.username, password=TEST_PASSWORD) usage_key = self.course_key.make_usage_key('problem', 'test-history') state_client = DjangoXBlockUserStateClient(admin) @@ -814,7 +818,7 @@ class ViewsTestCase(BaseViewsTestCase): course_key = course.id client = Client() admin = AdminFactory.create() - assert client.login(username=admin.username, password='test') + assert client.login(username=admin.username, password=TEST_PASSWORD) state_client = DjangoXBlockUserStateClient(admin) usage_key = course_key.make_usage_key('problem', 'test-history') state_client.set( @@ -1253,7 +1257,7 @@ class ProgressPageBaseTests(ModuleStoreTestCase): def setUp(self): super().setUp() self.user = UserFactory.create() - assert self.client.login(username=self.user.username, password='test') + assert self.client.login(username=self.user.username, password=TEST_PASSWORD) self.setup_course() @@ -1352,7 +1356,7 @@ class ProgressPageTests(ProgressPageBaseTests): # Create a new course, a user which will not be enrolled in course, admin user for staff access course = CourseFactory.create(default_store=ModuleStoreEnum.Type.split) admin = AdminFactory.create() - assert self.client.login(username=admin.username, password='test') + assert self.client.login(username=admin.username, password=TEST_PASSWORD) # Create and enable Credit course CreditCourse.objects.create(course_key=course.id, enabled=True) @@ -1646,7 +1650,7 @@ class ProgressPageTests(ProgressPageBaseTests): """ CourseDurationLimitConfig.objects.create(enabled=True, enabled_as_of=datetime(2018, 1, 1)) user = UserFactory.create() - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=user.username, password=TEST_PASSWORD) add_course_mode(self.course, mode_slug=CourseMode.AUDIT) add_course_mode(self.course) CourseEnrollmentFactory(user=user, course_id=self.course.id, mode=course_mode) @@ -1679,7 +1683,7 @@ class ProgressPageTests(ProgressPageBaseTests): """ CourseDurationLimitConfig.objects.create(enabled=False) user = UserFactory.create() - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=user.username, password=TEST_PASSWORD) CourseModeFactory.create( course_id=self.course.id, mode_slug=course_mode @@ -1698,7 +1702,7 @@ class ProgressPageTests(ProgressPageBaseTests): in an ineligible mode. """ user = UserFactory.create() - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=user.username, password=TEST_PASSWORD) CourseEnrollmentFactory(user=user, course_id=self.course.id, mode=course_mode) with patch('lms.djangoapps.grades.course_grade_factory.CourseGradeFactory.read') as mock_create: @@ -2081,7 +2085,7 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests): self.setup_course(show_correctness=show_correctness, due_date=due_date, graded=graded) self.add_problem() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=TEST_PASSWORD) resp = self._get_progress_page() # Ensure that expected text is present @@ -2133,7 +2137,7 @@ class ProgressPageShowCorrectnessTests(ProgressPageBaseTests): self.add_problem() # Login as a course staff user to view the student progress page. - self.client.login(username=self.staff_user.username, password='test') + self.client.login(username=self.staff_user.username, password=TEST_PASSWORD) resp = self._get_student_progress_page() @@ -3198,7 +3202,7 @@ class EnterpriseConsentTestCase(EnterpriseTestConsentRequired, ModuleStoreTestCa def setUp(self): super().setUp() self.user = UserFactory.create() - assert self.client.login(username=self.user.username, password='test') + assert self.client.login(username=self.user.username, password=TEST_PASSWORD) self.course = CourseFactory.create() CourseOverview.load_from_module_store(self.course.id) CourseEnrollmentFactory(user=self.user, course_id=self.course.id) @@ -3298,7 +3302,7 @@ class PreviewTests(BaseViewsTestCase): # Previews will not redirect to the mfe course_staff = UserFactory.create(is_staff=False) CourseStaffRole(self.course_key).add_users(course_staff) - self.client.login(username=course_staff.username, password='test') + self.client.login(username=course_staff.username, password=TEST_PASSWORD) assert self.client.get(preview_url).status_code == 200 @@ -3435,7 +3439,7 @@ class TestCourseWideResources(ModuleStoreTestCase): CourseEnrollmentFactory(user=user, course_id=course.id) if is_instructor: allow_access(course, user, 'instructor') - assert self.client.login(username=user.username, password='test') + assert self.client.login(username=user.username, password=TEST_PASSWORD) kwargs = None if param == 'course_id': @@ -3683,3 +3687,41 @@ class TestPublicVideoXBlockEmbedView(TestBasePublicVideoXBlock): assert template == 'public_video_share_embed.html' assert context['fragment'] == fragment assert context['course'] == self.course + + +class TestCoursewareMFESearchAPI(SharedModuleStoreTestCase): + """ + Tests the endpoint to fetch the Courseware Search waffle flag enabled status. + """ + + def setUp(self): + super().setUp() + + self.course = CourseFactory.create() + + self.client = APIClient() + self.apiUrl = reverse('courseware_search_enabled_view', kwargs={'course_id': str(self.course.id)}) + + @override_waffle_flag(COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, active=True) + def test_courseware_mfe_search_enabled(self): + """ + Getter to check if user is allowed to use Courseware Search. + """ + + response = self.client.get(self.apiUrl, content_type='application/json') + body = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(body, {'enabled': True}) + + @override_waffle_flag(COURSEWARE_MICROFRONTEND_SEARCH_ENABLED, active=False) + def test_is_mfe_search_disabled(self): + """ + Getter to check if user is allowed to use Courseware Search. + """ + + response = self.client.get(self.apiUrl, content_type='application/json') + body = json.loads(response.content.decode('utf-8')) + + self.assertEqual(response.status_code, 200) + self.assertEqual(body, {'enabled': False}) diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py index 3c2b946e91..a18074e9cc 100644 --- a/lms/djangoapps/courseware/testutils.py +++ b/lms/djangoapps/courseware/testutils.py @@ -81,7 +81,7 @@ class RenderXBlockTestMixin(MasqueradeMixin, metaclass=ABCMeta): """ Logs in the test user. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password='Password1234') def course_options(self): """ diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index 3e13755123..78cecac7f5 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -55,6 +55,19 @@ COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION = CourseWaffleFl f'{WAFFLE_FLAG_NAMESPACE}.mfe_progress_milestones_streak_celebration', __name__ ) +# .. toggle_name: courseware.mfe_courseware_search +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: Enables Courseware Search on Learning MFE +# .. toggle_use_cases: temporary +# .. toggle_creation_date: 2023-09-28 +# .. toggle_target_removal_date: None +# .. toggle_tickets: KBK-20 +# .. toggle_warning: None. +COURSEWARE_MICROFRONTEND_SEARCH_ENABLED = CourseWaffleFlag( + f'{WAFFLE_FLAG_NAMESPACE}.mfe_courseware_search', __name__ +) + # .. toggle_name: courseware.mfe_progress_milestones_streak_discount_enabled # .. toggle_implementation: CourseWaffleFlag # .. toggle_default: False @@ -153,3 +166,10 @@ def course_is_invitation_only(courselike) -> bool: def learning_assistant_is_active(course_key): return COURSEWARE_LEARNING_ASSISTANT.is_enabled(course_key) + + +def courseware_mfe_search_is_enabled(course_key=None): + """ + Return whether the courseware.mfe_courseware_search flag is on. + """ + return COURSEWARE_MICROFRONTEND_SEARCH_ENABLED.is_enabled(course_key) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 085e9e5a19..fdc90a46a5 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -18,8 +18,8 @@ from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pyli from django.core.exceptions import PermissionDenied from django.db import transaction from django.db.models import Q, prefetch_related_objects -from django.http import Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.shortcuts import redirect +from django.http import JsonResponse, Http404, HttpResponse, HttpResponseBadRequest, HttpResponseForbidden from django.template.context_processors import csrf from django.urls import reverse from django.utils.decorators import method_decorator @@ -38,8 +38,8 @@ from markupsafe import escape from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey from openedx_filters.learning.filters import CourseAboutRenderStarted -from pytz import UTC from requests.exceptions import ConnectionError, Timeout # pylint: disable=redefined-builtin +from pytz import UTC from rest_framework import status from rest_framework.decorators import api_view, throttle_classes from rest_framework.response import Response @@ -87,7 +87,7 @@ from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_stu from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.models import BaseStudentModuleHistory, StudentModule from lms.djangoapps.courseware.permissions import MASQUERADE_AS_STUDENT, VIEW_COURSE_HOME, VIEW_COURSEWARE -from lms.djangoapps.courseware.toggles import course_is_invitation_only +from lms.djangoapps.courseware.toggles import course_is_invitation_only, courseware_mfe_search_is_enabled from lms.djangoapps.courseware.user_state_client import DjangoXBlockUserStateClient from lms.djangoapps.courseware.utils import ( _use_new_financial_assistance_flow, @@ -2275,3 +2275,15 @@ def get_learner_username(learner_identifier): learner = User.objects.filter(Q(username=learner_identifier) | Q(email=learner_identifier)).first() if learner: return learner.username + + +@api_view(['GET']) +def courseware_mfe_search_enabled(request, course_id=None): + """ + Simple GET endpoint to expose whether the course may use Courseware Search. + """ + + course_key = CourseKey.from_string(course_id) if course_id else None + + payload = {"enabled": courseware_mfe_search_is_enabled(course_key)} + return JsonResponse(payload) diff --git a/lms/djangoapps/discussion/django_comment_client/base/tests.py b/lms/djangoapps/discussion/django_comment_client/base/tests.py index d450998a60..b871d4af44 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/tests.py +++ b/lms/djangoapps/discussion/django_comment_client/base/tests.py @@ -241,7 +241,7 @@ class ViewsTestCaseMixin: with patch('common.djangoapps.student.models.user.cc.User.save'): uname = 'student' email = 'student@edx.org' - self.password = 'test' + self.password = 'Password1234' # Create the user and make them active so we can log them in. self.student = UserFactory.create(username=uname, email=email, password=self.password) @@ -464,7 +464,7 @@ class ViewsTestCase( with patch('common.djangoapps.student.models.user.cc.User.save'): uname = 'student' email = 'student@edx.org' - self.password = 'test' + self.password = 'Password1234' # Create the user and make them active so we can log them in. self.student = UserFactory.create(username=uname, email=email, password=self.password) @@ -1859,6 +1859,34 @@ class ForumEventTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockReque assert event['undo_vote'] == undo assert event['vote_value'] == 'up' + @ddt.data('follow_thread', 'unfollow_thread',) + @patch('eventtracking.tracker.emit') + @patch('openedx.core.djangoapps.django_comment_common.comment_client.utils.requests.request', autospec=True) + def test_thread_followed_event(self, view_name, mock_request, mock_emit): + self._set_mock_request_data(mock_request, { + 'closed': False, + 'commentable_id': 'test_commentable_id', + 'username': 'test_user', + }) + request = RequestFactory().post('dummy_url', {}) + request.user = self.student + request.view_name = view_name + view_function = getattr(views, view_name) + kwargs = dict(course_id=str(self.course.id)) + kwargs['thread_id'] = 'thread_id' + view_function(request, **kwargs) + + assert mock_emit.called + event_name, event_data = mock_emit.call_args[0] + action_name = 'followed' if view_name == 'follow_thread' else 'unfollowed' + expected_action_value = True if view_name == 'follow_thread' else False + assert event_name == f'edx.forum.thread.{action_name}' + assert event_data['commentable_id'] == 'test_commentable_id' + assert event_data['id'] == 'thread_id' + assert event_data['followed'] == expected_action_value + assert event_data['user_forums_roles'] == ['Student'] + assert event_data['user_course_roles'] == ['Wizard'] + class UsersEndpointTestCase(ForumsEnableMixin, SharedModuleStoreTestCase, MockRequestSetupMixin): diff --git a/lms/djangoapps/discussion/django_comment_client/base/views.py b/lms/djangoapps/discussion/django_comment_client/base/views.py index 5ad3dae899..1ec41e3155 100644 --- a/lms/djangoapps/discussion/django_comment_client/base/views.py +++ b/lms/djangoapps/discussion/django_comment_client/base/views.py @@ -134,6 +134,20 @@ def track_thread_created_event(request, course, thread, followed, from_mfe_sideb track_created_event(request, event_name, course, thread, event_data) +def track_thread_followed_event(request, course, thread, followed): + """ + Send analytics event for a newly followed/unfollowed thread. + """ + action_name = 'followed' if followed else 'unfollowed' + event_name = _EVENT_NAME_TEMPLATE.format(obj_type='thread', action_name=action_name) + event_data = { + 'commentable_id': thread.commentable_id, + 'id': thread.id, + 'followed': followed, + } + track_forum_event(request, event_name, course, thread, event_data) + + def track_comment_created_event(request, course, comment, commentable_id, followed, from_mfe_sidebar=False): """ Send analytics event for a newly created response or comment. @@ -938,9 +952,12 @@ def un_pin_thread(request, course_id, thread_id): @permitted def follow_thread(request, course_id, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring, unused-argument user = cc.User.from_django_user(request.user) + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) thread = cc.Thread.find(thread_id) user.follow(thread) thread_followed.send(sender=None, user=request.user, post=thread) + track_thread_followed_event(request, course, thread, True) return JsonResponse({}) @@ -966,10 +983,13 @@ def unfollow_thread(request, course_id, thread_id): # lint-amnesty, pylint: dis given a course id and thread id, stop following this thread ajax only """ + course_key = CourseKey.from_string(course_id) + course = get_course_by_id(course_key) user = cc.User.from_django_user(request.user) thread = cc.Thread.find(thread_id) user.unfollow(thread) thread_unfollowed.send(sender=None, user=request.user, post=thread) + track_thread_followed_event(request, course, thread, False) return JsonResponse({}) diff --git a/lms/djangoapps/discussion/rest_api/api.py b/lms/djangoapps/discussion/rest_api/api.py index 2caa901d51..54878d69ca 100644 --- a/lms/djangoapps/discussion/rest_api/api.py +++ b/lms/djangoapps/discussion/rest_api/api.py @@ -93,7 +93,7 @@ from ..django_comment_client.base.views import ( track_voted_event, track_discussion_reported_event, track_discussion_unreported_event, - track_forum_search_event + track_forum_search_event, track_thread_followed_event ) from ..django_comment_client.utils import ( get_group_id_for_user, @@ -127,8 +127,8 @@ from .utils import ( discussion_open_for_user, get_usernames_for_course, get_usernames_from_search_string, - is_posting_allowed, set_attribute, + is_posting_allowed ) @@ -1333,7 +1333,7 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con if field in request_fields and field in api_content and form_value != api_content[field]: api_content[field] = form_value if field == "following": - _handle_following_field(form_value, context["cc_requester"], cc_content) + _handle_following_field(form_value, context["cc_requester"], cc_content, request) elif field == "abuse_flagged": _handle_abuse_flagged_field(form_value, context["cc_requester"], cc_content, request) elif field == "voted": @@ -1346,12 +1346,15 @@ def _do_extra_actions(api_content, cc_content, request_fields, actions_form, con raise ValidationError({field: ["Invalid Key"]}) -def _handle_following_field(form_value, user, cc_content): +def _handle_following_field(form_value, user, cc_content, request): """follow/unfollow thread for the user""" + course_key = CourseKey.from_string(cc_content.course_id) + course = get_course_with_access(request.user, 'load', course_key) if form_value: user.follow(cc_content) else: user.unfollow(cc_content) + track_thread_followed_event(request, course, cc_content, form_value) def _handle_abuse_flagged_field(form_value, user, cc_content, request): diff --git a/lms/djangoapps/discussion/rest_api/discussions_notifications.py b/lms/djangoapps/discussion/rest_api/discussions_notifications.py new file mode 100644 index 0000000000..f3a5e3d61d --- /dev/null +++ b/lms/djangoapps/discussion/rest_api/discussions_notifications.py @@ -0,0 +1,260 @@ +""" +Discussion notifications sender util. +""" +from django.conf import settings +from lms.djangoapps.discussion.django_comment_client.permissions import get_team +from openedx_events.learning.data import UserNotificationData +from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole +from openedx.core.djangoapps.course_groups.models import CourseCohortsSettings, CourseUserGroup +from openedx.core.djangoapps.discussions.utils import get_divided_discussions +from django.utils.translation import gettext_lazy as _ + +from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment +from openedx.core.djangoapps.django_comment_common.comment_client.subscriptions import Subscription +from openedx.core.djangoapps.django_comment_common.models import ( + FORUM_ROLE_ADMINISTRATOR, + FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_MODERATOR, + CourseDiscussionSettings, + Role +) + + +class DiscussionNotificationSender: + """ + Class to send notifications to users who are subscribed to the thread. + """ + + def __init__(self, thread, course, creator, parent_id=None): + self.thread = thread + self.course = course + self.creator = creator + self.parent_id = parent_id + self.parent_response = None + self._get_parent_response() + + def _send_notification(self, user_ids, notification_type, extra_context=None): + """ + Send notification to users + """ + if not user_ids: + return + + if extra_context is None: + extra_context = {} + + notification_data = UserNotificationData( + user_ids=[int(user_id) for user_id in user_ids], + context={ + "replier_name": self.creator.username, + "post_title": self.thread.title, + "course_name": self.course.display_name, + **extra_context, + }, + notification_type=notification_type, + content_url=f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/{self.thread.id}", + app_name="discussion", + course_key=self.course.id, + ) + USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data) + + def _get_parent_response(self): + """ + Get parent response object + """ + if self.parent_id and not self.parent_response: + self.parent_response = Comment(id=self.parent_id).retrieve() + + return self.parent_response + + def send_new_response_notification(self): + """ + Send notification to users who are subscribed to the main thread/post i.e. + there is a response to the main thread. + """ + if not self.parent_id and self.creator.id != int(self.thread.user_id): + self._send_notification([self.thread.user_id], "new_response") + + def _response_and_thread_has_same_creator(self) -> bool: + """ + Check if response and main thread have same author. + """ + return int(self.parent_response.user_id) == int(self.thread.user_id) + + def _response_and_comment_has_same_creator(self): + return int(self.parent_response.attributes['user_id']) == self.creator.id + + def send_new_comment_notification(self): + """ + Send notification to parent thread creator i.e. comment on the response. + """ + if ( + self.parent_response and + self.creator.id != int(self.thread.user_id) + ): + # use your if author of response is same as author of post. + # use 'their' if comment author is also response author. + author_name = ( + # Translators: Replier commented on "your" response to your post + _("your") + if self._response_and_thread_has_same_creator() + else ( + # Translators: Replier commented on "their" response to your post + _("their") + if self._response_and_comment_has_same_creator() + else f"{self.parent_response.username}'s" + ) + ) + context = { + "author_name": str(author_name), + } + self._send_notification([self.thread.user_id], "new_comment", extra_context=context) + + def send_new_comment_on_response_notification(self): + """ + Send notification to parent response creator i.e. comment on the response. + Do not send notification if author of response is same as author of post. + """ + if ( + self.parent_response and + self.creator.id != int(self.parent_response.user_id) and not + self._response_and_thread_has_same_creator() + ): + self._send_notification([self.parent_response.user_id], "new_comment_on_response") + + def _check_if_subscriber_is_not_thread_or_content_creator(self, subscriber_id) -> bool: + """ + Check if the subscriber is not the thread creator or response creator + """ + is_not_creator = ( + subscriber_id != int(self.thread.user_id) and + subscriber_id != int(self.creator.id) + ) + if self.parent_response: + return is_not_creator and subscriber_id != int(self.parent_response.user_id) + + return is_not_creator + + def send_response_on_followed_post_notification(self): + """ + Send notification to followers of the thread/post + except: + Tread creator , response creator, + """ + users = [] + page = 1 + has_more_subscribers = True + + while has_more_subscribers: + + subscribers = Subscription.fetch(self.thread.id, query_params={'page': page}) + if page <= subscribers.num_pages: + for subscriber in subscribers.collection: + # Check if the subscriber is not the thread creator or response creator + subscriber_id = int(subscriber.get('subscriber_id')) + # do not send notification to the user who created the response and the thread + if self._check_if_subscriber_is_not_thread_or_content_creator(subscriber_id): + users.append(subscriber_id) + else: + has_more_subscribers = False + page += 1 + # Remove duplicate users from the list of users to send notification + users = list(set(users)) + if not self.parent_id: + self._send_notification(users, "response_on_followed_post") + else: + self._send_notification( + users, + "comment_on_followed_post", + extra_context={"author_name": self.parent_response.username} + ) + + def _create_cohort_course_audience(self): + """ + Creates audience based on user cohort and role + """ + course_key_str = str(self.course.id) + discussion_cohorted = is_discussion_cohorted(course_key_str) + + # Retrieves cohort divided discussion + discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions( + self.course, + discussion_settings + ) + + # Checks if post has any cohort assigned + group_id = self.thread.attributes['group_id'] + if group_id is not None: + group_id = int(group_id) + + # Course wide topics + topic_id = self.thread.attributes['commentable_id'] + all_topics = divided_inline_discussions + divided_course_wide_discussions + topic_divided = topic_id in all_topics or discussion_settings.always_divide_inline_discussions + + # Team object from topic id + team = get_team(topic_id) + + user_ids = [] + if team: + user_ids = team.users.all().values_list('id', flat=True) + elif discussion_cohorted and topic_divided and group_id is not None: + users_in_cohort = CourseUserGroup.objects.filter( + course_id=course_key_str, id=group_id + ).values_list('users__id', flat=True) + user_ids.extend(users_in_cohort) + + privileged_roles = [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA] + privileged_users = Role.objects.filter( + name__in=privileged_roles, + course_id=course_key_str + ).values_list('users__id', flat=True) + user_ids.extend(privileged_users) + + staff_users = CourseStaffRole(self.course.id).users_with_role().values_list('id', flat=True) + user_ids.extend(staff_users) + + admin_users = CourseInstructorRole(self.course.id).users_with_role().values_list('id', flat=True) + user_ids.extend(admin_users) + else: + user_ids = CourseEnrollment.objects.filter( + course__id=course_key_str, is_active=True + ).values_list('user__id', flat=True) + + unique_user_ids = list(set(user_ids)) + if self.creator.id in unique_user_ids: + unique_user_ids.remove(self.creator.id) + return unique_user_ids + + def send_new_thread_created_notification(self): + """ + Send notification based on notification_type + """ + thread_type = self.thread.attributes['thread_type'] + notification_type = ( + "new_question_post" + if thread_type == "question" + else ("new_discussion_post" if thread_type == "discussion" else "") + ) + if notification_type not in ['new_discussion_post', 'new_question_post']: + raise ValueError(f'Invalid notification type {notification_type}') + + user_ids = self._create_cohort_course_audience() + context = { + 'username': self.creator.username, + 'post_title': self.thread.title + } + self._send_notification(user_ids, notification_type, context) + + +def is_discussion_cohorted(course_key_str): + """ + Returns if the discussion is divided by cohorts + """ + cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str) + discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) + return cohort_settings.is_cohorted and discussion_settings.always_divide_inline_discussions diff --git a/lms/djangoapps/discussion/rest_api/tasks.py b/lms/djangoapps/discussion/rest_api/tasks.py index 85de47286f..f5bb20c3d8 100644 --- a/lms/djangoapps/discussion/rest_api/tasks.py +++ b/lms/djangoapps/discussion/rest_api/tasks.py @@ -8,7 +8,7 @@ from opaque_keys.edx.locator import CourseKey from lms.djangoapps.courseware.courses import get_course_with_access from openedx.core.djangoapps.django_comment_common.comment_client.thread import Thread from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS -from lms.djangoapps.discussion.rest_api.utils import DiscussionNotificationSender +from lms.djangoapps.discussion.rest_api.discussions_notifications import DiscussionNotificationSender User = get_user_model() @@ -46,3 +46,4 @@ def send_response_notifications(thread_id, course_key_str, user_id, parent_id=No notification_sender.send_new_comment_notification() notification_sender.send_new_response_notification() notification_sender.send_new_comment_on_response_notification() + notification_sender.send_response_on_followed_post_notification() diff --git a/lms/djangoapps/discussion/rest_api/tests/test_api.py b/lms/djangoapps/discussion/rest_api/tests/test_api.py index 34e323c723..98c27feb58 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_api.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_api.py @@ -2156,6 +2156,7 @@ class CreateThreadTest( @disable_signal(api, 'comment_created') @disable_signal(api, 'comment_voted') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock()) class CreateCommentTest( ForumsEnableMixin, CommentsServiceMockMixin, @@ -2194,6 +2195,17 @@ class CreateCommentTest( "raw_body": "Test body", } + mock_response = { + 'collection': [], + 'page': 1, + 'num_pages': 1, + 'subscriptions_count': 1, + 'corrected_text': None + + } + self.register_get_subscriptions('cohort_thread', mock_response) + self.register_get_subscriptions('test_thread', mock_response) + @ddt.data(None, "test_parent") @mock.patch("eventtracking.tracker.emit") def test_success(self, parent_id, mock_emit): @@ -2697,7 +2709,8 @@ class UpdateThreadTest( @ddt.data(*itertools.product([True, False], [True, False])) @ddt.unpack - def test_following(self, old_following, new_following): + @mock.patch("eventtracking.tracker.emit") + def test_following(self, old_following, new_following, mock_emit): """ Test attempts to edit the "following" field. @@ -2727,6 +2740,13 @@ class UpdateThreadTest( ) request_data.pop("request_id", None) assert request_data == {'source_type': ['thread'], 'source_id': ['test_thread']} + event_name, event_data = mock_emit.call_args[0] + expected_event_action = 'followed' if new_following else 'unfollowed' + assert event_name == f'edx.forum.thread.{expected_event_action}' + assert event_data['commentable_id'] == 'original_topic' + assert event_data['id'] == 'test_thread' + assert event_data['followed'] == new_following + assert event_data['user_forums_roles'] == ['Student'] @ddt.data(*itertools.product([True, False], [True, False])) @ddt.unpack diff --git a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py index e7bc03c0ad..896e237f5f 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_tasks.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_tasks.py @@ -2,11 +2,14 @@ Test cases for tasks.py """ from unittest import mock -from django.conf import settings -from edx_toggles.toggles.testutils import override_waffle_flag +from unittest.mock import Mock import ddt import httpretty +from django.conf import settings +from edx_toggles.toggles.testutils import override_waffle_flag +from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED + from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import StaffFactory, UserFactory from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory @@ -14,18 +17,19 @@ from lms.djangoapps.discussion.rest_api.tasks import send_response_notifications from lms.djangoapps.discussion.rest_api.tests.utils import ThreadMock, make_minimal_cs_thread from openedx.core.djangoapps.course_groups.models import CohortMembership, CourseCohortsSettings from openedx.core.djangoapps.course_groups.tests.helpers import CohortFactory +from openedx.core.djangoapps.discussions.models import DiscussionTopicLink from openedx.core.djangoapps.django_comment_common.models import ( - CourseDiscussionSettings, FORUM_ROLE_COMMUNITY_TA, FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_STUDENT, + CourseDiscussionSettings ) -from openedx.core.djangoapps.discussions.models import DiscussionTopicLink from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS -from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory + +from ..discussions_notifications import DiscussionNotificationSender from .test_views import DiscussionAPIViewTestMixin @@ -241,6 +245,7 @@ class TestNewThreadCreatedNotification(DiscussionAPIViewTestMixin, ModuleStoreTe self.assert_users_id_list(user_ids_list, handler.call_args[1]['notification_data'].user_ids) +@ddt.ddt @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """ @@ -272,6 +277,7 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC "thread_type": 'discussion', "title": thread.title, }) + self._register_subscriptions_endpoint() def test_basic(self): """ @@ -293,8 +299,8 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC # Post the form or do what it takes to send the signal send_response_notifications(self.thread.id, str(self.course.id), self.user_2.id, parent_id=None) - self.assertEqual(handler.call_count, 1) - args = handler.call_args[1]['notification_data'] + self.assertEqual(handler.call_count, 2) + args = handler.call_args_list[0][1]['notification_data'] self.assertEqual([int(user_id) for user_id in args.user_ids], [self.user_1.id]) self.assertEqual(args.notification_type, 'new_response') expected_context = { @@ -363,13 +369,13 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC def test_no_signal_on_creators_own_thread(self): """ - Makes sure that no signal is emitted if user creates response on + Makes sure that 1 signal is emitted if user creates response on their own thread. """ handler = mock.Mock() USER_NOTIFICATION_REQUESTED.connect(handler) send_response_notifications(self.thread.id, str(self.course.id), self.user_1.id, parent_id=None) - self.assertEqual(handler.call_count, 0) + self.assertEqual(handler.call_count, 1) def test_comment_creators_own_response(self): """ @@ -387,7 +393,7 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC send_response_notifications(self.thread.id, str(self.course.id), self.user_3.id, parent_id=self.thread_2.id) # check if 1 call is made to the handler i.e. for the thread creator - self.assertEqual(handler.call_count, 1) + self.assertEqual(handler.call_count, 2) # check if the notification is sent to the thread creator args_comment = handler.call_args_list[0][1]['notification_data'] @@ -406,6 +412,71 @@ class TestSendResponseNotifications(DiscussionAPIViewTestMixin, ModuleStoreTestC ) self.assertEqual(args_comment.app_name, 'discussion') + @ddt.data( + (None, 'response_on_followed_post'), (1, 'comment_on_followed_post') + ) + @ddt.unpack + def test_send_notification_to_followers(self, parent_id, notification_type): + """ + Test that the notification is sent to the followers of the thread + """ + self.register_get_comment_response({ + 'id': self.thread.id, + 'thread_id': self.thread.id, + 'user_id': self.thread.user_id + }) + handler = Mock() + USER_NOTIFICATION_REQUESTED.connect(handler) + + # Post the form or do what it takes to send the signal + notification_sender = DiscussionNotificationSender(self.thread, self.course, self.user_2, parent_id=parent_id) + notification_sender.send_response_on_followed_post_notification() + self.assertEqual(handler.call_count, 1) + args = handler.call_args[1]['notification_data'] + # only sent to user_3 because user_2 is the one who created the response + self.assertEqual([self.user_3.id], args.user_ids) + self.assertEqual(args.notification_type, notification_type) + expected_context = { + 'replier_name': self.user_2.username, + 'post_title': 'test thread', + 'course_name': self.course.display_name, + } + if parent_id: + expected_context['author_name'] = 'dummy' + self.assertDictEqual(args.context, expected_context) + self.assertEqual( + args.content_url, + _get_mfe_url(self.course.id, self.thread.id) + ) + self.assertEqual(args.app_name, 'discussion') + + def _register_subscriptions_endpoint(self): + """ + Registers the endpoint for the subscriptions API + """ + mock_response = { + 'collection': [ + { + '_id': 1, + 'subscriber_id': str(self.user_2.id), + "source_id": self.thread.id, + "source_type": "thread", + }, + { + '_id': 2, + 'subscriber_id': str(self.user_3.id), + "source_id": self.thread.id, + "source_type": "thread", + }, + ], + 'page': 1, + 'num_pages': 1, + 'subscriptions_count': 2, + 'corrected_text': None + + } + self.register_get_subscriptions(self.thread.id, mock_response) + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCase): @@ -456,6 +527,7 @@ class TestSendCommentNotification(DiscussionAPIViewTestMixin, ModuleStoreTestCas 'thread_id': thread.id, 'user_id': response.user_id }) + self.register_get_subscriptions(1, {}) send_response_notifications(thread.id, str(self.course.id), self.user_2.id, parent_id=response.id) handler.assert_called_once() context = handler.call_args[1]['notification_data'].context diff --git a/lms/djangoapps/discussion/rest_api/tests/test_utils.py b/lms/djangoapps/discussion/rest_api/tests/test_utils.py index a0e7790cba..db24847a82 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_utils.py @@ -2,29 +2,29 @@ Tests for Discussion REST API utils. """ +import unittest from datetime import datetime, timedelta import ddt from pytz import UTC -import unittest -from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole -from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin -from lms.djangoapps.discussion.rest_api.tests.utils import CommentsServiceMockMixin -from openedx.core.djangoapps.discussions.models import PostingRestriction, DiscussionsConfiguration -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from xmodule.modulestore.tests.factories import CourseFactory +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.discussion.django_comment_client.tests.factories import RoleFactory +from lms.djangoapps.discussion.django_comment_client.tests.utils import ForumsEnableMixin +from lms.djangoapps.discussion.rest_api.tests.utils import CommentsServiceMockMixin from lms.djangoapps.discussion.rest_api.utils import ( discussion_open_for_user, - get_course_ta_users_list, - get_course_staff_users_list, - get_moderator_users_list, get_archived_topics, - remove_empty_sequentials, - is_posting_allowed + get_course_staff_users_list, + get_course_ta_users_list, + get_moderator_users_list, + is_posting_allowed, + remove_empty_sequentials ) +from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory class DiscussionAPIUtilsTestCase(ModuleStoreTestCase): diff --git a/lms/djangoapps/discussion/rest_api/tests/test_views.py b/lms/djangoapps/discussion/rest_api/tests/test_views.py index 0e303cc240..7f6fa57725 100644 --- a/lms/djangoapps/discussion/rest_api/tests/test_views.py +++ b/lms/djangoapps/discussion/rest_api/tests/test_views.py @@ -87,7 +87,7 @@ class DiscussionAPIViewTestMixin(ForumsEnableMixin, CommentsServiceMockMixin, Ur start=datetime.now(UTC), discussion_topics={"Test Topic": {"id": "test_topic"}} ) - self.password = "password" + self.password = "Password1234" self.user = UserFactory.create(password=self.password) # Ensure that parental controls don't apply to this user self.user.profile.year_of_birth = 1970 @@ -168,7 +168,7 @@ class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMi content_type="image/jpeg", ), } - self.user = UserFactory.create(password="password") + self.user = UserFactory.create(password=self.TEST_PASSWORD) self.course = CourseFactory.create(org='a', course='b', run='c', start=datetime.now(UTC)) self.url = reverse("upload_file", kwargs={"course_id": str(self.course.id)}) @@ -176,7 +176,7 @@ class UploadFileViewTest(ForumsEnableMixin, CommentsServiceMockMixin, UrlResetMi """ Authenticates the test client with the example user. """ - self.client.login(username=self.user.username, password="password") + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) def enroll_user_in_course(self): """ @@ -320,10 +320,10 @@ class CommentViewSetListByUserTest( self.addCleanup(httpretty.reset) self.addCleanup(httpretty.disable) - self.user = UserFactory.create(password="password") + self.user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.user) - self.other_user = UserFactory.create(password="password") + self.other_user = UserFactory.create(password=self.TEST_PASSWORD) self.register_get_user_response(self.other_user) self.course = CourseFactory.create(org="a", course="b", run="c", start=datetime.now(UTC)) @@ -405,7 +405,7 @@ class CommentViewSetListByUserTest( they're not either enrolled or staff members. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password="password") + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) assert response.status_code == status.HTTP_404_NOT_FOUND assert json.loads(response.content)["developer_message"] == "Course not found." @@ -416,7 +416,7 @@ class CommentViewSetListByUserTest( comments in that course. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password="password") + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) CourseEnrollmentFactory.create(user=self.other_user, course_id=self.course.id) self.assert_successful_response(self.client.get(self.url)) @@ -425,7 +425,7 @@ class CommentViewSetListByUserTest( Staff users are allowed to get any user's comments. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password="password") + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) self.assert_successful_response(self.client.get(self.url)) @@ -436,7 +436,7 @@ class CommentViewSetListByUserTest( course. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password="password") + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) role(course_key=self.course.id).add_users(self.other_user) self.assert_successful_response(self.client.get(self.url)) @@ -445,7 +445,7 @@ class CommentViewSetListByUserTest( Requests for users that don't exist result in a 404 response. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password="password") + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url("non_existent", self.course.id) response = self.client.get(url) @@ -456,7 +456,7 @@ class CommentViewSetListByUserTest( Requests for courses that don't exist result in a 404 response. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password="password") + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, "course-v1:x+y+z") response = self.client.get(url) @@ -467,7 +467,7 @@ class CommentViewSetListByUserTest( Requests with invalid course ID should fail form validation. """ self.register_mock_endpoints() - self.client.login(username=self.other_user.username, password="password") + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, "an invalid course") response = self.client.get(url) @@ -484,7 +484,7 @@ class CommentViewSetListByUserTest( self.register_get_threads_response(threads=[], page=1, num_pages=1) self.register_get_comments_response(comments=[], page=1, num_pages=1) - self.client.login(username=self.other_user.username, password="password") + self.client.login(username=self.other_user.username, password=self.TEST_PASSWORD) GlobalStaff().add_users(self.other_user) url = self.build_url(self.user.username, self.course.id, page=2) response = self.client.get(url) @@ -929,7 +929,7 @@ class CourseTopicsViewV3Test(DiscussionAPIViewTestMixin, CommentsServiceMockMixi """ def setUp(self) -> None: super().setUp() - self.password = "password" + self.password = self.TEST_PASSWORD self.user = UserFactory.create(password=self.password) self.client.login(username=self.user.username, password=self.password) self.staff = AdminFactory.create() @@ -2357,6 +2357,7 @@ class CommentViewSetDeleteTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): @httpretty.activate @disable_signal(api, 'comment_created') @mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) +@mock.patch("lms.djangoapps.discussion.signals.handlers.send_response_notifications", new=mock.Mock()) class CommentViewSetCreateTest(DiscussionAPIViewTestMixin, ModuleStoreTestCase): """Tests for CommentViewSet create""" def setUp(self): @@ -2758,7 +2759,7 @@ class CourseDiscussionSettingsAPIViewTest(APITestCase, UrlResetMixin, ModuleStor discussion_topics={"Test Topic": {"id": "test_topic"}} ) self.path = reverse('discussion_course_settings', kwargs={'course_id': str(self.course.id)}) - self.password = 'edx' + self.password = self.TEST_PASSWORD self.user = UserFactory(username='staff', password=self.password, is_staff=True) def _get_oauth_headers(self, user): @@ -3056,7 +3057,7 @@ class CourseDiscussionRolesAPIViewTest(APITestCase, UrlResetMixin, ModuleStoreTe run="z", start=datetime.now(UTC), ) - self.password = 'edx' + self.password = self.TEST_PASSWORD self.user = UserFactory(username='staff', password=self.password, is_staff=True) course_key = CourseKey.from_string('course-v1:x+y+z') seed_permissions_roles(course_key) @@ -3264,7 +3265,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM user = UserFactory.create( username=stat['username'], email=f"{stat['username']}@example.com", - password='12345' + password=self.TEST_PASSWORD ) CourseEnrollment.enroll(user, self.course.id, mode='audit') @@ -3278,7 +3279,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM """ Tests that for a regular user stats are returned without flag counts """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) data = response.json() assert data["results"] == self.stats_without_flags @@ -3288,7 +3289,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM """ Tests that for a moderator user stats are returned with flag counts """ - self.client.login(username=self.moderator.username, password='test') + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) data = response.json() assert data["results"] == self.stats @@ -3308,7 +3309,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM """ Test valid sorting options and defaults """ - self.client.login(username=username, password='test') + self.client.login(username=username, password=self.TEST_PASSWORD) params = {} if ordering_requested: params = {"order_by": ordering_requested} @@ -3326,7 +3327,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM """ Test for invalid sorting options for regular users. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url, {"order_by": order_by}) assert "order_by" in response.json()["field_errors"] @@ -3341,7 +3342,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM Test for endpoint with username param. """ params = {'username': username_search_string} - self.client.login(username=self.moderator.username, password='test') + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) self.client.get(self.url, params) assert urlparse( httpretty.last_request().path # lint-amnesty, pylint: disable=no-member @@ -3356,7 +3357,7 @@ class CourseActivityStatsTest(ForumsEnableMixin, UrlResetMixin, CommentsServiceM Test for endpoint with username param with no matches. """ params = {'username': 'unknown'} - self.client.login(username=self.moderator.username, password='test') + self.client.login(username=self.moderator.username, password=self.TEST_PASSWORD) response = self.client.get(self.url, params) data = response.json() self.assertFalse(data['results']) diff --git a/lms/djangoapps/discussion/rest_api/tests/utils.py b/lms/djangoapps/discussion/rest_api/tests/utils.py index 5db131bec8..39dddcf33f 100644 --- a/lms/djangoapps/discussion/rest_api/tests/utils.py +++ b/lms/djangoapps/discussion/rest_api/tests/utils.py @@ -425,6 +425,18 @@ class CommentsServiceMockMixin: status=200 ) + def register_get_subscriptions(self, thread_id, response): + """ + Register a mock response for GET on the CS comment active threads endpoint + """ + assert httpretty.is_enabled(), 'httpretty must be enabled to mock calls.' + httpretty.register_uri( + httpretty.GET, + f"http://localhost:4567/api/v1/threads/{thread_id}/subscriptions", + body=json.dumps(response), + status=200 + ) + def assert_query_params_equal(self, httpretty_request, expected_params): """ Assert that the given mock request had the expected query parameters diff --git a/lms/djangoapps/discussion/rest_api/utils.py b/lms/djangoapps/discussion/rest_api/utils.py index 0b84cf2ac6..e7dca49910 100644 --- a/lms/djangoapps/discussion/rest_api/utils.py +++ b/lms/djangoapps/discussion/rest_api/utils.py @@ -2,33 +2,23 @@ Utils for discussion API. """ from datetime import datetime -from pytz import UTC -from typing import List, Dict +from typing import Dict, List -from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.core.paginator import Paginator from django.db.models.functions import Length -from django.utils.translation import gettext_lazy as _ +from pytz import UTC -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.student.roles import CourseStaffRole, CourseInstructorRole -from lms.djangoapps.discussion.django_comment_client.permissions import get_team +from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole from lms.djangoapps.discussion.django_comment_client.utils import has_discussion_privileges -from openedx.core.djangoapps.course_groups.models import CourseCohortsSettings, CourseUserGroup from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, PostingRestriction -from openedx.core.djangoapps.discussions.utils import get_divided_discussions from openedx.core.djangoapps.django_comment_common.models import ( - Role, - CourseDiscussionSettings, FORUM_ROLE_ADMINISTRATOR, - FORUM_ROLE_MODERATOR, - FORUM_ROLE_GROUP_MODERATOR, FORUM_ROLE_COMMUNITY_TA, + FORUM_ROLE_GROUP_MODERATOR, + FORUM_ROLE_MODERATOR, + Role ) -from openedx_events.learning.signals import USER_NOTIFICATION_REQUESTED -from openedx_events.learning.data import UserNotificationData -from openedx.core.djangoapps.django_comment_common.comment_client.comment import Comment class AttributeDict(dict): @@ -371,196 +361,6 @@ def get_archived_topics(filtered_topic_ids: List[str], topics: List[Dict[str, st return archived_topics -def is_discussion_cohorted(course_key_str): - """ - Returns if the discussion is divided by cohorts - """ - cohort_settings = CourseCohortsSettings.objects.get(course_id=course_key_str) - discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) - return cohort_settings.is_cohorted and discussion_settings.always_divide_inline_discussions - - -class DiscussionNotificationSender: - """ - Class to send notifications to users who are subscribed to the thread. - """ - - def __init__(self, thread, course, creator, parent_id=None): - self.thread = thread - self.course = course - self.creator = creator - self.parent_id = parent_id - self.parent_response = None - self._get_parent_response() - - def _send_notification(self, user_ids, notification_type, extra_context=None): - """ - Send notification to users - """ - if not user_ids: - return - - if extra_context is None: - extra_context = {} - - notification_data = UserNotificationData( - user_ids=[int(user_id) for user_id in user_ids], - context={ - "replier_name": self.creator.username, - "post_title": self.thread.title, - "course_name": self.course.display_name, - **extra_context, - }, - notification_type=notification_type, - content_url=f"{settings.DISCUSSIONS_MICROFRONTEND_URL}/{str(self.course.id)}/posts/{self.thread.id}", - app_name="discussion", - course_key=self.course.id, - ) - USER_NOTIFICATION_REQUESTED.send_event(notification_data=notification_data) - - def _get_parent_response(self): - """ - Get parent response object - """ - if self.parent_id and not self.parent_response: - self.parent_response = Comment(id=self.parent_id).retrieve() - - return self.parent_response - - def _create_cohort_course_audience(self): - """ - Creates audience based on user cohort and role - """ - course_key_str = str(self.course.id) - discussion_cohorted = is_discussion_cohorted(course_key_str) - - # Retrieves cohort divided discussion - discussion_settings = CourseDiscussionSettings.objects.get(course_id=course_key_str) - divided_course_wide_discussions, divided_inline_discussions = get_divided_discussions( - self.course, - discussion_settings - ) - - # Checks if post has any cohort assigned - group_id = self.thread.attributes['group_id'] - if group_id is not None: - group_id = int(group_id) - - # Course wide topics - topic_id = self.thread.attributes['commentable_id'] - all_topics = divided_inline_discussions + divided_course_wide_discussions - topic_divided = topic_id in all_topics or discussion_settings.always_divide_inline_discussions - - # Team object from topic id - team = get_team(topic_id) - - user_ids = [] - if team: - user_ids = team.users.all().values_list('id', flat=True) - elif discussion_cohorted and topic_divided and group_id is not None: - users_in_cohort = CourseUserGroup.objects.filter( - course_id=course_key_str, id=group_id - ).values_list('users__id', flat=True) - user_ids.extend(users_in_cohort) - - privileged_roles = [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA] - privileged_users = Role.objects.filter( - name__in=privileged_roles, - course_id=course_key_str - ).values_list('users__id', flat=True) - user_ids.extend(privileged_users) - - staff_users = CourseStaffRole(self.course.id).users_with_role().values_list('id', flat=True) - user_ids.extend(staff_users) - - admin_users = CourseInstructorRole(self.course.id).users_with_role().values_list('id', flat=True) - user_ids.extend(admin_users) - else: - user_ids = CourseEnrollment.objects.filter( - course__id=course_key_str, is_active=True - ).values_list('user__id', flat=True) - - unique_user_ids = list(set(user_ids)) - if self.creator.id in unique_user_ids: - unique_user_ids.remove(self.creator.id) - return unique_user_ids - - def send_new_response_notification(self): - """ - Send notification to users who are subscribed to the main thread/post i.e. - there is a response to the main thread. - """ - if not self.parent_id and self.creator.id != int(self.thread.user_id): - self._send_notification([self.thread.user_id], "new_response") - - def _response_and_thread_has_same_creator(self) -> bool: - """ - Check if response and main thread have same author. - """ - return int(self.parent_response.user_id) == int(self.thread.user_id) - - def _response_and_comment_has_same_creator(self): - return int(self.parent_response.attributes['user_id']) == self.creator.id - - def send_new_comment_notification(self): - """ - Send notification to parent thread creator i.e. comment on the response. - """ - if ( - self.parent_response and - self.creator.id != int(self.thread.user_id) - ): - # use your if author of response is same as author of post. - # use 'their' if comment author is also response author. - author_name = ( - # Translators: Replier commented on "your" response to your post - _("your") - if self._response_and_thread_has_same_creator() - else ( - # Translators: Replier commented on "their" response to your post - _("their") - if self._response_and_comment_has_same_creator() - else f"{self.parent_response.username}'s" - ) - ) - context = { - "author_name": str(author_name), - } - self._send_notification([self.thread.user_id], "new_comment", extra_context=context) - - def send_new_comment_on_response_notification(self): - """ - Send notification to parent response creator i.e. comment on the response. - Do not send notification if author of response is same as author of post. - """ - if ( - self.parent_response and - self.creator.id != int(self.parent_response.user_id) and not - self._response_and_thread_has_same_creator() - ): - self._send_notification([self.parent_response.user_id], "new_comment_on_response") - - def send_new_thread_created_notification(self): - """ - Send notification based on notification_type - """ - thread_type = self.thread.attributes['thread_type'] - notification_type = ( - "new_question_post" - if thread_type == "question" - else ("new_discussion_post" if thread_type == "discussion" else "") - ) - if notification_type not in ['new_discussion_post', 'new_question_post']: - raise ValueError(f'Invalid notification type {notification_type}') - - user_ids = self._create_cohort_course_audience() - context = { - 'username': self.creator.username, - 'post_title': self.thread.title - } - self._send_notification(user_ids, notification_type, context) - - def is_posting_allowed(posting_restrictions: str, blackout_schedules: List): """ Check if posting is allowed based on the given posting restrictions and blackout schedules. diff --git a/lms/djangoapps/discussion/tests/test_views.py b/lms/djangoapps/discussion/tests/test_views.py index ada3db7792..e0d3b869da 100644 --- a/lms/djangoapps/discussion/tests/test_views.py +++ b/lms/djangoapps/discussion/tests/test_views.py @@ -463,7 +463,7 @@ class SingleThreadTestCase(ForumsEnableMixin, ModuleStoreTestCase): # lint-amne CourseTeamFactory.create(discussion_topic_id=discussion_topic_id) user_not_in_team = UserFactory.create() CourseEnrollmentFactory.create(user=user_not_in_team, course_id=self.course.id) - self.client.login(username=user_not_in_team.username, password='test') + self.client.login(username=user_not_in_team.username, password=self.TEST_PASSWORD) mock_request.side_effect = make_mock_request_impl( course=self.course, @@ -624,7 +624,7 @@ class SingleCohortedThreadTestCase(CohortedTestCase): # lint-amnesty, pylint: d def test_html(self, mock_request): _mock_text, mock_thread_id = self._create_mock_cohorted_thread(mock_request) - self.client.login(username=self.student.username, password='test') + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) response = self.client.get( reverse('single_thread', kwargs={ 'course_id': str(self.course.id), @@ -758,7 +758,7 @@ class SingleThreadGroupIdTestCase(CohortedTestCase, GroupIdAssertionMixin): # l if is_ajax: headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get( reverse('single_thread', args=[str(self.course.id), commentable_id, "dummy_thread_id"]), @@ -824,7 +824,7 @@ class ForumFormDiscussionContentGroupTestCase(ForumsEnableMixin, ContentGroupTes text="dummy content", thread_list=self.thread_list ) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get( reverse("forum_form_discussion", args=[str(self.course.id)]), HTTP_X_REQUESTED_WITH="XMLHttpRequest" @@ -889,7 +889,7 @@ class SingleThreadContentGroupTestCase(ForumsEnableMixin, UrlResetMixin, Content verify that the user does not have access to that thread. """ def call_single_thread(): - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get( reverse('single_thread', args=[str(self.course.id), discussion_id, thread_id]) ) @@ -1113,7 +1113,7 @@ class ForumFormDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupIdT if is_ajax: headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) return self.client.get( reverse("forum_form_discussion", args=[str(self.course.id)]), data=request_data, @@ -1165,7 +1165,7 @@ class UserProfileDiscussionGroupIdTestCase(CohortedTestCase, CohortedTopicGroupI if is_ajax: headers['HTTP_X_REQUESTED_WITH'] = "XMLHttpRequest" - self.client.login(username=requesting_user.username, password='test') + self.client.login(username=requesting_user.username, password=self.TEST_PASSWORD) return self.client.get( reverse('user_profile', args=[str(self.course.id), profiled_user.id]), data=request_data, @@ -1414,7 +1414,7 @@ class UserProfileTestCase(ForumsEnableMixin, UrlResetMixin, ModuleStoreTestCase) mock_request.side_effect = make_mock_request_impl( course=self.course, text=self.TEST_THREAD_TEXT, thread_id=self.TEST_THREAD_ID ) - self.client.login(username=self.student.username, password='test') + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) response = self.client.get( reverse('user_profile', kwargs={ @@ -2300,7 +2300,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase): """ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) url = reverse("forum_form_discussion", args=[self.course.id]) response = self.client.get(url) assert response.status_code == 302 @@ -2315,7 +2315,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase): """ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) url = reverse("user_profile", args=[self.course.id, self.user.id]) response = self.client.get(url) assert response.status_code == 302 @@ -2330,7 +2330,7 @@ class ForumMFETestCase(ForumsEnableMixin, SharedModuleStoreTestCase): """ with override_waffle_flag(ENABLE_DISCUSSIONS_MFE, True): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) url = reverse("single_thread", args=[self.course.id, "test_discussion", "test_thread"]) response = self.client.get(url) assert response.status_code == 302 diff --git a/lms/djangoapps/grades/rest_api/v1/tests/mixins.py b/lms/djangoapps/grades/rest_api/v1/tests/mixins.py index e5e7f269e3..1d3a6a0b45 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/mixins.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/mixins.py @@ -97,7 +97,7 @@ class GradeViewTestMixin(SharedModuleStoreTestCase): def setUp(self): super().setUp() - self.password = 'test' + self.password = self.TEST_PASSWORD self.global_staff = GlobalStaffFactory.create() self.student = UserFactory(password=self.password, username='student', email='student@example.com') self.other_student = UserFactory( diff --git a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py index 1fbfb6070d..cd2107ec7c 100644 --- a/lms/djangoapps/grades/rest_api/v1/tests/test_views.py +++ b/lms/djangoapps/grades/rest_api/v1/tests/test_views.py @@ -548,7 +548,7 @@ class CourseSubmissionHistoryWithDataTest(TestSubmittingProblems): def setUp(self): super().setUp() self.namespaced_url = 'grades_api:v1:submission_history' - self.password = 'test' + self.password = self.TEST_PASSWORD self.basic_setup() self.global_staff = GlobalStaffFactory.create() diff --git a/lms/djangoapps/grades/signals/handlers.py b/lms/djangoapps/grades/signals/handlers.py index af33279515..58c5fb90aa 100644 --- a/lms/djangoapps/grades/signals/handlers.py +++ b/lms/djangoapps/grades/signals/handlers.py @@ -8,6 +8,7 @@ from logging import getLogger from django.dispatch import receiver from opaque_keys.edx.keys import LearningContextKey +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, EXAM_ATTEMPT_VERIFIED from submissions.models import score_reset, score_set from xblock.scorable import ScorableXBlockMixin, Score @@ -25,7 +26,7 @@ from openedx.core.djangoapps.course_groups.signals.signals import COHORT_MEMBERS from openedx.core.lib.grade_utils import is_score_higher_or_equal from .. import events -from ..constants import ScoreDatabaseTableEnum +from ..constants import GradeOverrideFeatureEnum, ScoreDatabaseTableEnum from ..course_grade_factory import CourseGradeFactory from ..scores import weighted_score from .signals import ( @@ -122,6 +123,10 @@ def submissions_score_reset_handler(sender, **kwargs): # pylint: disable=unused def disconnect_submissions_signal_receiver(signal): """ Context manager to be used for temporarily disconnecting edx-submission's set or reset signal. + + Clear Student State on ORA problems currently results in a set->reset signal pair getting fired + from submissions which leads to tasks being enqueued, one of which can never succeed. This context manager + fixes the issue by disconnecting the "set" handler during the clear_state operation. """ if signal == score_set: handler = submissions_score_set_handler @@ -300,3 +305,45 @@ def listen_for_course_grade_passed_first_time(sender, user_id, course_id, **kwar """ events.course_grade_passed_first_time(user_id, course_id) events.fire_segment_event_on_course_grade_passed_first_time(user_id, course_id) + + +@receiver(EXAM_ATTEMPT_VERIFIED) +def exam_attempt_verified_event_handler(sender, signal, **kwargs): # pylint: disable=unused-argument + """ + Consume `EXAM_ATTEMPT_VERIFIED` events from the event bus. This will trigger + an undo section override, if one exists. + """ + from ..api import should_override_grade_on_rejected_exam, undo_override_subsection_grade + + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + course_key = event_data.course_key + usage_key = event_data.usage_key + + if should_override_grade_on_rejected_exam(course_key): + undo_override_subsection_grade(user_data.id, course_key, usage_key, GradeOverrideFeatureEnum.proctoring) + + +@receiver(EXAM_ATTEMPT_REJECTED) +def exam_attempt_rejected_event_handler(sender, signal, **kwargs): # pylint: disable=unused-argument + """ + Consume `EXAM_ATTEMPT_REJECTED` events from the event bus. This will trigger a subsection override. + """ + from ..api import override_subsection_grade + + event_data = kwargs.get('exam_attempt') + override_grade_value = 0.0 + user_data = event_data.student_user + course_key = event_data.course_key + usage_key = event_data.usage_key + + override_subsection_grade( + user_data.id, + course_key, + usage_key, + earned_all=override_grade_value, + earned_graded=override_grade_value, + feature=GradeOverrideFeatureEnum.proctoring, + overrider=None, + comment=None, + ) diff --git a/lms/djangoapps/grades/tests/integration/test_access.py b/lms/djangoapps/grades/tests/integration/test_access.py index 380962b19e..6545094a3c 100644 --- a/lms/djangoapps/grades/tests/integration/test_access.py +++ b/lms/djangoapps/grades/tests/integration/test_access.py @@ -73,9 +73,9 @@ class GradesAccessIntegrationTest(ProblemSubmissionTestMixin, SharedModuleStoreT self.addCleanup(set_current_request, None) self.request = get_mock_request(UserFactory()) self.student = self.request.user - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) CourseEnrollment.enroll(self.student, self.course.id) - self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password='test') + self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password=self.TEST_PASSWORD) self.refresh_course() def test_subsection_access_changed(self): diff --git a/lms/djangoapps/grades/tests/integration/test_events.py b/lms/djangoapps/grades/tests/integration/test_events.py index 868b55eeae..881ad049b3 100644 --- a/lms/djangoapps/grades/tests/integration/test_events.py +++ b/lms/djangoapps/grades/tests/integration/test_events.py @@ -70,9 +70,9 @@ class GradesEventIntegrationTest(ProblemSubmissionTestMixin, SharedModuleStoreTe self.addCleanup(set_current_request, None) self.request = get_mock_request(UserFactory()) self.student = self.request.user - self.client.login(username=self.student.username, password="test") + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) CourseEnrollment.enroll(self.student, self.course.id) - self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password='test') + self.instructor = UserFactory.create(is_staff=True, username='test_instructor', password=self.TEST_PASSWORD) self.refresh_course() @patch('lms.djangoapps.grades.events.tracker') diff --git a/lms/djangoapps/grades/tests/integration/test_problems.py b/lms/djangoapps/grades/tests/integration/test_problems.py index 36c54bec86..b4a2d06c9f 100644 --- a/lms/djangoapps/grades/tests/integration/test_problems.py +++ b/lms/djangoapps/grades/tests/integration/test_problems.py @@ -43,9 +43,8 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase): def setUp(self): super().setUp() - password = 'test' - self.student = UserFactory.create(is_staff=False, username='test_student', password=password) - self.client.login(username=self.student.username, password=password) + self.student = UserFactory.create(is_staff=False, username='test_student', password=self.TEST_PASSWORD) + self.client.login(username=self.student.username, password=self.TEST_PASSWORD) self.addCleanup(set_current_request, None) self.request = get_mock_request(self.student) self.course_structure = get_course_blocks(self.student, self.course.location) @@ -58,8 +57,7 @@ class TestMultipleProblemTypesSubsectionScores(SharedModuleStoreTestCase): For details on the contents and structure of the file, see `common/test/data/scoreable/README`. """ - password = 'test' - user = UserFactory.create(is_staff=False, username='test_student', password=password) + user = UserFactory.create(is_staff=False, username='test_student', password=cls.TEST_PASSWORD) course_items = import_course_from_xml( cls.store, @@ -144,7 +142,7 @@ class TestVariedMetadata(ProblemSubmissionTestMixin, ModuleStoreTestCase): ''' self.addCleanup(set_current_request, None) self.request = get_mock_request(UserFactory()) - self.client.login(username=self.request.user.username, password="test") + self.client.login(username=self.request.user.username, password=self.TEST_PASSWORD) CourseEnrollment.enroll(self.request.user, self.course.id) def _get_altered_metadata(self, alterations): diff --git a/lms/djangoapps/grades/tests/test_handlers.py b/lms/djangoapps/grades/tests/test_handlers.py new file mode 100644 index 0000000000..72424987b5 --- /dev/null +++ b/lms/djangoapps/grades/tests/test_handlers.py @@ -0,0 +1,133 @@ +""" +Tests for the grades handlers +""" +from datetime import datetime, timezone +from unittest import mock +from uuid import uuid4 + +import ddt +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData +from openedx_events.learning.signals import EXAM_ATTEMPT_REJECTED, EXAM_ATTEMPT_VERIFIED + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.grades.signals.handlers import ( + exam_attempt_rejected_event_handler, + exam_attempt_verified_event_handler +) +from ..constants import GradeOverrideFeatureEnum + + +@ddt.ddt +class ExamCompletionEventBusTests(TestCase): + """ + Tests for exam events from the event bus + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course') + cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection' + cls.usage_key = UsageKey.from_string(cls.subsection_id) + cls.student_user = UserFactory( + username='student_user', + ) + + @staticmethod + def _get_exam_event_data(student_user, course_key, usage_key, exam_type, requesting_user=None): + """ create ExamAttemptData object for exam based event """ + if requesting_user: + requesting_user_data = UserData( + id=requesting_user.id, + is_active=True, + pii=None + ) + else: + requesting_user_data = None + + return ExamAttemptData( + student_user=UserData( + id=student_user.id, + is_active=True, + pii=UserPersonalData( + username=student_user.username, + email=student_user.email, + ), + ), + course_key=course_key, + usage_key=usage_key, + requesting_user=requesting_user_data, + exam_type=exam_type, + ) + + @staticmethod + def _get_exam_event_metadata(event_signal): + """ create metadata object for event """ + return EventsMetadata( + event_type=event_signal.event_type, + id=uuid4(), + minorversion=0, + source='openedx/lms/web', + sourcehost='lms.test', + time=datetime.now(timezone.utc) + ) + + @ddt.data( + True, + False + ) + @mock.patch('lms.djangoapps.grades.api.should_override_grade_on_rejected_exam') + @mock.patch('lms.djangoapps.grades.api.undo_override_subsection_grade') + def test_exam_attempt_verified_event_handler(self, override_enabled, mock_undo_override, mock_should_override): + mock_should_override.return_value = override_enabled + + exam_event_data = self._get_exam_event_data(self.student_user, + self.course_key, + self.usage_key, + exam_type='proctored') + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_VERIFIED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + exam_attempt_verified_event_handler(None, EXAM_ATTEMPT_VERIFIED, ** event_kwargs) + + if override_enabled: + mock_undo_override.assert_called_once_with( + self.student_user.id, + self.course_key, + self.usage_key, + GradeOverrideFeatureEnum.proctoring + ) + else: + mock_undo_override.assert_not_called() + + @mock.patch('lms.djangoapps.grades.api.override_subsection_grade') + def test_exam_attempt_rejected_event_handler(self, mock_override): + exam_event_data = self._get_exam_event_data(self.student_user, + self.course_key, + self.usage_key, + exam_type='proctored') + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_REJECTED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + exam_attempt_rejected_event_handler(None, EXAM_ATTEMPT_REJECTED, ** event_kwargs) + + override_grade_value = 0.0 + + mock_override.assert_called_once_with( + self.student_user.id, + self.course_key, + self.usage_key, + earned_all=override_grade_value, + earned_graded=override_grade_value, + feature=GradeOverrideFeatureEnum.proctoring, + overrider=None, + comment=None, + ) diff --git a/lms/djangoapps/instructor/apps.py b/lms/djangoapps/instructor/apps.py index 898416585f..d22c8b0aa1 100644 --- a/lms/djangoapps/instructor/apps.py +++ b/lms/djangoapps/instructor/apps.py @@ -36,6 +36,7 @@ class InstructorConfig(AppConfig): } def ready(self): + from . import handlers # pylint: disable=unused-import,import-outside-toplevel if settings.FEATURES.get('ENABLE_SPECIAL_EXAMS'): from .services import InstructorService set_runtime_service('instructor', InstructorService()) diff --git a/lms/djangoapps/instructor/handlers.py b/lms/djangoapps/instructor/handlers.py new file mode 100644 index 0000000000..af740ee4fe --- /dev/null +++ b/lms/djangoapps/instructor/handlers.py @@ -0,0 +1,82 @@ +""" +Handlers for instructor +""" +import logging + +from django.contrib.auth import get_user_model +from django.core.exceptions import ObjectDoesNotExist +from django.dispatch import receiver +from openedx_events.learning.signals import EXAM_ATTEMPT_RESET, EXAM_ATTEMPT_SUBMITTED + +from lms.djangoapps.courseware.models import StudentModule +from lms.djangoapps.instructor import enrollment +from lms.djangoapps.instructor.tasks import update_exam_completion_task + +User = get_user_model() + +log = logging.getLogger(__name__) + + +@receiver(EXAM_ATTEMPT_SUBMITTED) +def handle_exam_completion(sender, signal, **kwargs): + """ + exam completion event from the event bus + """ + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + usage_key = event_data.usage_key + + update_exam_completion_task.apply_async((user_data.pii.username, str(usage_key), 1.0)) + + +@receiver(EXAM_ATTEMPT_RESET) +def handle_exam_reset(sender, signal, **kwargs): + """ + exam reset event from the event bus + """ + event_data = kwargs.get('exam_attempt') + user_data = event_data.student_user + requesting_user_data = event_data.requesting_user + usage_key = event_data.usage_key + course_key = event_data.course_key + content_id = str(usage_key) + + try: + student = User.objects.get(id=user_data.id) + except ObjectDoesNotExist: + log.error( + 'Error occurred while attempting to reset student attempt for user_id ' + f'{user_data.id} for content_id {content_id}. ' + 'User does not exist!' + ) + return + + try: + requesting_user = User.objects.get(id=requesting_user_data.id) + except ObjectDoesNotExist: + log.error( + 'Error occurred while attempting to reset student attempt. Requesting user_id ' + f'{requesting_user_data.id} does not exist!' + ) + return + + # reset problem state + try: + enrollment.reset_student_attempts( + course_key, + student, + usage_key, + requesting_user=requesting_user, + delete_module=True, + ) + except (StudentModule.DoesNotExist, enrollment.sub_api.SubmissionError): + log.error( + 'Error occurred while attempting to reset module state for user_id ' + f'{student.id} for content_id {content_id}.' + ) + + # In some cases, reset_student_attempts does not clear the entire exam's completion state. + # One example of this is an exam with multiple units (verticals) within it and the learner + # never viewing one of the units. All of the content in that unit will still be marked complete, + # but the reset code is unable to handle clearing the completion in that scenario. + update_exam_completion_task.apply_async((student.username, content_id, 0.0)) diff --git a/lms/djangoapps/instructor/permissions.py b/lms/djangoapps/instructor/permissions.py index 20426761e6..119ecf4e35 100644 --- a/lms/djangoapps/instructor/permissions.py +++ b/lms/djangoapps/instructor/permissions.py @@ -29,6 +29,8 @@ EMAIL = 'instructor.email' RESCORE_EXAMS = 'instructor.rescore_exams' VIEW_REGISTRATION = 'instructor.view_registration' VIEW_DASHBOARD = 'instructor.dashboard' +VIEW_ENROLLMENTS = 'instructor.view_enrollments' +VIEW_FORUM_MEMBERS = 'instructor.view_forum_members' perms[ALLOW_STUDENT_TO_BYPASS_ENTRANCE_EXAM] = HasAccessRule('staff') @@ -60,3 +62,5 @@ perms[VIEW_DASHBOARD] = \ 'instructor', 'data_researcher' ) | HasAccessRule('staff') | HasAccessRule('instructor') +perms[VIEW_ENROLLMENTS] = HasAccessRule('staff') +perms[VIEW_FORUM_MEMBERS] = HasAccessRule('staff') diff --git a/lms/djangoapps/instructor/tasks.py b/lms/djangoapps/instructor/tasks.py index ac3bda13a8..2ec19c35b8 100644 --- a/lms/djangoapps/instructor/tasks.py +++ b/lms/djangoapps/instructor/tasks.py @@ -5,14 +5,14 @@ import logging from celery import shared_task from celery_utils.logged_task import LoggedTask from django.core.exceptions import ObjectDoesNotExist +from edx_django_utils.monitoring import set_code_owner_attribute from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey from xblock.completable import XBlockCompletionMode -from edx_django_utils.monitoring import set_code_owner_attribute from common.djangoapps.student.models import get_user_by_username_or_email -from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block_for_descriptor +from lms.djangoapps.courseware.model_data import FieldDataCache from openedx.core.lib.request_utils import get_request_or_stub 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 diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 468bca31f7..bb534d9b02 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -325,7 +325,7 @@ class TestEndpointHttpMethods(SharedModuleStoreTestCase, LoginEnrollmentTestCase """ super().setUp() global_user = GlobalStaffFactory() - self.client.login(username=global_user.username, password='test') + self.client.login(username=global_user.username, password=self.TEST_PASSWORD) @ddt.data(*INSTRUCTOR_POST_ENDPOINTS) def test_endpoints_reject_get(self, data): @@ -478,7 +478,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest """ Ensure that an enrolled student can't access staff or instructor endpoints. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) for endpoint, args in self.staff_level_endpoints: self._access_endpoint( @@ -518,7 +518,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest CourseEnrollment.enroll(staff_member, self.course.id) CourseFinanceAdminRole(self.course.id).add_users(staff_member) CourseDataResearcherRole(self.course.id).add_users(staff_member) - self.client.login(username=staff_member.username, password='test') + self.client.login(username=staff_member.username, password=self.TEST_PASSWORD) # Try to promote to forums admin - not working # update_forum_role(self.course.id, staff_member, FORUM_ROLE_ADMINISTRATOR, 'allow') @@ -558,7 +558,7 @@ class TestInstructorAPIDenyLevels(SharedModuleStoreTestCase, LoginEnrollmentTest CourseFinanceAdminRole(self.course.id).add_users(inst) CourseDataResearcherRole(self.course.id).add_users(inst) - self.client.login(username=inst.username, password='test') + self.client.login(username=inst.username, password=self.TEST_PASSWORD) for endpoint, args in self.staff_level_endpoints: expected_status = 200 @@ -632,7 +632,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas self.audit_course_instructor = InstructorFactory(course_key=self.audit_course.id) self.white_label_course_instructor = InstructorFactory(course_key=self.white_label_course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.not_enrolled_student = UserFactory( username='NotEnrolledStudent', @@ -945,7 +945,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas Test that enrollment mode for audit courses (paid courses) is 'audit'. """ # Login Audit Course instructor - self.client.login(username=self.audit_course_instructor.username, password='test') + self.client.login(username=self.audit_course_instructor.username, password=self.TEST_PASSWORD) csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) @@ -976,7 +976,7 @@ class TestInstructorAPIBulkAccountCreationAndEnrollment(SharedModuleStoreTestCas self.white_label_course_mode.save() # Login Audit Course instructor - self.client.login(username=self.white_label_course_instructor.username, password='test') + self.client.login(username=self.white_label_course_instructor.username, password=self.TEST_PASSWORD) csv_content = b"test_student_wl@example.com,test_student_wl,Test Student,USA" uploaded_file = SimpleUploadedFile("temp.csv", csv_content) @@ -1024,7 +1024,7 @@ class TestInstructorAPIEnrollment(SharedModuleStoreTestCase, LoginEnrollmentTest self.request = RequestFactory().request() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.enrolled_student = UserFactory(username='EnrolledStudent', first_name='Enrolled', last_name='Student') CourseEnrollment.enroll( @@ -1873,7 +1873,7 @@ class TestInstructorAPIBulkBetaEnrollment(SharedModuleStoreTestCase, LoginEnroll super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.beta_tester = BetaTesterFactory(course_key=self.course.id) CourseEnrollment.enroll( @@ -2218,7 +2218,7 @@ class TestInstructorAPILevelsAccess(SharedModuleStoreTestCase, LoginEnrollmentTe super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.other_instructor = InstructorFactory(course_key=self.course.id) self.other_staff = StaffFactory(course_key=self.course.id) @@ -2458,7 +2458,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment self.course_mode.save() self.instructor = InstructorFactory(course_key=self.course.id) CourseDataResearcherRole(self.course.id).add_users(self.instructor) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.students = [UserFactory() for _ in range(6)] for student in self.students: @@ -2600,7 +2600,7 @@ class TestInstructorAPILevelsDataDump(SharedModuleStoreTestCase, LoginEnrollment })) course_instructor = InstructorFactory(course_key=self.course.id) CourseDataResearcherRole(self.course.id).add_users(course_instructor) - self.client.login(username=course_instructor.username, password='test') + self.client.login(username=course_instructor.username, password=self.TEST_PASSWORD) url = reverse('get_students_features', kwargs={'course_id': str(self.course.id)}) @@ -2972,7 +2972,7 @@ class TestInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginEnrollmentTes def setUp(self): super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.student = UserFactory() CourseEnrollment.enroll(self.student, self.course.id) @@ -3158,7 +3158,7 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE self.instructor = InstructorFactory(course_key=self.course.id) # Add instructor to invalid ee course CourseInstructorRole(self.course_with_invalid_ee.id).add_users(self.instructor) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.student = UserFactory() CourseEnrollment.enroll(self.student, self.course.id) @@ -3261,7 +3261,7 @@ class TestEntranceExamInstructorAPIRegradeTask(SharedModuleStoreTestCase, LoginE """ Test entrance exam delete state failure with staff access. """ self.client.logout() staff_user = StaffFactory(course_key=self.course.id) - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) url = reverse('reset_student_attempts_for_entrance_exam', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, { @@ -3414,7 +3414,7 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm } self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) def tearDown(self): super().tearDown() @@ -3434,7 +3434,7 @@ class TestInstructorSendEmail(SiteMixin, SharedModuleStoreTestCase, LoginEnrollm def test_send_email_but_not_staff(self): self.client.logout() student = UserFactory() - self.client.login(username=student.username, password='test') + self.client.login(username=student.username, password=self.TEST_PASSWORD) url = reverse('send_email', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, self.full_test_message) assert response.status_code == 403 @@ -3631,7 +3631,7 @@ class TestInstructorAPITaskLists(SharedModuleStoreTestCase, LoginEnrollmentTestC def setUp(self): super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.student = UserFactory() CourseEnrollment.enroll(self.student, self.course.id) @@ -3768,7 +3768,7 @@ class TestInstructorEmailContentList(SharedModuleStoreTestCase, LoginEnrollmentT super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.tasks = {} self.emails = {} self.emails_info = {} @@ -4030,7 +4030,7 @@ class TestDueDateExtensions(SharedModuleStoreTestCase, LoginEnrollmentTestCase): CourseEnrollmentFactory.create(user=self.user1, course_id=self.course.id) CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) extract_dates(None, self.course.id) def test_change_due_date(self): @@ -4206,7 +4206,7 @@ class TestDueDateExtensionsDeletedDate(ModuleStoreTestCase, LoginEnrollmentTestC CourseEnrollmentFactory.create(user=self.user1, course_id=self.course.id) CourseEnrollmentFactory.create(user=self.user2, course_id=self.course.id) self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) extract_dates(None, self.course.id) @override_waffle_flag(RELATIVE_DATES_FLAG, active=True) @@ -4251,7 +4251,7 @@ class TestCourseIssuedCertificatesData(SharedModuleStoreTestCase): def setUp(self): super().setUp() self.instructor = InstructorFactory(course_key=self.course.id) - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) def generate_certificate(self, course_id, mode, status): """ @@ -4379,7 +4379,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase): """ Verify that we get the error we expect for a given file input. """ - self.client.login(username=self.staff_user.username, password='test') + self.client.login(username=self.staff_user.username, password=self.TEST_PASSWORD) response = self.call_add_users_to_cohorts(file_content, suffix=file_suffix) assert response.status_code == 400 result = json.loads(response.content.decode('utf-8')) @@ -4392,7 +4392,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase): background task. """ mock_store_upload.return_value = (None, 'fake_file_name.csv') - self.client.login(username=self.staff_user.username, password='test') + self.client.login(username=self.staff_user.username, password=self.TEST_PASSWORD) response = self.call_add_users_to_cohorts(file_content) assert response.status_code == 204 assert mock_store_upload.called @@ -4438,7 +4438,7 @@ class TestBulkCohorting(SharedModuleStoreTestCase): """ Verify that we can't access the view when we aren't a staff user. """ - self.client.login(username=self.non_staff_user.username, password='test') + self.client.login(username=self.non_staff_user.username, password=self.TEST_PASSWORD) response = self.call_add_users_to_cohorts('') assert response.status_code == 403 diff --git a/lms/djangoapps/instructor/tests/test_api_email_localization.py b/lms/djangoapps/instructor/tests/test_api_email_localization.py index e0cef6a7af..a23b94ece0 100644 --- a/lms/djangoapps/instructor/tests/test_api_email_localization.py +++ b/lms/djangoapps/instructor/tests/test_api_email_localization.py @@ -35,7 +35,7 @@ class TestInstructorAPIEnrollmentEmailLocalization(SharedModuleStoreTestCase): # Esperanto. self.instructor = InstructorFactory(course_key=self.course.id) set_user_preference(self.instructor, LANGUAGE_KEY, 'zh-cn') - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.student = UserFactory.create() set_user_preference(self.student, LANGUAGE_KEY, 'eo') diff --git a/lms/djangoapps/instructor/tests/test_certificates.py b/lms/djangoapps/instructor/tests/test_certificates.py index 5350d1d07f..11fd82bfb3 100644 --- a/lms/djangoapps/instructor/tests/test_certificates.py +++ b/lms/djangoapps/instructor/tests/test_certificates.py @@ -64,11 +64,11 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): def test_visible_only_to_global_staff(self): # Instructors don't see the certificates section - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self._assert_certificates_visible(False) # Global staff can see the certificates section - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) self._assert_certificates_visible(True) def test_visible_only_when_feature_flag_enabled(self): @@ -77,17 +77,17 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): cache.clear() # Now even global staff can't see the certificates section - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) self._assert_certificates_visible(False) @ddt.data("started", "error", "success") def test_show_certificate_status(self, status): - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) with self._certificate_status("honor", status): self._assert_certificate_status("honor", status) def test_show_enabled_button(self): - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) # Initially, no example certs are generated, so # the enable button should be disabled @@ -104,7 +104,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): self._assert_enable_certs_button(False) def test_can_disable_even_after_failure(self): - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) with self._certificate_status("honor", "error"): # When certs are disabled for a course, then don't allow them @@ -127,7 +127,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): self.course.cert_html_view_enabled = True self.course.save() self.store.update_item(self.course, self.global_staff.id) - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) self.assertContains(response, 'Enable Student-Generated Certificates') self.assertContains(response, 'enable-certificates-submit') @@ -143,7 +143,7 @@ class CertificatesInstructorDashTest(SharedModuleStoreTestCase): self.course.cert_html_view_enabled = True self.course.save() self.store.update_item(self.course, self.global_staff.id) - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) self.assertContains(response, 'Enable Student-Generated Certificates') self.assertContains(response, 'enable-certificates-submit') @@ -233,18 +233,18 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): url = reverse(url_name, kwargs={'course_id': self.course.id}) # Instructors do not have access - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) response = self.client.post(url) assert response.status_code == 403 # Global staff have access - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) response = self.client.post(url) assert response.status_code == 302 @ddt.data(True, False) def test_enable_certificate_generation(self, is_enabled): - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) url = reverse( 'enable_certificate_generation', kwargs={'course_id': str(self.course.id)} @@ -274,7 +274,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): user who made the request is not member of global staff. """ user = UserFactory.create() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) url = reverse( 'start_certificate_generation', kwargs={'course_id': str(self.course.id)} @@ -283,7 +283,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): response = self.client.post(url) assert response.status_code == 403 - self.client.login(username=self.instructor.username, password='test') + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) response = self.client.post(url) assert response.status_code == 403 @@ -292,7 +292,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): Test certificates generation api endpoint returns success status when called with valid course key """ - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) url = reverse( 'start_certificate_generation', kwargs={'course_id': str(self.course.id)} @@ -319,7 +319,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): ) # Login the client and access the url with 'certificate_statuses' - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url, data={'certificate_statuses': [CertificateStatuses.downloadable]}) @@ -352,7 +352,7 @@ class CertificatesInstructorApiTest(SharedModuleStoreTestCase): ) # Login the client and access the url without 'certificate_statuses' - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) url = reverse('start_certificate_regeneration', kwargs={'course_id': str(self.course.id)}) response = self.client.post(url) @@ -419,7 +419,7 @@ class CertificateExceptionViewInstructorApiTest(SharedModuleStoreTestCase): # Enable certificate generation cache.clear() CertificateGenerationConfiguration.objects.create(enabled=True) - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_certificate_exception_added_successfully(self): """ @@ -712,7 +712,7 @@ class GenerateCertificatesInstructorApiTest(SharedModuleStoreTestCase): # Enable certificate generation cache.clear() CertificateGenerationConfiguration.objects.create(enabled=True) - self.client.login(username=self.global_staff.username, password='test') + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_generate_certificate_exceptions_all_students(self): """ @@ -830,7 +830,7 @@ class TestCertificatesInstructorApiBulkAllowlist(SharedModuleStoreTestCase): CourseEnrollment.enroll(self.enrolled_user_2, self.course.id) # Global staff can see the certificates section - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_create_allowlist_exception_record(self): """ @@ -1026,7 +1026,7 @@ class CertificateInvalidationViewTests(SharedModuleStoreTestCase): ) # Global staff can see the certificates section - self.client.login(username=self.global_staff.username, password="test") + self.client.login(username=self.global_staff.username, password=self.TEST_PASSWORD) def test_invalidate_certificate(self): """ diff --git a/lms/djangoapps/instructor/tests/test_email.py b/lms/djangoapps/instructor/tests/test_email.py index a84c728c30..322c734fa6 100644 --- a/lms/djangoapps/instructor/tests/test_email.py +++ b/lms/djangoapps/instructor/tests/test_email.py @@ -40,7 +40,7 @@ class TestNewInstructorDashboardEmailViewMongoBacked(SharedModuleStoreTestCase): # Create instructor account instructor = AdminFactory.create() - self.client.login(username=instructor.username, password="test") + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) def tearDown(self): super().tearDown() @@ -148,7 +148,7 @@ class TestNewInstructorDashboardEmailViewXMLBacked(SharedModuleStoreTestCase): # Create instructor account instructor = AdminFactory.create() - self.client.login(username=instructor.username, password="test") + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) # URL for instructor dash self.url = reverse('instructor_dashboard', kwargs={'course_id': str(self.course_key)}) diff --git a/lms/djangoapps/instructor/tests/test_filters.py b/lms/djangoapps/instructor/tests/test_filters.py index 19948da144..a676ab92a5 100644 --- a/lms/djangoapps/instructor/tests/test_filters.py +++ b/lms/djangoapps/instructor/tests/test_filters.py @@ -99,7 +99,7 @@ class InstructorDashboardFiltersTest(ModuleStoreTestCase): """ super().setUp() self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.course = CourseFactory.create( org="test1", course="course1", display_name="run1", ) diff --git a/lms/djangoapps/instructor/tests/test_handlers.py b/lms/djangoapps/instructor/tests/test_handlers.py new file mode 100644 index 0000000000..ee29e99eea --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_handlers.py @@ -0,0 +1,229 @@ +""" +Unit tests for instructor signals +""" +from datetime import datetime, timezone +from unittest import mock +from uuid import uuid4 + +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_events.data import EventsMetadata +from openedx_events.learning.data import ExamAttemptData, UserData, UserPersonalData +from openedx_events.learning.signals import EXAM_ATTEMPT_RESET, EXAM_ATTEMPT_SUBMITTED + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.instructor import enrollment +from lms.djangoapps.instructor.handlers import handle_exam_completion, handle_exam_reset + + +class ExamCompletionEventBusTests(TestCase): + """ + Tests completion events from the event bus. + """ + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.course_key = CourseKey.from_string('course-v1:edX+TestX+Test_Course') + cls.subsection_id = 'block-v1:edX+TestX+Test_Course+type@sequential+block@subsection' + cls.subsection_key = UsageKey.from_string(cls.subsection_id) + cls.student_user = UserFactory( + username='student_user', + ) + + @staticmethod + def _get_exam_event_data(student_user, course_key, usage_key, requesting_user=None): + """ create ExamAttemptData object for exam based event """ + if requesting_user: + requesting_user_data = UserData( + id=requesting_user.id, + is_active=True, + pii=None + ) + else: + requesting_user_data = None + + return ExamAttemptData( + student_user=UserData( + id=student_user.id, + is_active=True, + pii=UserPersonalData( + username=student_user.username, + email=student_user.email, + ), + ), + course_key=course_key, + usage_key=usage_key, + exam_type='timed', + requesting_user=requesting_user_data, + ) + + @staticmethod + def _get_exam_event_metadata(event_signal): + """ create metadata object for event """ + return EventsMetadata( + event_type=event_signal.event_type, + id=uuid4(), + minorversion=0, + source='openedx/lms/web', + sourcehost='lms.test', + time=datetime.now(timezone.utc) + ) + + @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True) + def test_submit_exam_completion_event(self, mock_task_apply): + """ + Assert update completion task is scheduled + """ + exam_event_data = self._get_exam_event_data(self.student_user, self.course_key, self.subsection_key) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_SUBMITTED) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + handle_exam_completion(None, EXAM_ATTEMPT_SUBMITTED, **event_kwargs) + mock_task_apply.assert_called_once_with(('student_user', self.subsection_id, 1.0)) + + @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True) + @mock.patch('lms.djangoapps.instructor.enrollment.reset_student_attempts', autospec=True) + def test_exam_reset_event(self, mock_reset, mock_task_apply): + """ + Assert problem state and completion are reset + """ + staff_user = UserFactory( + username='staff_user', + is_staff=True, + ) + + exam_event_data = self._get_exam_event_data( + self.student_user, + self.course_key, + self.subsection_key, + requesting_user=staff_user + ) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + + # reset signal + handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs) + + # make sure problem attempts have been deleted + mock_reset.assert_called_once_with( + self.course_key, + self.student_user, + self.subsection_key, + requesting_user=staff_user, + delete_module=True, + ) + + # Assert we update completion with 0.0 + mock_task_apply.assert_called_once_with(('student_user', self.subsection_id, 0.0)) + + def test_exam_reset_bad_user(self): + staff_user = UserFactory( + username='staff_user', + is_staff=True, + ) + + # not a user + bad_user_data = ExamAttemptData( + student_user=UserData( + id=999, + is_active=True, + pii=UserPersonalData( + username='user_dne', + email='user_dne@example.com', + ), + ), + course_key=self.course_key, + usage_key=self.subsection_key, + exam_type='timed', + requesting_user=UserData( + id=staff_user.id, + is_active=True, + pii=None + ), + ) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + event_kwargs = { + 'exam_attempt': bad_user_data, + 'metadata': event_metadata + } + + # reset signal + with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log: + handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs) + mock_log.assert_called_once_with( + 'Error occurred while attempting to reset student attempt for user_id ' + f'999 for content_id {bad_user_data.usage_key}. ' + 'User does not exist!' + ) + + def test_exam_reset_bad_requesting_user(self): + # requesting user is not a user + bad_user_data = ExamAttemptData( + student_user=UserData( + id=self.student_user.id, + is_active=True, + pii=UserPersonalData( + username='user_dne', + email='user_dne@example.com', + ), + ), + course_key=self.course_key, + usage_key=self.subsection_key, + exam_type='timed', + requesting_user=UserData( + id=999, + is_active=True, + pii=None + ), + ) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + event_kwargs = { + 'exam_attempt': bad_user_data, + 'metadata': event_metadata + } + + # reset signal + with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log: + handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs) + mock_log.assert_called_once_with( + 'Error occurred while attempting to reset student attempt. Requesting user_id ' + '999 does not exist!' + ) + + @mock.patch( + 'lms.djangoapps.instructor.enrollment.reset_student_attempts', + side_effect=enrollment.sub_api.SubmissionError + ) + def test_module_reset_failure(self, mock_reset): + staff_user = UserFactory( + username='staff_user', + is_staff=True, + ) + + exam_event_data = self._get_exam_event_data( + self.student_user, + self.course_key, + self.subsection_key, + requesting_user=staff_user + ) + event_metadata = self._get_exam_event_metadata(EXAM_ATTEMPT_RESET) + + event_kwargs = { + 'exam_attempt': exam_event_data, + 'metadata': event_metadata + } + + with mock.patch('lms.djangoapps.instructor.handlers.log.error') as mock_log: + # reset signal + handle_exam_reset(None, EXAM_ATTEMPT_RESET, **event_kwargs) + mock_log.assert_called_once_with( + 'Error occurred while attempting to reset module state for user_id ' + f'{self.student_user.id} for content_id {self.subsection_id}.' + ) diff --git a/lms/djangoapps/instructor/tests/test_proctoring.py b/lms/djangoapps/instructor/tests/test_proctoring.py index 491a2ee1f7..4b196f307b 100644 --- a/lms/djangoapps/instructor/tests/test_proctoring.py +++ b/lms/djangoapps/instructor/tests/test_proctoring.py @@ -36,7 +36,7 @@ class TestProctoringDashboardViews(SharedModuleStoreTestCase): # Create instructor account self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) def setup_course_url(self, course): """ diff --git a/lms/djangoapps/instructor/tests/test_services.py b/lms/djangoapps/instructor/tests/test_services.py index 488c0d43a6..7080b58bba 100644 --- a/lms/djangoapps/instructor/tests/test_services.py +++ b/lms/djangoapps/instructor/tests/test_services.py @@ -5,9 +5,7 @@ import json from unittest import mock import pytest -from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH from django.core.exceptions import ObjectDoesNotExist -from edx_toggles.toggles.testutils import override_waffle_switch from opaque_keys import InvalidKeyError from common.djangoapps.student.models import CourseEnrollment @@ -15,9 +13,8 @@ from common.djangoapps.student.tests.factories import UserFactory from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.instructor.access import allow_access from lms.djangoapps.instructor.services import InstructorService -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory, BlockFactory # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions import Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory class InstructorServiceTests(SharedModuleStoreTestCase): @@ -54,8 +51,8 @@ class InstructorServiceTests(SharedModuleStoreTestCase): ) @mock.patch('lms.djangoapps.grades.signals.handlers.PROBLEM_WEIGHTED_SCORE_CHANGED.send') - @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') - def test_reset_student_attempts_delete(self, mock_submit, _mock_signal): + @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True) + def test_reset_student_attempts_delete(self, mock_completion_task, _mock_signal): """ Test delete student state. """ @@ -64,22 +61,19 @@ class InstructorServiceTests(SharedModuleStoreTestCase): assert StudentModule.objects.filter(student=self.module_to_reset.student, course_id=self.course.id, module_state_key=self.module_to_reset.module_state_key).count() == 1 - with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): - self.service.delete_student_attempt( - self.student.username, - str(self.course.id), - str(self.subsection.location), - requesting_user=self.student, - ) + self.service.delete_student_attempt( + self.student.username, + str(self.course.id), + str(self.subsection.location), + requesting_user=self.student, + ) # make sure the module has been deleted assert StudentModule.objects.filter(student=self.module_to_reset.student, course_id=self.course.id, module_state_key=self.module_to_reset.module_state_key).count() == 0 - # Assert we send completion == 0.0 for both problems even though the second problem was never viewed - assert mock_submit.call_count == 2 - mock_submit.assert_any_call(user=self.student, block_key=self.problem.location, completion=0.0) - mock_submit.assert_any_call(user=self.student, block_key=self.problem_2.location, completion=0.0) + # Assert we send update completion with 0.0 + mock_completion_task.assert_called_once_with((self.student.username, str(self.subsection.location), 0.0)) def test_reset_bad_content_id(self): """ @@ -120,128 +114,13 @@ class InstructorServiceTests(SharedModuleStoreTestCase): ) assert result is None - @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') - def test_complete_student_attempt_success(self, mock_submit): + @mock.patch('lms.djangoapps.instructor.tasks.update_exam_completion_task.apply_async', autospec=True) + def test_complete_student_attempt_success(self, mock_completion_task): """ - Assert complete_student_attempt correctly publishes completion for all - completable children of the given content_id + Assert update_exam_completion task is triggered """ - # Section, subsection, and unit are all aggregators and not completable so should - # not be submitted. - section = BlockFactory.create(parent=self.course, category='chapter') - subsection = BlockFactory.create(parent=section, category='sequential') - unit = BlockFactory.create(parent=subsection, category='vertical') - - # should both be submitted - video = BlockFactory.create(parent=unit, category='video') - problem = BlockFactory.create(parent=unit, category='problem') - - # Not a completable block - BlockFactory.create(parent=unit, category='discussion') - - with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): - self.service.complete_student_attempt(self.student.username, str(subsection.location)) - - # Only Completable leaf blocks should have completion published - assert mock_submit.call_count == 2 - mock_submit.assert_any_call(user=self.student, block_key=video.location, completion=1.0) - mock_submit.assert_any_call(user=self.student, block_key=problem.location, completion=1.0) - - @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') - def test_complete_student_attempt_split_test(self, mock_submit): - """ - Asserts complete_student_attempt correctly publishes completion when a split test is involved - - This test case exists because we ran into a bug about the user_service not existing - when a split_test existed inside of a subsection. Associated with this change was adding - in the user state into the module before attempting completion and this ensures that is - working properly. - """ - partition = UserPartition( - 0, - 'first_partition', - 'First Partition', - [ - Group(0, 'alpha'), - Group(1, 'beta') - ] - ) - course = CourseFactory.create(user_partitions=[partition]) - section = BlockFactory.create(parent=course, category='chapter') - subsection = BlockFactory.create(parent=section, category='sequential') - - c0_url = course.id.make_usage_key('vertical', 'split_test_cond0') - c1_url = course.id.make_usage_key('vertical', 'split_test_cond1') - split_test = BlockFactory.create( - parent=subsection, - category='split_test', - user_partition_id=0, - group_id_to_child={'0': c0_url, '1': c1_url}, - ) - - cond0vert = BlockFactory.create(parent=split_test, category='vertical', location=c0_url) - BlockFactory.create(parent=cond0vert, category='video') - BlockFactory.create(parent=cond0vert, category='problem') - - cond1vert = BlockFactory.create(parent=split_test, category='vertical', location=c1_url) - BlockFactory.create(parent=cond1vert, category='video') - BlockFactory.create(parent=cond1vert, category='html') - - with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): - self.service.complete_student_attempt(self.student.username, str(subsection.location)) - - # Only the group the user was assigned to should have completion published. - # Either cond0vert's children or cond1vert's children - assert mock_submit.call_count == 2 - - @mock.patch('lms.djangoapps.instructor.tasks.log.error') - def test_complete_student_attempt_bad_user(self, mock_logger): - """ - Assert complete_student_attempt with a bad user raises error and returns None - """ - username = 'bad_user' - block_id = str(self.problem.location) - self.service.complete_student_attempt(username, block_id) - mock_logger.assert_called_once_with( - self.complete_error_prefix.format(user=username, content_id=block_id) + 'User does not exist!' - ) - - @mock.patch('lms.djangoapps.instructor.tasks.log.error') - def test_complete_student_attempt_bad_content_id(self, mock_logger): - """ - Assert complete_student_attempt with a bad content_id raises error and returns None - """ - username = self.student.username - self.service.complete_student_attempt(username, 'foo/bar/baz') - mock_logger.assert_called_once_with( - self.complete_error_prefix.format(user=username, content_id='foo/bar/baz') + 'Invalid content_id!' - ) - - @mock.patch('lms.djangoapps.instructor.tasks.log.error') - def test_complete_student_attempt_nonexisting_item(self, mock_logger): - """ - Assert complete_student_attempt with nonexisting item in the modulestore - raises error and returns None - """ - username = self.student.username - block = 'i4x://org.0/course_0/problem/fake_problem' - self.service.complete_student_attempt(username, block) - mock_logger.assert_called_once_with( - self.complete_error_prefix.format(user=username, content_id=block) + 'Block not found in the modulestore!' - ) - - @mock.patch('lms.djangoapps.instructor.tasks.log.error') - def test_complete_student_attempt_failed_module(self, mock_logger): - """ - Assert complete_student_attempt with failed get_block raises error and returns None - """ - username = self.student.username - with mock.patch('lms.djangoapps.instructor.tasks.get_block_for_descriptor', return_value=None): - self.service.complete_student_attempt(username, str(self.course.location)) - mock_logger.assert_called_once_with( - self.complete_error_prefix.format(user=username, content_id=self.course.location) + - 'Block unable to be created from descriptor!' - ) + self.service.complete_student_attempt(self.student.username, str(self.subsection.location)) + mock_completion_task.assert_called_once_with((self.student.username, str(self.subsection.location), 1.0)) def test_is_user_staff(self): """ diff --git a/lms/djangoapps/instructor/tests/test_spoc_gradebook.py b/lms/djangoapps/instructor/tests/test_spoc_gradebook.py index bf574e8f50..221ed3603e 100644 --- a/lms/djangoapps/instructor/tests/test_spoc_gradebook.py +++ b/lms/djangoapps/instructor/tests/test_spoc_gradebook.py @@ -56,7 +56,7 @@ class TestGradebook(SharedModuleStoreTestCase): super().setUp() instructor = AdminFactory.create() - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) self.users = [UserFactory.create() for _ in range(USER_COUNT)] for user in self.users: CourseEnrollmentFactory.create(user=user, course_id=self.course.id) diff --git a/lms/djangoapps/instructor/tests/test_tasks.py b/lms/djangoapps/instructor/tests/test_tasks.py new file mode 100644 index 0000000000..4c6f9016f4 --- /dev/null +++ b/lms/djangoapps/instructor/tests/test_tasks.py @@ -0,0 +1,189 @@ +""" +Tests for tasks.py +""" +import json +from unittest import mock + +from completion.waffle import ENABLE_COMPLETION_TRACKING_SWITCH +from edx_toggles.toggles.testutils import override_waffle_switch + +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.courseware.models import StudentModule +from lms.djangoapps.instructor.tasks import update_exam_completion_task +from xmodule.modulestore.tests.django_utils import \ + SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.factories import ( # lint-amnesty, pylint: disable=wrong-import-order + BlockFactory, + CourseFactory +) +from xmodule.partitions.partitions import Group, UserPartition # lint-amnesty, pylint: disable=wrong-import-order + + +class UpdateCompletionTests(SharedModuleStoreTestCase): + """ + Test the update_exam_completion_task + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.email = 'escalation@test.com' + cls.course = CourseFactory.create(proctoring_escalation_email=cls.email) + cls.section = BlockFactory.create(parent=cls.course, category='chapter') + cls.subsection = BlockFactory.create(parent=cls.section, category='sequential') + cls.unit = BlockFactory.create(parent=cls.subsection, category='vertical') + cls.problem = BlockFactory.create(parent=cls.unit, category='problem') + cls.unit_2 = BlockFactory.create(parent=cls.subsection, category='vertical') + cls.problem_2 = BlockFactory.create(parent=cls.unit_2, category='problem') + cls.complete_error_prefix = ('Error occurred while attempting to complete student attempt for ' + 'user {user} for content_id {content_id}. ') + + def setUp(self): + super().setUp() + + self.student = UserFactory() + CourseEnrollment.enroll(self.student, self.course.id) + + self.module_to_reset = StudentModule.objects.create( + student=self.student, + course_id=self.course.id, + module_state_key=self.problem.location, + state=json.dumps({'attempts': 2}), + ) + + @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') + def test_update_completion_success(self, mock_submit): + """ + Assert correctly publishes completion for all + completable children of the given content_id + """ + # Section, subsection, and unit are all aggregators and not completable so should + # not be submitted. + section = BlockFactory.create(parent=self.course, category='chapter') + subsection = BlockFactory.create(parent=section, category='sequential') + unit = BlockFactory.create(parent=subsection, category='vertical') + + # should both be submitted + video = BlockFactory.create(parent=unit, category='video') + problem = BlockFactory.create(parent=unit, category='problem') + + # Not a completable block + BlockFactory.create(parent=unit, category='discussion') + + with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): + update_exam_completion_task(self.student.username, str(subsection.location), 1.0) + + # Only Completable leaf blocks should have completion published + assert mock_submit.call_count == 2 + mock_submit.assert_any_call(user=self.student, block_key=video.location, completion=1.0) + mock_submit.assert_any_call(user=self.student, block_key=problem.location, completion=1.0) + + @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') + def test_update_completion_delete(self, mock_submit): + """ + Test update completion with a value of 0.0 + """ + with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): + update_exam_completion_task(self.student.username, str(self.subsection.location), 0.0) + + # Assert we send completion == 0.0 for both problems + assert mock_submit.call_count == 2 + mock_submit.assert_any_call(user=self.student, block_key=self.problem.location, completion=0.0) + mock_submit.assert_any_call(user=self.student, block_key=self.problem_2.location, completion=0.0) + + @mock.patch('completion.handlers.BlockCompletion.objects.submit_completion') + def test_update_completion_split_test(self, mock_submit): + """ + Asserts correctly publishes completion when a split test is involved + + This test case exists because we ran into a bug about the user_service not existing + when a split_test existed inside of a subsection. Associated with this change was adding + in the user state into the module before attempting completion and this ensures that is + working properly. + """ + partition = UserPartition( + 0, + 'first_partition', + 'First Partition', + [ + Group(0, 'alpha'), + Group(1, 'beta') + ] + ) + course = CourseFactory.create(user_partitions=[partition]) + section = BlockFactory.create(parent=course, category='chapter') + subsection = BlockFactory.create(parent=section, category='sequential') + + c0_url = course.id.make_usage_key('vertical', 'split_test_cond0') + c1_url = course.id.make_usage_key('vertical', 'split_test_cond1') + split_test = BlockFactory.create( + parent=subsection, + category='split_test', + user_partition_id=0, + group_id_to_child={'0': c0_url, '1': c1_url}, + ) + + cond0vert = BlockFactory.create(parent=split_test, category='vertical', location=c0_url) + BlockFactory.create(parent=cond0vert, category='video') + BlockFactory.create(parent=cond0vert, category='problem') + + cond1vert = BlockFactory.create(parent=split_test, category='vertical', location=c1_url) + BlockFactory.create(parent=cond1vert, category='video') + BlockFactory.create(parent=cond1vert, category='html') + + with override_waffle_switch(ENABLE_COMPLETION_TRACKING_SWITCH, True): + update_exam_completion_task(self.student.username, str(subsection.location), 1.0) + + # Only the group the user was assigned to should have completion published. + # Either cond0vert's children or cond1vert's children + assert mock_submit.call_count == 2 + + @mock.patch('lms.djangoapps.instructor.tasks.log.error') + def test_update_completion_bad_user(self, mock_logger): + """ + Assert a bad user raises error and returns None + """ + username = 'bad_user' + block_id = str(self.problem.location) + update_exam_completion_task(username, block_id, 1.0) + mock_logger.assert_called_once_with( + self.complete_error_prefix.format(user=username, content_id=block_id) + 'User does not exist!' + ) + + @mock.patch('lms.djangoapps.instructor.tasks.log.error') + def test_update_completion_bad_content_id(self, mock_logger): + """ + Assert a bad content_id raises error and returns None + """ + username = self.student.username + update_exam_completion_task(username, 'foo/bar/baz', 1.0) + mock_logger.assert_called_once_with( + self.complete_error_prefix.format(user=username, content_id='foo/bar/baz') + 'Invalid content_id!' + ) + + @mock.patch('lms.djangoapps.instructor.tasks.log.error') + def test_update_completion_nonexisting_item(self, mock_logger): + """ + Assert nonexisting item in the modulestore + raises error and returns None + """ + username = self.student.username + block = 'i4x://org.0/course_0/problem/fake_problem' + update_exam_completion_task(username, block, 1.0) + mock_logger.assert_called_once_with( + self.complete_error_prefix.format(user=username, content_id=block) + 'Block not found in the modulestore!' + ) + + @mock.patch('lms.djangoapps.instructor.tasks.log.error') + def test_update_completion_failed_module(self, mock_logger): + """ + Assert failed get_block raises error and returns None + """ + username = self.student.username + with mock.patch('lms.djangoapps.instructor.tasks.get_block_for_descriptor', return_value=None): + update_exam_completion_task(username, str(self.course.location), 1.0) + mock_logger.assert_called_once_with( + self.complete_error_prefix.format(user=username, content_id=self.course.location) + + 'Block unable to be created from descriptor!' + ) diff --git a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py index bca16977a9..db455f0258 100644 --- a/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py +++ b/lms/djangoapps/instructor/tests/views/test_instructor_dashboard.py @@ -85,7 +85,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT self.course_mode.save() # Create instructor account self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) # URL for instructor dash self.url = reverse('instructor_dashboard', kwargs={'course_id': str(self.course.id)}) @@ -169,7 +169,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT org=self.course.id.org ) set_course_cohorted(self.course.id, True) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url).content.decode('utf-8') self.assertEqual(discussion_section in response, is_discussion_tab_available) @@ -192,7 +192,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT with override_waffle_flag(OVERRIDE_DISCUSSION_LEGACY_SETTINGS_FLAG, is_legacy_discussion_setting_enabled): user = UserFactory.create(is_staff=True) set_course_cohorted(self.course.id, True) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url).content.decode('utf-8') self.assertEqual(discussion_section in response, is_discussion_tab_available) @@ -224,7 +224,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT role=access_role, org=self.course.id.org ) - self.client.login(username=user.username, password="test") + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) if can_access: self.assertContains(response, download_section) @@ -244,7 +244,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT role='data_researcher', org=self.course.id.org ) - self.client.login(username=user.username, password="test") + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) matches = re.findall( rb'<li class="nav-item"><button type="button" class="btn-link .*" data-section=".*">.*', @@ -320,7 +320,7 @@ class TestInstructorDashboard(ModuleStoreTestCase, LoginEnrollmentTestCase, XssT # But staff user can only see specific grades staff = StaffFactory(course_key=self.course.id) - self.client.login(username=staff.username, password="test") + self.client.login(username=staff.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) self.assertNotContains(response, '<h4 class="hd hd-4">Adjust all enrolled learners') self.assertContains(response, '<h4 class="hd hd-4">View a specific learner's grades and progress') @@ -620,7 +620,7 @@ class TestInstructorDashboardPerformance(ModuleStoreTestCase, LoginEnrollmentTes self.course_mode.save() # Create instructor account self.instructor = AdminFactory.create() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) def test_spoc_gradebook_mongo_calls(self): """ diff --git a/lms/djangoapps/instructor/views/api.py b/lms/djangoapps/instructor/views/api.py index 778cbe5c8b..fd346d242f 100644 --- a/lms/djangoapps/instructor/views/api.py +++ b/lms/djangoapps/instructor/views/api.py @@ -1661,7 +1661,7 @@ def get_anon_ids(request, course_id): @require_POST @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_ENROLL) +@require_course_permission(permissions.VIEW_ENROLLMENTS) @require_post_params( unique_student_identifier="email or username of student for whom to get enrollment status" ) @@ -2611,7 +2611,7 @@ def problem_grade_report(request, course_id): @require_POST @ensure_csrf_cookie @cache_control(no_cache=True, no_store=True, must_revalidate=True) -@require_course_permission(permissions.CAN_ENROLL) +@require_course_permission(permissions.VIEW_FORUM_MEMBERS) @require_post_params('rolename') def list_forum_members(request, course_id): """ diff --git a/lms/djangoapps/instructor_task/rest_api/v1/tests/test_views.py b/lms/djangoapps/instructor_task/rest_api/v1/tests/test_views.py index fb7f2a1ad2..bb338f0bf4 100644 --- a/lms/djangoapps/instructor_task/rest_api/v1/tests/test_views.py +++ b/lms/djangoapps/instructor_task/rest_api/v1/tests/test_views.py @@ -128,7 +128,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): Test case that verifies the permissions of the ListScheduledBulkEmailInstructorTasks view. A user without staff, instructor, or GlobalStaff access should not be able to retrieve the bulk email schedules for a course. """ - self.client.login(username=username, password="test") + self.client.login(username=username, password=self.TEST_PASSWORD) response = self.client.get(self._build_api_url(self.course1.id)) assert response.status_code == expected_status @@ -143,7 +143,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): # add a "In Progress" task which shouldn't be returned in our results self._create_scheduled_course_emails_for_course(self.course1.id, self.instructor_course1, PROGRESS, 1) - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.get(self._build_api_url(self.course1.id)) results = response.data.get("results") @@ -169,7 +169,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): expected_task_output_msg = "Task revoked before running" self._create_scheduled_course_emails_for_course(self.course1.id, self.instructor_course1, SCHEDULED, 3) - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.get(self._build_api_url(self.course1.id)) results = response.data.get("results") @@ -199,7 +199,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): This test verifies the response received when we try to delete a task schedule that does not exist. """ - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.delete(f"{self._build_api_url(self.course1.id)}123456789") assert response.status_code == 404 @@ -214,7 +214,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): task = InstructorTask.objects.get(course_id=self.course1.id) schedule = InstructorTaskSchedule.objects.get(task=task) - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.delete(f"{self._build_api_url(self.course1.id)}{schedule.id}") assert response.status_code == 400 @@ -232,7 +232,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): "schedule": schedule_datetime.strftime('%Y-%m-%dT%H:%M:%SZ'), "browser_timezone": "UTC" } - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.patch( f"{self._build_api_url(self.course1.id)}{task_schedule.id}", data=json.dumps(data), @@ -262,7 +262,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): } } - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.patch( f"{self._build_api_url(self.course1.id)}{task_schedule.id}", data=json.dumps(data), @@ -296,7 +296,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): f"Cannot update instructor task schedule '{task_schedule.id}', the updated schedule occurs in the past" ) - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.patch( f"{self._build_api_url(self.course1.id)}{task_schedule.id}", data=json.dumps(data), @@ -328,7 +328,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): f"Subject{email_id}'" ) - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.patch( f"{self._build_api_url(self.course1.id)}{task_schedule.id}", data=json.dumps(data), @@ -359,7 +359,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): "specified in the request does not match the email id associated with the task" ) - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.patch( f"{self._build_api_url(self.course1.id)}{task_schedule.id}", data=json.dumps(data), @@ -379,7 +379,7 @@ class TestScheduledBulkEmailAPIViews(APITestCase, ModuleStoreTestCase): "browser_timezone": "UTC" } - self.client.login(username=self.instructor_course1.username, password="test") + self.client.login(username=self.instructor_course1.username, password=self.TEST_PASSWORD) response = self.client.patch( f"{self._build_api_url(self.course1.id)}8675309", data=json.dumps(data), diff --git a/lms/djangoapps/instructor_task/tests/test_base.py b/lms/djangoapps/instructor_task/tests/test_base.py index dbcac033f7..35f9867cdc 100644 --- a/lms/djangoapps/instructor_task/tests/test_base.py +++ b/lms/djangoapps/instructor_task/tests/test_base.py @@ -161,7 +161,7 @@ class InstructorTaskCourseTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase) if self.current_user != username: self.logout() user_email = User.objects.get(username=username).email - self.login(user_email, "test") + self.login(user_email, self.TEST_PASSWORD) self.current_user = username def _create_user(self, username, email=None, is_staff=False, mode='honor', enrollment_active=True): diff --git a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py index 0587ad7c4f..48479920a4 100644 --- a/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/api/v0/tests/test_views.py @@ -50,7 +50,6 @@ PROGRAMS_UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTestCase): """Unit tests for the program progress detail page.""" program_uuid = str(uuid4()) - password = 'test' url = reverse_lazy('learner_dashboard:v0:program_progress_detail', kwargs={'program_uuid': program_uuid}) @classmethod @@ -71,7 +70,7 @@ class TestProgramProgressDetailView(ProgramsApiConfigMixin, SharedModuleStoreTes super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) def assert_program_data_present(self, response): """Verify that program data is present.""" @@ -144,7 +143,6 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): enterprise_uuid = str(uuid4()) program_uuid = str(uuid4()) - password = 'test' url = reverse_lazy('learner_dashboard:v0:program_list', kwargs={'enterprise_uuid': enterprise_uuid}) @classmethod @@ -184,7 +182,7 @@ class TestProgramsView(SharedModuleStoreTestCase, ProgramCacheMixin): def setUp(self): super().setUp() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.set_program_in_catalog_cache(self.program_uuid, self.program) ProgramEnrollmentFactory.create( user=self.user, diff --git a/lms/djangoapps/learner_dashboard/tests/test_programs.py b/lms/djangoapps/learner_dashboard/tests/test_programs.py index 6b2b160451..c1b699c44a 100644 --- a/lms/djangoapps/learner_dashboard/tests/test_programs.py +++ b/lms/djangoapps/learner_dashboard/tests/test_programs.py @@ -55,7 +55,6 @@ def load_serialized_data(response, key): class TestProgramListing(ProgramsApiConfigMixin, SharedModuleStoreTestCase): """Unit tests for the program listing page.""" maxDiff = None - password = 'test' url = reverse_lazy('program_listing_view') @classmethod @@ -75,7 +74,7 @@ class TestProgramListing(ProgramsApiConfigMixin, SharedModuleStoreTestCase): super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) @classmethod def program_sort_key(cls, program): @@ -112,7 +111,7 @@ class TestProgramListing(ProgramsApiConfigMixin, SharedModuleStoreTestCase): '{}?next={}'.format(reverse('signin_user'), self.url) ) - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) assert response.status_code == 200 @@ -224,7 +223,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, CatalogIntegrationMixin, Shared super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) def assert_program_data_present(self, response): """Verify that program data is present.""" @@ -277,7 +276,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, CatalogIntegrationMixin, Shared '{}?next={}'.format(reverse('signin_user'), self.url) ) - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) with mock.patch('lms.djangoapps.learner_dashboard.programs.get_certificates') as certs: certs.return_value = [{'type': 'program', 'url': '/'}] @@ -324,7 +323,7 @@ class TestProgramDetailsFragmentView(SharedModuleStoreTestCase, ProgramCacheMixi def setUp(self): super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.set_program_in_catalog_cache(self.program_uuid, self.program) self.create_programs_config() diff --git a/lms/djangoapps/learner_dashboard/tests/test_views.py b/lms/djangoapps/learner_dashboard/tests/test_views.py index d38b2876f5..03f7da1d8c 100644 --- a/lms/djangoapps/learner_dashboard/tests/test_views.py +++ b/lms/djangoapps/learner_dashboard/tests/test_views.py @@ -26,7 +26,6 @@ from openedx.core.djangoapps.programs.models import ProgramDiscussionsConfigurat class TestProgramDiscussionIframeView(SharedModuleStoreTestCase, ProgramCacheMixin): """Unit tests for the program details page.""" program_uuid = str(uuid4()) - password = 'test' url = reverse_lazy('program_discussion', kwargs={'program_uuid': program_uuid}) @classmethod @@ -41,7 +40,7 @@ class TestProgramDiscussionIframeView(SharedModuleStoreTestCase, ProgramCacheMix def setUp(self): super().setUp() self.user = UserFactory() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.set_program_in_catalog_cache(self.program_uuid, self.program) ProgramEnrollmentFactory.create( user=self.user, @@ -103,7 +102,7 @@ class TestProgramDiscussionIframeView(SharedModuleStoreTestCase, ProgramCacheMix """ if staff: self.user = UserFactory(is_staff=True) - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) discussion_config = ProgramDiscussionsConfiguration.objects.create( program_uuid=self.program_uuid, enabled=True, @@ -126,7 +125,7 @@ class TestProgramDiscussionIframeView(SharedModuleStoreTestCase, ProgramCacheMix Test if API returns default response in case a non staff user has no enrollment in the program """ self.user = UserFactory() - self.client.login(username=self.user.username, password=self.password) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) default_response = { 'tab_view_enabled': False, diff --git a/lms/djangoapps/learner_recommendations/tests/test_views.py b/lms/djangoapps/learner_recommendations/tests/test_views.py index 87c9e905ea..46c54dc90d 100644 --- a/lms/djangoapps/learner_recommendations/tests/test_views.py +++ b/lms/djangoapps/learner_recommendations/tests/test_views.py @@ -26,8 +26,9 @@ class TestRecommendationsBase(APITestCase): def setUp(self): super().setUp() - self.user = UserFactory() - self.client.login(username=self.user.username, password="test") + self.TEST_PASSWORD = 'Password1234' + self.user = UserFactory(password=self.TEST_PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.recommended_courses = [ "MITx+6.00.1x", "IBM+PY0101EN", @@ -163,7 +164,7 @@ class TestRecommendationsContextView(APITestCase): def setUp(self): super().setUp() self.user = UserFactory() - self.password = "test" + self.password = 'Password1234' self.url = reverse_lazy("learner_recommendations:recommendations_context") @mock.patch("lms.djangoapps.learner_recommendations.views.country_code_from_ip") @@ -369,8 +370,9 @@ class TestProductRecommendationsView(APITestCase): def setUp(self): super().setUp() - self.user = UserFactory() - self.client.login(username=self.user.username, password="test") + self.TEST_PASSWORD = 'Password1234' + self.user = UserFactory(password=self.TEST_PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.associated_course_keys = ["edx+HL1", "edx+HL2"] self.amplitude_keys = [ "edx+CS0", diff --git a/lms/djangoapps/mobile_api/testutils.py b/lms/djangoapps/mobile_api/testutils.py index fe2804782a..f74d4b45a5 100644 --- a/lms/djangoapps/mobile_api/testutils.py +++ b/lms/djangoapps/mobile_api/testutils.py @@ -50,7 +50,7 @@ class MobileAPITestCase(ModuleStoreTestCase, APITestCase): certificate_available_date=datetime.datetime.now(pytz.UTC) ) self.user = UserFactory.create() - self.password = 'test' + self.password = self.TEST_PASSWORD self.username = self.user.username self.api_version = API_V1 IgnoreMobileAvailableFlagConfig(enabled=False).save() diff --git a/lms/djangoapps/staticbook/tests.py b/lms/djangoapps/staticbook/tests.py index 81c67523f2..919ee16c4f 100644 --- a/lms/djangoapps/staticbook/tests.py +++ b/lms/djangoapps/staticbook/tests.py @@ -60,7 +60,7 @@ class StaticBookTest(ModuleStoreTestCase): self.course = CourseFactory.create(**kwargs) user = UserFactory.create() CourseEnrollmentFactory.create(user=user, course_id=self.course.id) - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) def make_url(self, url_name, **kwargs): """ diff --git a/lms/djangoapps/survey/tests/test_models.py b/lms/djangoapps/survey/tests/test_models.py index 0fb2b5fe74..e8c6619da6 100644 --- a/lms/djangoapps/survey/tests/test_models.py +++ b/lms/djangoapps/survey/tests/test_models.py @@ -30,7 +30,7 @@ class SurveyModelsTests(TestCase): self.client = Client() # Create two accounts - self.password = 'abc' + self.password = 'Password1234' self.student = UserFactory.create( username='student', email='student@test.com', password=self.password, ) diff --git a/lms/djangoapps/survey/tests/test_utils.py b/lms/djangoapps/survey/tests/test_utils.py index f02efe0934..ce35474d6d 100644 --- a/lms/djangoapps/survey/tests/test_utils.py +++ b/lms/djangoapps/survey/tests/test_utils.py @@ -28,7 +28,7 @@ class SurveyModelsTests(ModuleStoreTestCase): self.client = Client() # Create two accounts - self.password = 'abc' + self.password = self.TEST_PASSWORD self.student = UserFactory.create( username='student', email='student@test.com', password=self.password, ) diff --git a/lms/djangoapps/survey/tests/test_views.py b/lms/djangoapps/survey/tests/test_views.py index 1e2ff04ba9..07ddd34e05 100644 --- a/lms/djangoapps/survey/tests/test_views.py +++ b/lms/djangoapps/survey/tests/test_views.py @@ -29,7 +29,7 @@ class SurveyViewsTests(ModuleStoreTestCase): self.client = Client() # Create two accounts - self.password = 'abc' + self.password = self.TEST_PASSWORD self.student = UserFactory.create(username='student', email='student@test.com', password=self.password) self.test_survey_name = 'TestSurvey' diff --git a/lms/djangoapps/verify_student/tests/test_views.py b/lms/djangoapps/verify_student/tests/test_views.py index ea2ccb8919..77ee515d37 100644 --- a/lms/djangoapps/verify_student/tests/test_views.py +++ b/lms/djangoapps/verify_student/tests/test_views.py @@ -93,6 +93,8 @@ l8N6+LEIVTMAytPk+/bImHvGHKZkCz5rEMSuYJWOmqKI92rUtI6fz5DUb3XSbrwT 3W+sdGFUK3GH1NAX71VxbAlFVLUetcMwai1+wXmGkRw6A7YezVFnhw== -----END RSA PRIVATE KEY-----""" +TEST_PASSWORD = 'Password1234' + def _mock_payment_processors(): """ @@ -122,8 +124,8 @@ class StartView(TestCase): Test the case where the user has no pending `PhotoVerificationAttempts`, but is just starting their first. """ - UserFactory.create(username="rusty", password="test") - self.client.login(username="rusty", password="test") + UserFactory.create(username="rusty", password=TEST_PASSWORD) + self.client.login(username="rusty", password=TEST_PASSWORD) def must_be_logged_in(self): self.assertHttpForbidden(self.client.get(self.start_url())) # lint-amnesty, pylint: disable=no-member @@ -1055,11 +1057,11 @@ class CheckoutTestMixin: """ Create a user and course. """ super().setUp() - self.user = UserFactory.create(username="test", password="test") + self.user = UserFactory.create(username="test", password=TEST_PASSWORD) self.course = CourseFactory.create() for mode, min_price in (('audit', 0), ('honor', 0), ('verified', 100)): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id, min_price=min_price, sku=self.make_sku()) - self.client.login(username="test", password="test") + self.client.login(username="test", password=TEST_PASSWORD) def _assert_checked_out( self, @@ -1868,7 +1870,7 @@ class TestPhotoURLView(TestVerificationBase): super().setUp() self.user = AdminFactory() - login_success = self.client.login(username=self.user.username, password='test') + login_success = self.client.login(username=self.user.username, password=TEST_PASSWORD) assert login_success self.attempt = SoftwareSecurePhotoVerification( status="submitted", @@ -1895,7 +1897,7 @@ class TestPhotoURLView(TestVerificationBase): def test_403_for_non_staff(self): self.user = UserFactory() - login_success = self.client.login(username=self.user.username, password='test') + login_success = self.client.login(username=self.user.username, password=TEST_PASSWORD) assert login_success url = reverse('verification_photo_urls', kwargs={'receipt_id': str(self.receipt_id)}) response = self.client.get(url) @@ -1934,7 +1936,7 @@ class TestDecodeImageViews(MockS3Boto3Mixin, TestVerificationBase): def setUp(self): super().setUp() self.user = AdminFactory() - login_success = self.client.login(username=self.user.username, password='test') + login_success = self.client.login(username=self.user.username, password=TEST_PASSWORD) assert login_success def _mock_submit_images(self): @@ -1995,7 +1997,7 @@ class TestDecodeImageViews(MockS3Boto3Mixin, TestVerificationBase): @ddt.data("face", "photo_id") def test_403_for_non_staff(self, img_type): self.user = UserFactory() - login_success = self.client.login(username=self.user.username, password='test') + login_success = self.client.login(username=self.user.username, password=TEST_PASSWORD) assert login_success self._mock_submit_images() diff --git a/lms/envs/common.py b/lms/envs/common.py index 6c78327d07..feebd335f4 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -696,19 +696,6 @@ FEATURES = { # .. toggle_tickets: https://github.com/openedx/edx-platform/pull/9744 'ENABLE_SPECIAL_EXAMS': False, - # .. toggle_name: FEATURES['ENABLE_OPENBADGES'] - # .. toggle_implementation: DjangoSetting - # .. toggle_default: False - # .. toggle_description: Enables support for the creation of OpenBadges as a method of awarding credentials. - # .. toggle_warning: The following settings (all of which are in the same file) should be set or reviewed prior to - # enabling this setting: BADGING_BACKEND, BADGR_API_TOKEN, BADGR_BASE_URL, BADGR_ISSUER_SLUG, BADGR_TIMEOUT. - # Full guide for setting up OpenBadges available here: - # https://edx.readthedocs.io/projects/edx-installing-configuring-and-running/en/latest/configuration/enable_badging.html pylint: disable=line-too-long,useless-suppression - # .. toggle_use_cases: open_edx - # .. toggle_creation_date: 2015-04-30 - # .. toggle_tickets: https://openedx.atlassian.net/browse/SOL-1325 - 'ENABLE_OPENBADGES': False, - # .. toggle_name: FEATURES['ENABLE_LTI_PROVIDER'] # .. toggle_implementation: DjangoSetting # .. toggle_default: False @@ -1039,6 +1026,18 @@ FEATURES = { # .. toggle_creation_date: 2022-06-06 # .. toggle_tickets: 'https://github.com/edx/edx-platform/pull/29538' 'DISABLE_ALLOWED_ENROLLMENT_IF_ENROLLMENT_CLOSED': False, + + # .. toggle_name: FEATURES['SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS'] + # .. toggle_implementation: SettingToggle + # .. toggle_default: False + # .. toggle_description: When True, the system will publish certificate lifecycle signals to the event bus. + # This toggle is used to create the EVENT_BUS_PRODUCER_CONFIG setting. + # .. toggle_warning: The default may be changed in a later release. See + # https://github.com/openedx/openedx-events/issues/265 + # .. toggle_use_cases: opt_in + # .. toggle_creation_date: 2023-10-10 + # .. toggle_tickets: https://github.com/openedx/openedx-events/issues/210 + 'SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS': False } # Specifies extra XBlock fields that should available when requested via the Course Blocks API @@ -1734,8 +1733,8 @@ DATABASES = { DEFAULT_AUTO_FIELD = 'django.db.models.AutoField' +# This will be overridden through LMS config DEFAULT_HASHING_ALGORITHM = 'sha1' - #################### Python sandbox ############################################ CODE_JAIL = { @@ -3304,6 +3303,7 @@ INSTALLED_APPS = [ # Notifications 'openedx.core.djangoapps.notifications', + 'openedx_events', ] ######################### CSRF ######################################### @@ -3653,68 +3653,6 @@ REGISTRATION_EMAIL_PATTERNS_ALLOWED = None CERT_NAME_SHORT = "Certificate" CERT_NAME_LONG = "Certificate of Achievement" -#################### OpenBadges Settings ####################### - -# .. setting_name: BADGING_BACKEND -# .. setting_default: 'lms.djangoapps.badges.backends.badgr.BadgrBackend' -# .. setting_description: The backend service class (or callable) for creating OpenBadges. It must implement -# the interface provided by lms.djangoapps.badges.backends.base.BadgeBackend -# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context. -BADGING_BACKEND = 'lms.djangoapps.badges.backends.badgr.BadgrBackend' - -# .. setting_name: BADGR_BASE_URL -# .. setting_default: 'http://localhost:8005' -# .. setting_description: The base URL for the Badgr server. -# .. setting_warning: DO NOT include a trailing slash. Review FEATURES['ENABLE_OPENBADGES'] for further context. -BADGR_BASE_URL = "http://localhost:8005" - -# .. setting_name: BADGR_ISSUER_SLUG -# .. setting_default: 'example-issuer' -# .. setting_description: A string that is the slug for the Badgr issuer. The slug can be obtained from the URL of -# the Badgr Server page that displays the issuer. For example, in the URL -# http://exampleserver.com/issuer/test-issuer, the issuer slug is "test-issuer". -# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context. -BADGR_ISSUER_SLUG = "example-issuer" - -# .. setting_name: BADGR_USERNAME -# .. setting_default: None -# .. setting_description: The username for Badgr. You should set up an issuer application with Badgr -# (https://badgr.org/app-developers/). The username and password will then be used to create or renew -# OAuth2 tokens. -# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context. -BADGR_USERNAME = None - -# .. setting_name: BADGR_PASSWORD -# .. setting_default: None -# .. setting_description: The password for Badgr. You should set up an issuer application with Badgr -# (https://badgr.org/app-developers/). The username and password will then be used to create or renew -# OAuth2 tokens. -# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context. -BADGR_PASSWORD = None - -# .. setting_name: BADGR_TOKENS_CACHE_KEY -# .. setting_default: None -# .. setting_description: The cache key for Badgr API tokens. Once created, the tokens will be stored in cache. -# Define the key here for setting and retrieveing the tokens. -# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context. -BADGR_TOKENS_CACHE_KEY = None - -# .. setting_name: BADGR_TIMEOUT -# .. setting_default: 10 -# .. setting_description: Number of seconds to wait on the badging server when contacting it before giving up. -# .. setting_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context. -BADGR_TIMEOUT = 10 - -# .. toggle_name: BADGR_ENABLE_NOTIFICATIONS -# .. toggle_implementation: DjangoSetting -# .. toggle_default: False -# .. toggle_description: Optional setting for enabling email notifications. When set to "True", -# learners will be notified by email when they earn a badge. -# .. toggle_use_cases: open_edx -# .. toggle_creation_date: 2021-07-29 -# .. toggle_warning: Review FEATURES['ENABLE_OPENBADGES'] for further context. -BADGR_ENABLE_NOTIFICATIONS = False - ###################### Grade Downloads ###################### # These keys are used for all of our asynchronous downloadable files, including # the ones that contain information other than grades. @@ -3756,7 +3694,7 @@ AUTH_PASSWORD_VALIDATORS = [ { "NAME": "common.djangoapps.util.password_policy_validators.MinimumLengthValidator", "OPTIONS": { - "min_length": 2 + "min_length": 8 } }, { @@ -4195,9 +4133,6 @@ ACCOUNT_VISIBILITY_CONFIGURATION["bulk_shareable_fields"] = ( "level_of_education", 'social_links', 'time_zone', - - # Not an actual field, but used to signal whether badges should be public. - 'accomplishments_shared', ] ) @@ -5380,11 +5315,83 @@ SUBSCRIPTIONS_SERVICE_WORKER_USERNAME = 'subscriptions_worker' ############## NOTIFICATIONS EXPIRY ############## NOTIFICATIONS_EXPIRY = 60 EXPIRED_NOTIFICATIONS_DELETE_BATCH_SIZE = 10000 +NOTIFICATION_CREATION_BATCH_SIZE = 99 #### django-simple-history## # disable indexing on date field its coming from django-simple-history. SIMPLE_HISTORY_DATE_INDEX = False -#### Event bus publishing #### -## Will be more filled out as part of https://github.com/edx/edx-arch-experiments/issues/381 -EVENT_BUS_PRODUCER_CONFIG = {} + +def _should_send_certificate_events(settings): + return settings.FEATURES['SEND_LEARNING_CERTIFICATE_LIFECYCLE_EVENTS_TO_BUS'] + +#### Event bus producing #### +# .. setting_name: EVENT_BUS_PRODUCER_CONFIG +# .. setting_default: all events disabled +# .. setting_description: Dictionary of event_types mapped to dictionaries of topic to topic-related configuration. +# Each topic configuration dictionary contains +# * `enabled`: a toggle denoting whether the event will be published to the topic. These should be annotated +# according to +# https://edx.readthedocs.io/projects/edx-toggles/en/latest/how_to/documenting_new_feature_toggles.html +# * `event_key_field` which is a period-delimited string path to event data field to use as event key. +# Note: The topic names should not include environment prefix as it will be dynamically added based on +# EVENT_BUS_TOPIC_PREFIX setting. +EVENT_BUS_PRODUCER_CONFIG = { + 'org.openedx.learning.certificate.created.v1': { + 'learning-certificate-lifecycle': + {'event_key_field': 'certificate.course.course_key', 'enabled': _should_send_certificate_events}, + }, + 'org.openedx.learning.certificate.revoked.v1': { + 'learning-certificate-lifecycle': + {'event_key_field': 'certificate.course.course_key', 'enabled': _should_send_certificate_events}, + }, + 'org.openedx.learning.course.unenrollment.completed.v1': { + 'course-unenrollment-lifecycle': + {'event_key_field': 'enrollment.course.course_key', + # .. toggle_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.course.unenrollment.completed.v1'] + # ['course-unenrollment-lifecycle']['enabled'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Enables sending COURSE_UNENROLLMENT_COMPLETED events over the event bus. + # .. toggle_use_cases: opt_in + # .. toggle_creation_date: 2023-09-18 + # .. toggle_warning: The default may be changed in a later release. See + # https://github.com/openedx/openedx-events/issues/265 + # .. toggle_tickets: https://github.com/openedx/openedx-events/issues/210 + 'enabled': False}, + }, + 'org.openedx.learning.xblock.skill.verified.v1': { + 'learning-xblock-skill-verified': + {'event_key_field': 'xblock_info.usage_key', + # .. toggle_name: EVENT_BUS_PRODUCER_CONFIG['org.openedx.learning.xblock.skill.verified.v1'] + # ['learning-xblock-skill-verified']['enabled'] + # .. toggle_implementation: DjangoSetting + # .. toggle_default: False + # .. toggle_description: Enables sending xblock_skill_verified events over the event bus. + # .. toggle_use_cases: opt_in + # .. toggle_creation_date: 2023-09-18 + # .. toggle_warning: The default may be changed in a later release. See + # https://github.com/openedx/openedx-events/issues/265 + # .. toggle_tickets: https://github.com/openedx/openedx-events/issues/210 + 'enabled': False} + }, + # CMS events. These have to be copied over here because cms.common adds some derived entries as well, + # and the derivation fails if the keys are missing. If we ever fully decouple the lms and cms settings, + # we can remove these. + 'org.openedx.content_authoring.xblock.published.v1': { + 'course-authoring-xblock-lifecycle': + {'event_key_field': 'xblock_info.usage_key', 'enabled': False}, + }, + 'org.openedx.content_authoring.xblock.deleted.v1': { + 'course-authoring-xblock-lifecycle': + {'event_key_field': 'xblock_info.usage_key', 'enabled': False}, + }, + 'org.openedx.content_authoring.xblock.duplicated.v1': { + 'course-authoring-xblock-lifecycle': + {'event_key_field': 'xblock_info.usage_key', 'enabled': False}, + }, +} +derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.created.v1', + 'learning-certificate-lifecycle', 'enabled') +derived_collection_entry('EVENT_BUS_PRODUCER_CONFIG', 'org.openedx.learning.certificate.revoked.v1', + 'learning-certificate-lifecycle', 'enabled') diff --git a/lms/envs/production.py b/lms/envs/production.py index 0b48d461bb..1d7d4a5657 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -26,6 +26,7 @@ from corsheaders.defaults import default_headers as corsheaders_default_headers import django from django.core.exceptions import ImproperlyConfigured from edx_django_utils.plugins import add_plugins +from openedx_events.event_bus import merge_producer_configs from path import Path as path from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType @@ -84,6 +85,7 @@ with codecs.open(CONFIG_FILE, encoding='utf-8') as f: 'MKTG_URL_LINK_MAP', 'MKTG_URL_OVERRIDES', 'REST_FRAMEWORK', + 'EVENT_BUS_PRODUCER_CONFIG', ] for key in KEYS_WITH_MERGED_VALUES: if key in __config_copy__: @@ -1133,3 +1135,7 @@ AVAILABLE_DISCUSSION_TOURS = ENV_TOKENS.get('AVAILABLE_DISCUSSION_TOURS', []) ############## NOTIFICATIONS EXPIRY ############## NOTIFICATIONS_EXPIRY = ENV_TOKENS.get('NOTIFICATIONS_EXPIRY', NOTIFICATIONS_EXPIRY) + +############## Event bus producer ############## +EVENT_BUS_PRODUCER_CONFIG = merge_producer_configs(EVENT_BUS_PRODUCER_CONFIG, + ENV_TOKENS.get('EVENT_BUS_PRODUCER_CONFIG', {})) diff --git a/lms/envs/test.py b/lms/envs/test.py index 32352c8498..7d50c943a3 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -517,25 +517,35 @@ VIDEO_TRANSCRIPTS_SETTINGS = dict( ####################### Authentication Settings ########################## JWT_AUTH.update({ - 'JWT_PUBLIC_SIGNING_JWK_SET': ( - '{"keys": [{"kid": "BTZ9HA6K", "e": "AQAB", "kty": "RSA", "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6' - 'sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc' - '4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEu' - 'lLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ"}]}' - ), - 'JWT_PRIVATE_SIGNING_JWK': ( - '{"e": "AQAB", "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_Xv' - 'FUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZs' - 'gbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3x' - 'WTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0H' - 'ChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwO' - 'n5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5' - 'q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", "q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Lo' - 'z3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy' - '1KB6fCby0C9WE", "p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfH' - 'ayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", "kid": "BTZ9HA6K", "kty"' - ': "RSA"}' - ), + 'JWT_PUBLIC_SIGNING_JWK_SET': """ + { + "keys":[ + { + "kid":"BTZ9HA6K", + "e":"AQAB", + "kty":"RSA", + "n":"o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + } + ] + } + """, + 'JWT_PRIVATE_SIGNING_JWK': """ + { + "kid": "BTZ9HA6K", + "kty": "RSA", + "key_ops": [ + "sign" + ], + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "e": "AQAB", + "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", + "p": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", + "q": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", + "dp": "Azh08H8r2_sJuBXAzx_mQ6iZnAZQ619PnJFOXjTqnMgcaK8iSHLL2CgDIUQwteUcBphgP0uBrfWIBs5jmM8rUtVz4CcrPb5jdjhHjuu4NxmnFbPlhNoOp8OBUjPP3S-h-fPoaFjxDrUqz_zCdPVzp4S6UTkf6Hu-SiI9CFVFZ8E", + "dq": "WQ44_KTIbIej9qnYUPMA1DoaAF8ImVDIdiOp9c79dC7FvCpN3w-lnuugrYDM1j9Tk5bRrY7-JuE6OaKQgOtajoS1BIxjYHj5xAVPD15CVevOihqeq5Zx0ZAAYmmCKRrfUe0iLx2QnIcoKH1-Azs23OXeeo6nysznZjvv9NVJv60", + "qi": "KSWGH607H1kNG2okjYdmVdNgLxTUB-Wye9a9FNFE49UmQIOJeZYXtDzcjk8IiK3g-EU3CqBeDKVUgHvHFu4_Wj3IrIhKYizS4BeFmOcPDvylDQCmJcC9tXLQgHkxM_MEJ7iLn9FOLRshh7GPgZphXxMhezM26Cz-8r3_mACHu84" + } + """, }) # pylint: enable=unicode-format-string # lint-amnesty, pylint: disable=bad-option-value ####################### Plugin Settings ########################## diff --git a/lms/static/js/certificates/certificates.js b/lms/static/js/certificates/certificates.js index 6cc19796c4..e69de29bb2 100644 --- a/lms/static/js/certificates/certificates.js +++ b/lms/static/js/certificates/certificates.js @@ -1,11 +0,0 @@ -$(function() { - 'use strict'; - - $('.action-share-mozillaopenbadges').click(function(event) { - $('.badges-overlay').fadeIn(); - event.preventDefault(); - }); - $('.badges-modal .close').click(function() { - $('.badges-overlay').fadeOut(); - }); -}); diff --git a/lms/static/js/i18n/eo/djangojs.js b/lms/static/js/i18n/eo/djangojs.js index 5ef96bc3e6..8b51840c37 100644 --- a/lms/static/js/i18n/eo/djangojs.js +++ b/lms/static/js/i18n/eo/djangojs.js @@ -132,8 +132,6 @@ "Access to some content in this {blockType} is restricted to specific groups of learners.": "\u00c0\u00e7\u00e7\u00e9ss t\u00f6 s\u00f6m\u00e9 \u00e7\u00f6nt\u00e9nt \u00efn th\u00efs {blockType} \u00efs r\u00e9str\u00ef\u00e7t\u00e9d t\u00f6 sp\u00e9\u00e7\u00eff\u00ef\u00e7 gr\u00f6\u00fcps \u00f6f l\u00e9\u00e4rn\u00e9rs. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#", "Access to this unit is restricted to: {selectedGroupsLabel}": "\u00c0\u00e7\u00e7\u00e9ss t\u00f6 th\u00efs \u00fcn\u00eft \u00efs r\u00e9str\u00ef\u00e7t\u00e9d t\u00f6: {selectedGroupsLabel} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #", "Access to this {blockType} is restricted to: {selectedGroupsLabel}": "\u00c0\u00e7\u00e7\u00e9ss t\u00f6 th\u00efs {blockType} \u00efs r\u00e9str\u00ef\u00e7t\u00e9d t\u00f6: {selectedGroupsLabel} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f#", - "Accomplishments": "\u00c0\u00e7\u00e7\u00f6mpl\u00efshm\u00e9nts \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1#", - "Accomplishments Pagination": "\u00c0\u00e7\u00e7\u00f6mpl\u00efshm\u00e9nts P\u00e4g\u00efn\u00e4t\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455#", "According to our records, you are not enrolled in any courses included in your {programName} program subscription. Enroll in a course from the {i_start}Program Details{i_end} page.": "\u00c0\u00e7\u00e7\u00f6rd\u00efng t\u00f6 \u00f6\u00fcr r\u00e9\u00e7\u00f6rds, \u00fd\u00f6\u00fc \u00e4r\u00e9 n\u00f6t \u00e9nr\u00f6ll\u00e9d \u00efn \u00e4n\u00fd \u00e7\u00f6\u00fcrs\u00e9s \u00efn\u00e7l\u00fcd\u00e9d \u00efn \u00fd\u00f6\u00fcr {programName} pr\u00f6gr\u00e4m s\u00fc\u00dfs\u00e7r\u00efpt\u00ef\u00f6n. \u00c9nr\u00f6ll \u00efn \u00e4 \u00e7\u00f6\u00fcrs\u00e9 fr\u00f6m th\u00e9 {i_start}Pr\u00f6gr\u00e4m D\u00e9t\u00e4\u00efls{i_end} p\u00e4g\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 \u00a2\u03c5\u0142\u03c1\u03b1 q\u03c5\u03b9 \u03c3\u0192\u0192\u03b9\u00a2\u03b9\u03b1 \u2202\u0454\u0455\u0454\u044f\u03c5#", "Account": "\u00c0\u00e7\u00e7\u00f6\u00fcnt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c #", "Account Information": "\u00c0\u00e7\u00e7\u00f6\u00fcnt \u00ccnf\u00f6rm\u00e4t\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442,#", @@ -538,7 +536,6 @@ "Create a content group": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4 \u00e7\u00f6nt\u00e9nt gr\u00f6\u00fcp \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#", "Create a new team if you can't find an existing team to join, or if you would like to learn with friends you know.": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4 n\u00e9w t\u00e9\u00e4m \u00eff \u00fd\u00f6\u00fc \u00e7\u00e4n't f\u00efnd \u00e4n \u00e9x\u00efst\u00efng t\u00e9\u00e4m t\u00f6 j\u00f6\u00efn, \u00f6r \u00eff \u00fd\u00f6\u00fc w\u00f6\u00fcld l\u00efk\u00e9 t\u00f6 l\u00e9\u00e4rn w\u00efth fr\u00ef\u00e9nds \u00fd\u00f6\u00fc kn\u00f6w. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142#", "Create a report of problem responses": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4 r\u00e9p\u00f6rt \u00f6f pr\u00f6\u00dfl\u00e9m r\u00e9sp\u00f6ns\u00e9s \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", - "Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4 {link_start}M\u00f6z\u00efll\u00e4 B\u00e4\u00e7kp\u00e4\u00e7k{link_end} \u00e4\u00e7\u00e7\u00f6\u00fcnt, \u00f6r l\u00f6g \u00efn t\u00f6 \u00fd\u00f6\u00fcr \u00e9x\u00efst\u00efng \u00e4\u00e7\u00e7\u00f6\u00fcnt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", "Create account using %(providerName)s.": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4\u00e7\u00e7\u00f6\u00fcnt \u00fcs\u00efng %(providerName)s. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455#", "Create an Account": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4n \u00c0\u00e7\u00e7\u00f6\u00fcnt \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454#", "Create an Account.": "\u00c7r\u00e9\u00e4t\u00e9 \u00e4n \u00c0\u00e7\u00e7\u00f6\u00fcnt. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442#", @@ -651,7 +648,6 @@ "Duplicate": "D\u00fcpl\u00ef\u00e7\u00e4t\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142#", "Duplicating": "D\u00fcpl\u00ef\u00e7\u00e4t\u00efng \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #", "Duration (sec)": "D\u00fcr\u00e4t\u00ef\u00f6n (s\u00e9\u00e7) \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442#", - "Earned %(created)s.": "\u00c9\u00e4rn\u00e9d %(created)s. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #", "Earned Certificates": "\u00c9\u00e4rn\u00e9d \u00c7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9s \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442,#", "EdX doesn't have a timed transcript for this video in Studio, but we found a transcript on YouTube. You can import the YouTube transcript or upload your own .srt transcript file.": "\u00c9dX d\u00f6\u00e9sn't h\u00e4v\u00e9 \u00e4 t\u00efm\u00e9d tr\u00e4ns\u00e7r\u00efpt f\u00f6r th\u00efs v\u00efd\u00e9\u00f6 \u00efn St\u00fcd\u00ef\u00f6, \u00df\u00fct w\u00e9 f\u00f6\u00fcnd \u00e4 tr\u00e4ns\u00e7r\u00efpt \u00f6n \u00dd\u00f6\u00fcT\u00fc\u00df\u00e9. \u00dd\u00f6\u00fc \u00e7\u00e4n \u00efmp\u00f6rt th\u00e9 \u00dd\u00f6\u00fcT\u00fc\u00df\u00e9 tr\u00e4ns\u00e7r\u00efpt \u00f6r \u00fcpl\u00f6\u00e4d \u00fd\u00f6\u00fcr \u00f6wn .srt tr\u00e4ns\u00e7r\u00efpt f\u00efl\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 #", "EdX doesn\\'t have a timed transcript for this video. Please upload an .srt file.": "\u00c9dX d\u00f6\u00e9sn\\'t h\u00e4v\u00e9 \u00e4 t\u00efm\u00e9d tr\u00e4ns\u00e7r\u00efpt f\u00f6r th\u00efs v\u00efd\u00e9\u00f6. Pl\u00e9\u00e4s\u00e9 \u00fcpl\u00f6\u00e4d \u00e4n .srt f\u00efl\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#", @@ -823,7 +819,6 @@ "Financial Assistance": "F\u00efn\u00e4n\u00e7\u00ef\u00e4l \u00c0ss\u00efst\u00e4n\u00e7\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, #", "Financial Assistance Application": "F\u00efn\u00e4n\u00e7\u00ef\u00e4l \u00c0ss\u00efst\u00e4n\u00e7\u00e9 \u00c0ppl\u00ef\u00e7\u00e4t\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#", "Find": "F\u00efnd \u2c60'\u03c3\u044f\u0454\u043c \u03b9#", - "Find a course": "F\u00efnd \u00e4 \u00e7\u00f6\u00fcrs\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9#", "Find and replace": "F\u00efnd \u00e4nd r\u00e9pl\u00e4\u00e7\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c#", "Find answers to the top questions asked by learners.": "F\u00efnd \u00e4nsw\u00e9rs t\u00f6 th\u00e9 t\u00f6p q\u00fc\u00e9st\u00ef\u00f6ns \u00e4sk\u00e9d \u00df\u00fd l\u00e9\u00e4rn\u00e9rs. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#", "Find discussions": "F\u00efnd d\u00efs\u00e7\u00fcss\u00ef\u00f6ns \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c#", @@ -1576,10 +1571,7 @@ "Settings": "S\u00e9tt\u00efngs \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202#", "Settings updated": "S\u00e9tt\u00efngs \u00fcpd\u00e4t\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c#", "Setup Started": "S\u00e9t\u00fcp St\u00e4rt\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9#", - "Share": "Sh\u00e4r\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455#", "Share Alike": "Sh\u00e4r\u00e9 \u00c0l\u00efk\u00e9 \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #", - "Share on Mozilla Backpack": "Sh\u00e4r\u00e9 \u00f6n M\u00f6z\u00efll\u00e4 B\u00e4\u00e7kp\u00e4\u00e7k \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455#", - "Share your \"%(display_name)s\" award": "Sh\u00e4r\u00e9 \u00fd\u00f6\u00fcr \"%(display_name)s\" \u00e4w\u00e4rd \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#", "Short explanation": "Sh\u00f6rt \u00e9xpl\u00e4n\u00e4t\u00ef\u00f6n \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454#", "Show": "Montru", "Show All": "Sh\u00f6w \u00c0ll \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202#", @@ -1666,7 +1658,6 @@ "Start of transcript. Skip to the end.": "St\u00e4rt \u00f6f tr\u00e4ns\u00e7r\u00efpt. Sk\u00efp t\u00f6 th\u00e9 \u00e9nd. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", "Start regenerating certificates for students in this course?": "St\u00e4rt r\u00e9g\u00e9n\u00e9r\u00e4t\u00efng \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9s f\u00f6r st\u00fcd\u00e9nts \u00efn th\u00efs \u00e7\u00f6\u00fcrs\u00e9? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#", "Start search": "St\u00e4rt s\u00e9\u00e4r\u00e7h \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455#", - "Start working toward your next learning goal.": "St\u00e4rt w\u00f6rk\u00efng t\u00f6w\u00e4rd \u00fd\u00f6\u00fcr n\u00e9xt l\u00e9\u00e4rn\u00efng g\u00f6\u00e4l. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #", "Start {trialLength}-day free trial": "St\u00e4rt {trialLength}-d\u00e4\u00fd fr\u00e9\u00e9 tr\u00ef\u00e4l \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7#", "Started": "St\u00e4rt\u00e9d \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c #", "Started entrance exam rescore task for student '{student_id}'. Click the 'Show Task Status' button to see the status of the task.": "St\u00e4rt\u00e9d \u00e9ntr\u00e4n\u00e7\u00e9 \u00e9x\u00e4m r\u00e9s\u00e7\u00f6r\u00e9 t\u00e4sk f\u00f6r st\u00fcd\u00e9nt '{student_id}'. \u00c7l\u00ef\u00e7k th\u00e9 'Sh\u00f6w T\u00e4sk St\u00e4t\u00fcs' \u00df\u00fctt\u00f6n t\u00f6 s\u00e9\u00e9 th\u00e9 st\u00e4t\u00fcs \u00f6f th\u00e9 t\u00e4sk. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5#", @@ -1976,7 +1967,6 @@ "To receive a certificate, you must also verify your identity.": "T\u00f6 r\u00e9\u00e7\u00e9\u00efv\u00e9 \u00e4 \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9, \u00fd\u00f6\u00fc m\u00fcst \u00e4ls\u00f6 v\u00e9r\u00eff\u00fd \u00fd\u00f6\u00fcr \u00efd\u00e9nt\u00eft\u00fd. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1#", "To receive credit for problems, you must select \"Submit\" for each problem before you select \"End My Exam\".": "T\u00f6 r\u00e9\u00e7\u00e9\u00efv\u00e9 \u00e7r\u00e9d\u00eft f\u00f6r pr\u00f6\u00dfl\u00e9ms, \u00fd\u00f6\u00fc m\u00fcst s\u00e9l\u00e9\u00e7t \"S\u00fc\u00dfm\u00eft\" f\u00f6r \u00e9\u00e4\u00e7h pr\u00f6\u00dfl\u00e9m \u00df\u00e9f\u00f6r\u00e9 \u00fd\u00f6\u00fc s\u00e9l\u00e9\u00e7t \"\u00c9nd M\u00fd \u00c9x\u00e4m\". \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 #", "To review learner cohort assignments or see the results of uploading a CSV file, download course profile information or cohort results on the {link_start}Data Download{link_end} page.": "T\u00f6 r\u00e9v\u00ef\u00e9w l\u00e9\u00e4rn\u00e9r \u00e7\u00f6h\u00f6rt \u00e4ss\u00efgnm\u00e9nts \u00f6r s\u00e9\u00e9 th\u00e9 r\u00e9s\u00fclts \u00f6f \u00fcpl\u00f6\u00e4d\u00efng \u00e4 \u00c7SV f\u00efl\u00e9, d\u00f6wnl\u00f6\u00e4d \u00e7\u00f6\u00fcrs\u00e9 pr\u00f6f\u00efl\u00e9 \u00efnf\u00f6rm\u00e4t\u00ef\u00f6n \u00f6r \u00e7\u00f6h\u00f6rt r\u00e9s\u00fclts \u00f6n th\u00e9 {link_start}D\u00e4t\u00e4 D\u00f6wnl\u00f6\u00e4d{link_end} p\u00e4g\u00e9. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 \u00a2\u03c5\u0142\u03c1\u03b1 q\u03c5\u03b9 \u03c3\u0192\u0192\u03b9\u00a2#", - "To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.": "T\u00f6 sh\u00e4r\u00e9 \u00fd\u00f6\u00fcr \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9 \u00f6n M\u00f6z\u00efll\u00e4 B\u00e4\u00e7kp\u00e4\u00e7k, \u00fd\u00f6\u00fc m\u00fcst f\u00efrst h\u00e4v\u00e9 \u00e4 B\u00e4\u00e7kp\u00e4\u00e7k \u00e4\u00e7\u00e7\u00f6\u00fcnt. \u00c7\u00f6mpl\u00e9t\u00e9 th\u00e9 f\u00f6ll\u00f6w\u00efng st\u00e9ps t\u00f6 \u00e4dd \u00fd\u00f6\u00fcr \u00e7\u00e9rt\u00eff\u00ef\u00e7\u00e4t\u00e9 t\u00f6 B\u00e4\u00e7kp\u00e4\u00e7k. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 \u00a2\u03c5\u0142\u03c1\u03b1 q\u03c5\u03b9 \u03c3\u0192\u0192\u03b9\u00a2\u03b9\u03b1 \u2202\u0454\u0455\u0454\u044f\u03c5\u03b7\u0442 \u043c\u03c3\u0142\u0142\u03b9\u0442#", "To take a successful photo, make sure that:": "T\u00f6 t\u00e4k\u00e9 \u00e4 s\u00fc\u00e7\u00e7\u00e9ssf\u00fcl ph\u00f6t\u00f6, m\u00e4k\u00e9 s\u00fcr\u00e9 th\u00e4t: \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f #", "To take the photo of your face, click on the camera button {icon}. If you need to try again, click 'Retake Photo'.": "T\u00f6 t\u00e4k\u00e9 th\u00e9 ph\u00f6t\u00f6 \u00f6f \u00fd\u00f6\u00fcr f\u00e4\u00e7\u00e9, \u00e7l\u00ef\u00e7k \u00f6n th\u00e9 \u00e7\u00e4m\u00e9r\u00e4 \u00df\u00fctt\u00f6n {icon}. \u00ccf \u00fd\u00f6\u00fc n\u00e9\u00e9d t\u00f6 tr\u00fd \u00e4g\u00e4\u00efn, \u00e7l\u00ef\u00e7k 'R\u00e9t\u00e4k\u00e9 Ph\u00f6t\u00f6'. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f #", "To verify your identity, you need a webcam and a government-issued photo ID.": "T\u00f6 v\u00e9r\u00eff\u00fd \u00fd\u00f6\u00fcr \u00efd\u00e9nt\u00eft\u00fd, \u00fd\u00f6\u00fc n\u00e9\u00e9d \u00e4 w\u00e9\u00df\u00e7\u00e4m \u00e4nd \u00e4 g\u00f6v\u00e9rnm\u00e9nt-\u00efss\u00fc\u00e9d ph\u00f6t\u00f6 \u00ccD. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", @@ -2216,7 +2206,6 @@ "What if I have difficulty holding my ID in position relative to the camera?": "Wh\u00e4t \u00eff \u00cc h\u00e4v\u00e9 d\u00efff\u00ef\u00e7\u00fclt\u00fd h\u00f6ld\u00efng m\u00fd \u00ccD \u00efn p\u00f6s\u00eft\u00ef\u00f6n r\u00e9l\u00e4t\u00efv\u00e9 t\u00f6 th\u00e9 \u00e7\u00e4m\u00e9r\u00e4? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5#", "What if I have difficulty holding my head in position relative to the camera?": "Wh\u00e4t \u00eff \u00cc h\u00e4v\u00e9 d\u00efff\u00ef\u00e7\u00fclt\u00fd h\u00f6ld\u00efng m\u00fd h\u00e9\u00e4d \u00efn p\u00f6s\u00eft\u00ef\u00f6n r\u00e9l\u00e4t\u00efv\u00e9 t\u00f6 th\u00e9 \u00e7\u00e4m\u00e9r\u00e4? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442#", "What was the total combined income, during the last 12 months, of all members of your family? ": "Wh\u00e4t w\u00e4s th\u00e9 t\u00f6t\u00e4l \u00e7\u00f6m\u00df\u00efn\u00e9d \u00efn\u00e7\u00f6m\u00e9, d\u00fcr\u00efng th\u00e9 l\u00e4st 12 m\u00f6nths, \u00f6f \u00e4ll m\u00e9m\u00df\u00e9rs \u00f6f \u00fd\u00f6\u00fcr f\u00e4m\u00efl\u00fd? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#", - "What's Your Next Accomplishment?": "Wh\u00e4t's \u00dd\u00f6\u00fcr N\u00e9xt \u00c0\u00e7\u00e7\u00f6mpl\u00efshm\u00e9nt? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454#", "What's in my clipboard?": "Wh\u00e4t's \u00efn m\u00fd \u00e7l\u00efp\u00df\u00f6\u00e4rd? \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3#", "When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.": "Wh\u00e9n l\u00e9\u00e4rn\u00e9rs s\u00fc\u00dfm\u00eft \u00e4n \u00e4nsw\u00e9r t\u00f6 \u00e4n \u00e4ss\u00e9ssm\u00e9nt, th\u00e9\u00fd \u00efmm\u00e9d\u00ef\u00e4t\u00e9l\u00fd s\u00e9\u00e9 wh\u00e9th\u00e9r th\u00e9 \u00e4nsw\u00e9r \u00efs \u00e7\u00f6rr\u00e9\u00e7t \u00f6r \u00efn\u00e7\u00f6rr\u00e9\u00e7t, \u00e4nd th\u00e9 s\u00e7\u00f6r\u00e9 r\u00e9\u00e7\u00e9\u00efv\u00e9d. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2\u0442\u0454\u0442\u03c5\u044f \u03b1\u2202\u03b9\u03c1\u03b9\u0455\u03b9\u00a2\u03b9\u03b7g \u0454\u0142\u03b9\u0442, \u0455\u0454\u2202 \u2202\u03c3 \u0454\u03b9\u03c5\u0455\u043c\u03c3\u2202 \u0442\u0454\u043c\u03c1\u03c3\u044f \u03b9\u03b7\u00a2\u03b9\u2202\u03b9\u2202\u03c5\u03b7\u0442 \u03c5\u0442 \u0142\u03b1\u0432\u03c3\u044f\u0454 \u0454\u0442 \u2202\u03c3\u0142\u03c3\u044f\u0454 \u043c\u03b1g\u03b7\u03b1 \u03b1\u0142\u03b9q\u03c5\u03b1. \u03c5\u0442 \u0454\u03b7\u03b9\u043c \u03b1\u2202 \u043c\u03b9\u03b7\u03b9\u043c \u03bd\u0454\u03b7\u03b9\u03b1\u043c, q\u03c5\u03b9\u0455 \u03b7\u03c3\u0455\u0442\u044f\u03c5\u2202 \u0454\u03c7\u0454\u044f\u00a2\u03b9\u0442\u03b1\u0442\u03b9\u03c3\u03b7 \u03c5\u0142\u0142\u03b1\u043c\u00a2\u03c3 \u0142\u03b1\u0432\u03c3\u044f\u03b9\u0455 \u03b7\u03b9\u0455\u03b9 \u03c5\u0442 \u03b1\u0142\u03b9q\u03c5\u03b9\u03c1 \u0454\u03c7 \u0454\u03b1 \u00a2\u03c3\u043c\u043c\u03c3\u2202\u03c3 \u00a2\u03c3\u03b7\u0455\u0454q\u03c5\u03b1\u0442. \u2202\u03c5\u03b9\u0455 \u03b1\u03c5\u0442\u0454 \u03b9\u044f\u03c5\u044f\u0454 \u2202\u03c3\u0142\u03c3\u044f \u03b9\u03b7 \u044f\u0454\u03c1\u044f\u0454\u043d\u0454\u03b7\u2202\u0454\u044f\u03b9\u0442 \u03b9\u03b7 \u03bd\u03c3\u0142\u03c5\u03c1\u0442\u03b1\u0442\u0454 \u03bd\u0454\u0142\u03b9\u0442 \u0454\u0455\u0455\u0454 \u00a2\u03b9\u0142\u0142\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f\u0454 \u0454\u03c5 \u0192\u03c5g\u03b9\u03b1\u0442 \u03b7\u03c5\u0142\u0142\u03b1 \u03c1\u03b1\u044f\u03b9\u03b1\u0442\u03c5\u044f. \u0454\u03c7\u00a2\u0454\u03c1\u0442\u0454\u03c5\u044f \u0455\u03b9\u03b7\u0442 \u03c3\u00a2\u00a2\u03b1\u0454\u00a2\u03b1\u0442 \u00a2\u03c5\u03c1\u03b9\u2202\u03b1\u0442\u03b1\u0442 \u03b7\u03c3\u03b7 \u03c1\u044f\u03c3\u03b9\u2202\u0454\u03b7\u0442, \u0455\u03c5\u03b7\u0442 \u03b9\u03b7 \u00a2\u03c5\u0142\u03c1\u03b1 q\u03c5\u03b9 \u03c3\u0192\u0192\u03b9\u00a2\u03b9\u03b1 \u2202\u0454\u0455\u0454\u044f\u03c5\u03b7\u0442 \u043c\u03c3\u0142\u0142\u03b9\u0442 \u03b1\u03b7\u03b9\u043c \u03b9\u2202 \u0454\u0455\u0442 \u0142\u03b1\u0432\u03c3#", "When your face is in position, use the Take Photo button {icon} below to take your photo.": "Wh\u00e9n \u00fd\u00f6\u00fcr f\u00e4\u00e7\u00e9 \u00efs \u00efn p\u00f6s\u00eft\u00ef\u00f6n, \u00fcs\u00e9 th\u00e9 T\u00e4k\u00e9 Ph\u00f6t\u00f6 \u00df\u00fctt\u00f6n {icon} \u00df\u00e9l\u00f6w t\u00f6 t\u00e4k\u00e9 \u00fd\u00f6\u00fcr ph\u00f6t\u00f6. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3\u03b7\u0455\u0454\u00a2#", @@ -2497,7 +2486,6 @@ "{categoryText} in {parentDisplayname}": "{categoryText} \u00efn {parentDisplayname} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3#", "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} of {maxCharacters}": "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} \u00f6f {maxCharacters} \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c#", "{display_name} Settings": "{display_name} S\u00e9tt\u00efngs \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455#", - "{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack.": "{download_link_start}D\u00f6wnl\u00f6\u00e4d th\u00efs \u00efm\u00e4g\u00e9 (r\u00efght-\u00e7l\u00ef\u00e7k \u00f6r \u00f6pt\u00ef\u00f6n-\u00e7l\u00ef\u00e7k, s\u00e4v\u00e9 \u00e4s){link_end} \u00e4nd th\u00e9n {upload_link_start}\u00fcpl\u00f6\u00e4d{link_end} \u00eft t\u00f6 \u00fd\u00f6\u00fcr \u00df\u00e4\u00e7kp\u00e4\u00e7k. \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442#", "{earned}/{possible} point (graded)": [ "{earned}/{possible} p\u00f6\u00efnt (gr\u00e4d\u00e9d) \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2#", "{earned}/{possible} p\u00f6\u00efnts (gr\u00e4d\u00e9d) \u2c60'\u03c3\u044f\u0454\u043c \u03b9\u03c1\u0455\u03c5\u043c \u2202\u03c3\u0142\u03c3\u044f \u0455\u03b9\u0442 \u03b1\u043c\u0454\u0442, \u00a2\u03c3#" diff --git a/lms/static/js/i18n/rtl/djangojs.js b/lms/static/js/i18n/rtl/djangojs.js index 6917e187da..b94b5774de 100644 --- a/lms/static/js/i18n/rtl/djangojs.js +++ b/lms/static/js/i18n/rtl/djangojs.js @@ -125,8 +125,6 @@ "Access to some content in this {blockType} is restricted to specific groups of learners.": "\u023a\u0254\u0254\u01ddss \u0287\u00f8 s\u00f8\u026f\u01dd \u0254\u00f8n\u0287\u01ddn\u0287 \u1d09n \u0287\u0265\u1d09s {blockType} \u1d09s \u0279\u01dds\u0287\u0279\u1d09\u0254\u0287\u01ddd \u0287\u00f8 sd\u01dd\u0254\u1d09\u025f\u1d09\u0254 \u0183\u0279\u00f8nds \u00f8\u025f l\u01dd\u0250\u0279n\u01dd\u0279s.", "Access to this unit is restricted to: {selectedGroupsLabel}": "\u023a\u0254\u0254\u01ddss \u0287\u00f8 \u0287\u0265\u1d09s nn\u1d09\u0287 \u1d09s \u0279\u01dds\u0287\u0279\u1d09\u0254\u0287\u01ddd \u0287\u00f8: {selectedGroupsLabel}", "Access to this {blockType} is restricted to: {selectedGroupsLabel}": "\u023a\u0254\u0254\u01ddss \u0287\u00f8 \u0287\u0265\u1d09s {blockType} \u1d09s \u0279\u01dds\u0287\u0279\u1d09\u0254\u0287\u01ddd \u0287\u00f8: {selectedGroupsLabel}", - "Accomplishments": "\u023a\u0254\u0254\u00f8\u026fdl\u1d09s\u0265\u026f\u01ddn\u0287s", - "Accomplishments Pagination": "\u023a\u0254\u0254\u00f8\u026fdl\u1d09s\u0265\u026f\u01ddn\u0287s \u2c63\u0250\u0183\u1d09n\u0250\u0287\u1d09\u00f8n", "According to our records, you are not enrolled in any courses included in your {programName} program subscription. Enroll in a course from the {i_start}Program Details{i_end} page.": "\u023a\u0254\u0254\u00f8\u0279d\u1d09n\u0183 \u0287\u00f8 \u00f8n\u0279 \u0279\u01dd\u0254\u00f8\u0279ds, \u028e\u00f8n \u0250\u0279\u01dd n\u00f8\u0287 \u01ddn\u0279\u00f8ll\u01ddd \u1d09n \u0250n\u028e \u0254\u00f8n\u0279s\u01dds \u1d09n\u0254lnd\u01ddd \u1d09n \u028e\u00f8n\u0279 {programName} d\u0279\u00f8\u0183\u0279\u0250\u026f snbs\u0254\u0279\u1d09d\u0287\u1d09\u00f8n. \u0246n\u0279\u00f8ll \u1d09n \u0250 \u0254\u00f8n\u0279s\u01dd \u025f\u0279\u00f8\u026f \u0287\u0265\u01dd {i_start}\u2c63\u0279\u00f8\u0183\u0279\u0250\u026f \u0110\u01dd\u0287\u0250\u1d09ls{i_end} d\u0250\u0183\u01dd.", "Account": "\u023a\u0254\u0254\u00f8nn\u0287", "Account Information": "\u023a\u0254\u0254\u00f8nn\u0287 \u0197n\u025f\u00f8\u0279\u026f\u0250\u0287\u1d09\u00f8n", @@ -500,7 +498,6 @@ "Create a content group": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250 \u0254\u00f8n\u0287\u01ddn\u0287 \u0183\u0279\u00f8nd", "Create a new team if you can't find an existing team to join, or if you would like to learn with friends you know.": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250 n\u01dd\u028d \u0287\u01dd\u0250\u026f \u1d09\u025f \u028e\u00f8n \u0254\u0250n'\u0287 \u025f\u1d09nd \u0250n \u01ddx\u1d09s\u0287\u1d09n\u0183 \u0287\u01dd\u0250\u026f \u0287\u00f8 \u027e\u00f8\u1d09n, \u00f8\u0279 \u1d09\u025f \u028e\u00f8n \u028d\u00f8nld l\u1d09\u029e\u01dd \u0287\u00f8 l\u01dd\u0250\u0279n \u028d\u1d09\u0287\u0265 \u025f\u0279\u1d09\u01ddnds \u028e\u00f8n \u029en\u00f8\u028d.", "Create a report of problem responses": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250 \u0279\u01ddd\u00f8\u0279\u0287 \u00f8\u025f d\u0279\u00f8bl\u01dd\u026f \u0279\u01ddsd\u00f8ns\u01dds", - "Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250 {link_start}M\u00f8z\u1d09ll\u0250 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e{link_end} \u0250\u0254\u0254\u00f8nn\u0287, \u00f8\u0279 l\u00f8\u0183 \u1d09n \u0287\u00f8 \u028e\u00f8n\u0279 \u01ddx\u1d09s\u0287\u1d09n\u0183 \u0250\u0254\u0254\u00f8nn\u0287", "Create account using %(providerName)s.": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250\u0254\u0254\u00f8nn\u0287 ns\u1d09n\u0183 %(providerName)s.", "Create an Account": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250n \u023a\u0254\u0254\u00f8nn\u0287", "Create an Account.": "\u023b\u0279\u01dd\u0250\u0287\u01dd \u0250n \u023a\u0254\u0254\u00f8nn\u0287.", @@ -604,7 +601,6 @@ "Duplicate": "\u0110ndl\u1d09\u0254\u0250\u0287\u01dd", "Duplicating": "\u0110ndl\u1d09\u0254\u0250\u0287\u1d09n\u0183", "Duration (sec)": "\u0110n\u0279\u0250\u0287\u1d09\u00f8n (s\u01dd\u0254)", - "Earned %(created)s.": "\u0246\u0250\u0279n\u01ddd %(created)s.", "Earned Certificates": "\u0246\u0250\u0279n\u01ddd \u023b\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dds", "EdX doesn't have a timed transcript for this video in Studio, but we found a transcript on YouTube. You can import the YouTube transcript or upload your own .srt transcript file.": "\u0246dX d\u00f8\u01ddsn'\u0287 \u0265\u0250\u028c\u01dd \u0250 \u0287\u1d09\u026f\u01ddd \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u025f\u00f8\u0279 \u0287\u0265\u1d09s \u028c\u1d09d\u01dd\u00f8 \u1d09n S\u0287nd\u1d09\u00f8, bn\u0287 \u028d\u01dd \u025f\u00f8nnd \u0250 \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u00f8n \u024e\u00f8n\u0166nb\u01dd. \u024e\u00f8n \u0254\u0250n \u1d09\u026fd\u00f8\u0279\u0287 \u0287\u0265\u01dd \u024e\u00f8n\u0166nb\u01dd \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u00f8\u0279 ndl\u00f8\u0250d \u028e\u00f8n\u0279 \u00f8\u028dn .s\u0279\u0287 \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u025f\u1d09l\u01dd.", "EdX doesn\\'t have a timed transcript for this video. Please upload an .srt file.": "\u0246dX d\u00f8\u01ddsn\\'\u0287 \u0265\u0250\u028c\u01dd \u0250 \u0287\u1d09\u026f\u01ddd \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287 \u025f\u00f8\u0279 \u0287\u0265\u1d09s \u028c\u1d09d\u01dd\u00f8. \u2c63l\u01dd\u0250s\u01dd ndl\u00f8\u0250d \u0250n .s\u0279\u0287 \u025f\u1d09l\u01dd.", @@ -761,7 +757,6 @@ "Financial Assistance": "F\u1d09n\u0250n\u0254\u1d09\u0250l \u023ass\u1d09s\u0287\u0250n\u0254\u01dd", "Financial Assistance Application": "F\u1d09n\u0250n\u0254\u1d09\u0250l \u023ass\u1d09s\u0287\u0250n\u0254\u01dd \u023addl\u1d09\u0254\u0250\u0287\u1d09\u00f8n", "Find": "F\u1d09nd", - "Find a course": "F\u1d09nd \u0250 \u0254\u00f8n\u0279s\u01dd", "Find and replace": "F\u1d09nd \u0250nd \u0279\u01dddl\u0250\u0254\u01dd", "Find answers to the top questions asked by learners.": "F\u1d09nd \u0250ns\u028d\u01dd\u0279s \u0287\u00f8 \u0287\u0265\u01dd \u0287\u00f8d bn\u01dds\u0287\u1d09\u00f8ns \u0250s\u029e\u01ddd b\u028e l\u01dd\u0250\u0279n\u01dd\u0279s.", "Find discussions": "F\u1d09nd d\u1d09s\u0254nss\u1d09\u00f8ns", @@ -1449,10 +1444,7 @@ "Set up your certificate": "S\u01dd\u0287 nd \u028e\u00f8n\u0279 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd", "Settings": "S\u01dd\u0287\u0287\u1d09n\u0183s", "Settings updated": "S\u01dd\u0287\u0287\u1d09n\u0183s ndd\u0250\u0287\u01ddd", - "Share": "S\u0265\u0250\u0279\u01dd", "Share Alike": "S\u0265\u0250\u0279\u01dd \u023al\u1d09\u029e\u01dd", - "Share on Mozilla Backpack": "S\u0265\u0250\u0279\u01dd \u00f8n M\u00f8z\u1d09ll\u0250 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e", - "Share your \"%(display_name)s\" award": "S\u0265\u0250\u0279\u01dd \u028e\u00f8n\u0279 \"%(display_name)s\" \u0250\u028d\u0250\u0279d", "Short explanation": "S\u0265\u00f8\u0279\u0287 \u01ddxdl\u0250n\u0250\u0287\u1d09\u00f8n", "Show All": "S\u0265\u00f8\u028d \u023all", "Show Annotations": "S\u0265\u00f8\u028d \u023ann\u00f8\u0287\u0250\u0287\u1d09\u00f8ns", @@ -1532,7 +1524,6 @@ "Start of transcript. Skip to the end.": "S\u0287\u0250\u0279\u0287 \u00f8\u025f \u0287\u0279\u0250ns\u0254\u0279\u1d09d\u0287. S\u029e\u1d09d \u0287\u00f8 \u0287\u0265\u01dd \u01ddnd.", "Start regenerating certificates for students in this course?": "S\u0287\u0250\u0279\u0287 \u0279\u01dd\u0183\u01ddn\u01dd\u0279\u0250\u0287\u1d09n\u0183 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dds \u025f\u00f8\u0279 s\u0287nd\u01ddn\u0287s \u1d09n \u0287\u0265\u1d09s \u0254\u00f8n\u0279s\u01dd?", "Start search": "S\u0287\u0250\u0279\u0287 s\u01dd\u0250\u0279\u0254\u0265", - "Start working toward your next learning goal.": "S\u0287\u0250\u0279\u0287 \u028d\u00f8\u0279\u029e\u1d09n\u0183 \u0287\u00f8\u028d\u0250\u0279d \u028e\u00f8n\u0279 n\u01ddx\u0287 l\u01dd\u0250\u0279n\u1d09n\u0183 \u0183\u00f8\u0250l.", "Start {trialLength}-day free trial": "S\u0287\u0250\u0279\u0287 {trialLength}-d\u0250\u028e \u025f\u0279\u01dd\u01dd \u0287\u0279\u1d09\u0250l", "Started entrance exam rescore task for student '{student_id}'. Click the 'Show Task Status' button to see the status of the task.": "S\u0287\u0250\u0279\u0287\u01ddd \u01ddn\u0287\u0279\u0250n\u0254\u01dd \u01ddx\u0250\u026f \u0279\u01dds\u0254\u00f8\u0279\u01dd \u0287\u0250s\u029e \u025f\u00f8\u0279 s\u0287nd\u01ddn\u0287 '{student_id}'. \u023bl\u1d09\u0254\u029e \u0287\u0265\u01dd 'S\u0265\u00f8\u028d \u0166\u0250s\u029e S\u0287\u0250\u0287ns' bn\u0287\u0287\u00f8n \u0287\u00f8 s\u01dd\u01dd \u0287\u0265\u01dd s\u0287\u0250\u0287ns \u00f8\u025f \u0287\u0265\u01dd \u0287\u0250s\u029e.", "Started rescore problem task for problem '<%- problem_id %>' and student '<%- student_id %>'. Click the 'Show Task Status' button to see the status of the task.": "S\u0287\u0250\u0279\u0287\u01ddd \u0279\u01dds\u0254\u00f8\u0279\u01dd d\u0279\u00f8bl\u01dd\u026f \u0287\u0250s\u029e \u025f\u00f8\u0279 d\u0279\u00f8bl\u01dd\u026f '<%- problem_id %>' \u0250nd s\u0287nd\u01ddn\u0287 '<%- student_id %>'. \u023bl\u1d09\u0254\u029e \u0287\u0265\u01dd 'S\u0265\u00f8\u028d \u0166\u0250s\u029e S\u0287\u0250\u0287ns' bn\u0287\u0287\u00f8n \u0287\u00f8 s\u01dd\u01dd \u0287\u0265\u01dd s\u0287\u0250\u0287ns \u00f8\u025f \u0287\u0265\u01dd \u0287\u0250s\u029e.", @@ -1820,7 +1811,6 @@ "To receive a certificate, you must also verify your identity.": "\u0166\u00f8 \u0279\u01dd\u0254\u01dd\u1d09\u028c\u01dd \u0250 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd, \u028e\u00f8n \u026fns\u0287 \u0250ls\u00f8 \u028c\u01dd\u0279\u1d09\u025f\u028e \u028e\u00f8n\u0279 \u1d09d\u01ddn\u0287\u1d09\u0287\u028e.", "To receive credit for problems, you must select \"Submit\" for each problem before you select \"End My Exam\".": "\u0166\u00f8 \u0279\u01dd\u0254\u01dd\u1d09\u028c\u01dd \u0254\u0279\u01ddd\u1d09\u0287 \u025f\u00f8\u0279 d\u0279\u00f8bl\u01dd\u026fs, \u028e\u00f8n \u026fns\u0287 s\u01ddl\u01dd\u0254\u0287 \"Snb\u026f\u1d09\u0287\" \u025f\u00f8\u0279 \u01dd\u0250\u0254\u0265 d\u0279\u00f8bl\u01dd\u026f b\u01dd\u025f\u00f8\u0279\u01dd \u028e\u00f8n s\u01ddl\u01dd\u0254\u0287 \"\u0246nd M\u028e \u0246x\u0250\u026f\".", "To review learner cohort assignments or see the results of uploading a CSV file, download course profile information or cohort results on the {link_start}Data Download{link_end} page.": "\u0166\u00f8 \u0279\u01dd\u028c\u1d09\u01dd\u028d l\u01dd\u0250\u0279n\u01dd\u0279 \u0254\u00f8\u0265\u00f8\u0279\u0287 \u0250ss\u1d09\u0183n\u026f\u01ddn\u0287s \u00f8\u0279 s\u01dd\u01dd \u0287\u0265\u01dd \u0279\u01ddsnl\u0287s \u00f8\u025f ndl\u00f8\u0250d\u1d09n\u0183 \u0250 \u023bSV \u025f\u1d09l\u01dd, d\u00f8\u028dnl\u00f8\u0250d \u0254\u00f8n\u0279s\u01dd d\u0279\u00f8\u025f\u1d09l\u01dd \u1d09n\u025f\u00f8\u0279\u026f\u0250\u0287\u1d09\u00f8n \u00f8\u0279 \u0254\u00f8\u0265\u00f8\u0279\u0287 \u0279\u01ddsnl\u0287s \u00f8n \u0287\u0265\u01dd {link_start}\u0110\u0250\u0287\u0250 \u0110\u00f8\u028dnl\u00f8\u0250d{link_end} d\u0250\u0183\u01dd.", - "To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.": "\u0166\u00f8 s\u0265\u0250\u0279\u01dd \u028e\u00f8n\u0279 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd \u00f8n M\u00f8z\u1d09ll\u0250 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e, \u028e\u00f8n \u026fns\u0287 \u025f\u1d09\u0279s\u0287 \u0265\u0250\u028c\u01dd \u0250 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e \u0250\u0254\u0254\u00f8nn\u0287. \u023b\u00f8\u026fdl\u01dd\u0287\u01dd \u0287\u0265\u01dd \u025f\u00f8ll\u00f8\u028d\u1d09n\u0183 s\u0287\u01ddds \u0287\u00f8 \u0250dd \u028e\u00f8n\u0279 \u0254\u01dd\u0279\u0287\u1d09\u025f\u1d09\u0254\u0250\u0287\u01dd \u0287\u00f8 \u0243\u0250\u0254\u029ed\u0250\u0254\u029e.", "To take a successful photo, make sure that:": "\u0166\u00f8 \u0287\u0250\u029e\u01dd \u0250 sn\u0254\u0254\u01ddss\u025fnl d\u0265\u00f8\u0287\u00f8, \u026f\u0250\u029e\u01dd sn\u0279\u01dd \u0287\u0265\u0250\u0287:", "To take the photo of your face, click on the camera button {icon}. If you need to try again, click 'Retake Photo'.": "\u0166\u00f8 \u0287\u0250\u029e\u01dd \u0287\u0265\u01dd d\u0265\u00f8\u0287\u00f8 \u00f8\u025f \u028e\u00f8n\u0279 \u025f\u0250\u0254\u01dd, \u0254l\u1d09\u0254\u029e \u00f8n \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250 bn\u0287\u0287\u00f8n {icon}. \u0197\u025f \u028e\u00f8n n\u01dd\u01ddd \u0287\u00f8 \u0287\u0279\u028e \u0250\u0183\u0250\u1d09n, \u0254l\u1d09\u0254\u029e '\u024c\u01dd\u0287\u0250\u029e\u01dd \u2c63\u0265\u00f8\u0287\u00f8'.", "To verify your identity, you need a webcam and a government-issued photo ID.": "\u0166\u00f8 \u028c\u01dd\u0279\u1d09\u025f\u028e \u028e\u00f8n\u0279 \u1d09d\u01ddn\u0287\u1d09\u0287\u028e, \u028e\u00f8n n\u01dd\u01ddd \u0250 \u028d\u01ddb\u0254\u0250\u026f \u0250nd \u0250 \u0183\u00f8\u028c\u01dd\u0279n\u026f\u01ddn\u0287-\u1d09ssn\u01ddd d\u0265\u00f8\u0287\u00f8 \u0197\u0110.", @@ -2046,7 +2036,6 @@ "What if I have difficulty holding my ID in position relative to the camera?": "W\u0265\u0250\u0287 \u1d09\u025f \u0197 \u0265\u0250\u028c\u01dd d\u1d09\u025f\u025f\u1d09\u0254nl\u0287\u028e \u0265\u00f8ld\u1d09n\u0183 \u026f\u028e \u0197\u0110 \u1d09n d\u00f8s\u1d09\u0287\u1d09\u00f8n \u0279\u01ddl\u0250\u0287\u1d09\u028c\u01dd \u0287\u00f8 \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250?", "What if I have difficulty holding my head in position relative to the camera?": "W\u0265\u0250\u0287 \u1d09\u025f \u0197 \u0265\u0250\u028c\u01dd d\u1d09\u025f\u025f\u1d09\u0254nl\u0287\u028e \u0265\u00f8ld\u1d09n\u0183 \u026f\u028e \u0265\u01dd\u0250d \u1d09n d\u00f8s\u1d09\u0287\u1d09\u00f8n \u0279\u01ddl\u0250\u0287\u1d09\u028c\u01dd \u0287\u00f8 \u0287\u0265\u01dd \u0254\u0250\u026f\u01dd\u0279\u0250?", "What was the total combined income, during the last 12 months, of all members of your family? ": "W\u0265\u0250\u0287 \u028d\u0250s \u0287\u0265\u01dd \u0287\u00f8\u0287\u0250l \u0254\u00f8\u026fb\u1d09n\u01ddd \u1d09n\u0254\u00f8\u026f\u01dd, dn\u0279\u1d09n\u0183 \u0287\u0265\u01dd l\u0250s\u0287 12 \u026f\u00f8n\u0287\u0265s, \u00f8\u025f \u0250ll \u026f\u01dd\u026fb\u01dd\u0279s \u00f8\u025f \u028e\u00f8n\u0279 \u025f\u0250\u026f\u1d09l\u028e? ", - "What's Your Next Accomplishment?": "W\u0265\u0250\u0287's \u024e\u00f8n\u0279 N\u01ddx\u0287 \u023a\u0254\u0254\u00f8\u026fdl\u1d09s\u0265\u026f\u01ddn\u0287?", "What's in my clipboard?": "W\u0265\u0250\u0287's \u1d09n \u026f\u028e \u0254l\u1d09db\u00f8\u0250\u0279d?", "When learners submit an answer to an assessment, they immediately see whether the answer is correct or incorrect, and the score received.": "W\u0265\u01ddn l\u01dd\u0250\u0279n\u01dd\u0279s snb\u026f\u1d09\u0287 \u0250n \u0250ns\u028d\u01dd\u0279 \u0287\u00f8 \u0250n \u0250ss\u01ddss\u026f\u01ddn\u0287, \u0287\u0265\u01dd\u028e \u1d09\u026f\u026f\u01ddd\u1d09\u0250\u0287\u01ddl\u028e s\u01dd\u01dd \u028d\u0265\u01dd\u0287\u0265\u01dd\u0279 \u0287\u0265\u01dd \u0250ns\u028d\u01dd\u0279 \u1d09s \u0254\u00f8\u0279\u0279\u01dd\u0254\u0287 \u00f8\u0279 \u1d09n\u0254\u00f8\u0279\u0279\u01dd\u0254\u0287, \u0250nd \u0287\u0265\u01dd s\u0254\u00f8\u0279\u01dd \u0279\u01dd\u0254\u01dd\u1d09\u028c\u01ddd.", "When your face is in position, use the Take Photo button {icon} below to take your photo.": "W\u0265\u01ddn \u028e\u00f8n\u0279 \u025f\u0250\u0254\u01dd \u1d09s \u1d09n d\u00f8s\u1d09\u0287\u1d09\u00f8n, ns\u01dd \u0287\u0265\u01dd \u0166\u0250\u029e\u01dd \u2c63\u0265\u00f8\u0287\u00f8 bn\u0287\u0287\u00f8n {icon} b\u01ddl\u00f8\u028d \u0287\u00f8 \u0287\u0250\u029e\u01dd \u028e\u00f8n\u0279 d\u0265\u00f8\u0287\u00f8.", @@ -2301,7 +2290,6 @@ "{categoryText} in {parentDisplayname}": "{categoryText} \u1d09n {parentDisplayname}", "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} of {maxCharacters}": "{currentCountOpeningTag}{currentCharacterCount}{currentCountClosingTag} \u00f8\u025f {maxCharacters}", "{display_name} Settings": "{display_name} S\u01dd\u0287\u0287\u1d09n\u0183s", - "{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack.": "{download_link_start}\u0110\u00f8\u028dnl\u00f8\u0250d \u0287\u0265\u1d09s \u1d09\u026f\u0250\u0183\u01dd (\u0279\u1d09\u0183\u0265\u0287-\u0254l\u1d09\u0254\u029e \u00f8\u0279 \u00f8d\u0287\u1d09\u00f8n-\u0254l\u1d09\u0254\u029e, s\u0250\u028c\u01dd \u0250s){link_end} \u0250nd \u0287\u0265\u01ddn {upload_link_start}ndl\u00f8\u0250d{link_end} \u1d09\u0287 \u0287\u00f8 \u028e\u00f8n\u0279 b\u0250\u0254\u029ed\u0250\u0254\u029e.", "{earned}/{possible} point (graded)": [ "{earned}/{possible} d\u00f8\u1d09n\u0287 (\u0183\u0279\u0250d\u01ddd)", "{earned}/{possible} d\u00f8\u1d09n\u0287s (\u0183\u0279\u0250d\u01ddd)" diff --git a/lms/static/js/learner_dashboard/spec/program_details_header_spec.js b/lms/static/js/learner_dashboard/spec/program_details_header_spec.js index 398632ebb4..d28d8f0bd3 100644 --- a/lms/static/js/learner_dashboard/spec/program_details_header_spec.js +++ b/lms/static/js/learner_dashboard/spec/program_details_header_spec.js @@ -81,7 +81,7 @@ describe('Program Details Header View', () => { expect(view.$('.org-logo').attr('alt')) .toEqual(`${context.programData.authoring_organizations[0].name}'s logo`); }); - + it('should render the subscription badge if subscription is active', () => { expect(view.$('.meta-info .badge').html().trim()).toEqual('Subscribed'); }); diff --git a/lms/static/js/spec/student_account/helpers.js b/lms/static/js/spec/student_account/helpers.js index 9215bd4edc..e1dd11425d 100644 --- a/lms/static/js/spec/student_account/helpers.js +++ b/lms/static/js/spec/student_account/helpers.js @@ -3,7 +3,6 @@ define(['underscore'], function(_) { var USER_ACCOUNTS_API_URL = '/api/user/v0/accounts/student'; var USER_PREFERENCES_API_URL = '/api/user/v0/preferences/student'; - var BADGES_API_URL = '/api/badges/v1/assertions/user/student/'; var IMAGE_UPLOAD_API_URL = '/api/profile_images/v0/staff/upload'; var IMAGE_REMOVE_API_URL = '/api/profile_images/v0/staff/remove'; var FIND_COURSES_URL = '/courses'; @@ -116,7 +115,6 @@ define(['underscore'], function(_) { social_links: [{platform: 'facebook', social_link: 'https://www.facebook.com/edX'}], language_proficiencies: [{code: '1'}], profile_image: PROFILE_IMAGE, - accomplishments_shared: false }; var DEFAULT_USER_PREFERENCES_DATA = { 'pref-lang': '2', @@ -223,7 +221,6 @@ define(['underscore'], function(_) { return { USER_ACCOUNTS_API_URL: USER_ACCOUNTS_API_URL, USER_PREFERENCES_API_URL: USER_PREFERENCES_API_URL, - BADGES_API_URL: BADGES_API_URL, FIND_COURSES_URL: FIND_COURSES_URL, IMAGE_UPLOAD_API_URL: IMAGE_UPLOAD_API_URL, IMAGE_REMOVE_API_URL: IMAGE_REMOVE_API_URL, diff --git a/lms/static/js/student_account/models/user_account_model.js b/lms/static/js/student_account/models/user_account_model.js index d7ae96cc6c..2e3f9b0348 100644 --- a/lms/static/js/student_account/models/user_account_model.js +++ b/lms/static/js/student_account/models/user_account_model.js @@ -25,7 +25,6 @@ language_proficiencies: [], requires_parental_consent: true, profile_image: null, - accomplishments_shared: false, default_public_account_fields: [], extended_profile: [], secondary_email: '' diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index 795782948f..cd8668ddc4 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -788,13 +788,9 @@ 'js/spec/views/message_banner_spec.js', 'js/spec/views/notification_spec.js', 'learner_profile/js/spec/learner_profile_factory_spec.js', - 'learner_profile/js/spec/views/badge_list_container_spec.js', - 'learner_profile/js/spec/views/badge_list_view_spec.js', - 'learner_profile/js/spec/views/badge_view_spec.js', 'learner_profile/js/spec/views/learner_profile_fields_spec.js', 'learner_profile/js/spec/views/learner_profile_view_spec.js', 'learner_profile/js/spec/views/section_two_tab_spec.js', - 'learner_profile/js/spec/views/share_modal_view_spec.js', 'support/js/spec/collections/enrollment_spec.js', 'support/js/spec/models/enrollment_spec.js', 'support/js/spec/views/certificates_spec.js', diff --git a/lms/static/sass/course/base/_extends.scss b/lms/static/sass/course/base/_extends.scss index b1f3f96c3b..72d2eedf44 100644 --- a/lms/static/sass/course/base/_extends.scss +++ b/lms/static/sass/course/base/_extends.scss @@ -30,7 +30,7 @@ h1.top-header { } .light-button, -a.light-button, // only used in askbot as classes +a.light-button, .gray-button { @include simple($gray-l5); diff --git a/lms/templates/problem.html b/lms/templates/problem.html index 60d27b4ff5..3bf90b5daf 100644 --- a/lms/templates/problem.html +++ b/lms/templates/problem.html @@ -40,7 +40,7 @@ from openedx.core.djangolib.markup import HTML </button> </span> % endif - % if reset_button: + % if attempts_used and reset_button: <span class="problem-action-button-wrapper"> <button type="button" class="reset problem-action-btn btn-link btn-small" data-value="${_('Reset')}"><span aria-hidden="true">${_('Reset')}</span><span class="sr">${_("Reset your answer")}</span></button> </span> diff --git a/lms/urls.py b/lms/urls.py index dc673054df..ca6a240eab 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -227,11 +227,6 @@ if settings.FEATURES.get('ENABLE_MOBILE_REST_API'): re_path(r'^api/mobile/(?P<api_version>v(2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), ] -if settings.FEATURES.get('ENABLE_OPENBADGES'): - urlpatterns += [ - path('api/badges/v1/', include(('lms.djangoapps.badges.api.urls', 'badges'), namespace='badges_api')), - ] - urlpatterns += [ path('openassessment/fileupload/', include('openassessment.fileupload.urls')), ] @@ -752,6 +747,16 @@ urlpatterns += [ ), ] +urlpatterns += [ + re_path( + r'^courses/{}/courseware-search/enabled/$'.format( + settings.COURSE_ID_PATTERN, + ), + courseware_views.courseware_mfe_search_enabled, + name='courseware_search_enabled_view', + ), +] + urlpatterns += [ re_path( r'^courses/{}/lti_tab/(?P<provider_uuid>[^/]+)/$'.format( diff --git a/mypy.ini b/mypy.ini index 49d6f61b29..4d2f566fa3 100644 --- a/mypy.ini +++ b/mypy.ini @@ -2,7 +2,6 @@ follow_imports = silent ignore_missing_imports = True allow_untyped_globals = True -exclude = tests plugins = mypy_django_plugin.main, mypy_drf_plugin.main @@ -11,8 +10,53 @@ files = openedx/core/djangoapps/content_staging, openedx/core/djangoapps/content_libraries, openedx/core/djangoapps/xblock, - openedx/core/types + openedx/core/types, + openedx/core/djangoapps/content_tagging [mypy.plugins.django-stubs] # content_staging only works with CMS; others work with either, so we run mypy with CMS settings. django_settings_module = "cms.envs.test" + +# Selectively ignore packages known to be lacking type hints +[mypy-bridgekeeper.*] +ignore_missing_imports = True +[mypy-celery.*] +ignore_missing_imports = True +[mypy-celery_utils.*] +ignore_missing_imports = True +[mypy-completion.*] +ignore_missing_imports = True +[mypy-crum.*] +ignore_missing_imports = True +[mypy-ddt.*] +ignore_missing_imports = True +[mypy-edx_api_doc_tools.*] +ignore_missing_imports = True +[mypy-edx_django_utils.*] +ignore_missing_imports = True +[mypy-edx_proctoring.*] +ignore_missing_imports = True +[mypy-edx_rest_api_client.*] +ignore_missing_imports = True +[mypy-edx_rest_framework_extensions.*] +ignore_missing_imports = True +[mypy-eventtracking.*] +ignore_missing_imports = True +[mypy-fs.*] +ignore_missing_imports = True +[mypy-model_utils.*] +ignore_missing_imports = True +[mypy-openedx_events.*] +ignore_missing_imports = True +[mypy-organizations.*] +ignore_missing_imports = True +[mypy-search.*] +ignore_missing_imports = True +[mypy-rules.*] +ignore_missing_imports = True +[mypy-web_fragments.*] +ignore_missing_imports = True +[mypy-webob.*] +ignore_missing_imports = True +[mypy-xblock.*] +ignore_missing_imports = True diff --git a/openedx/core/djangoapps/agreements/toggles.py b/openedx/core/djangoapps/agreements/toggles.py new file mode 100644 index 0000000000..6d71abdf65 --- /dev/null +++ b/openedx/core/djangoapps/agreements/toggles.py @@ -0,0 +1,28 @@ +""" +Toggle for lti pii acknowledgement feature. +""" + +from opaque_keys.edx.keys import CourseKey +from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + +# .. toggle_name: agreements.enable_lti_pii_acknowledgement +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Enables the lti pii acknowledgement feature for a course +# .. toggle_warning: None +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2023-10 +# .. toggle_target_removal_date: None +# .. toggle_tickets: MST-2055 + + +ENABLE_LTI_PII_ACKNOWLEDGEMENT = CourseWaffleFlag('agreements.enable_lti_pii_acknowledgement', __name__) + + +def lti_pii_acknowledgment_enabled(course_key): + """ + Returns a boolean if lti pii acknowledgements are enabled for a course. + """ + if isinstance(course_key, str): + course_key = CourseKey.from_string(course_key) + return ENABLE_LTI_PII_ACKNOWLEDGEMENT.is_enabled(course_key) diff --git a/openedx/core/djangoapps/cache_toolbox/middleware.py b/openedx/core/djangoapps/cache_toolbox/middleware.py index 4ba2162fe3..9d1ea2bf06 100644 --- a/openedx/core/djangoapps/cache_toolbox/middleware.py +++ b/openedx/core/djangoapps/cache_toolbox/middleware.py @@ -95,6 +95,7 @@ from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user from django.utils.crypto import constant_time_compare from django.utils.deprecation import MiddlewareMixin +from edx_django_utils.monitoring import set_custom_attribute from openedx.core.djangoapps.safe_sessions.middleware import SafeSessionMiddleware, _mark_cookie_for_deletion @@ -112,6 +113,7 @@ class CacheBackedAuthenticationMiddleware(AuthenticationMiddleware, MiddlewareMi super().__init__(*args, **kwargs) def process_request(self, request): + set_custom_attribute('DEFAULT_HASHING_ALGORITHM', settings.DEFAULT_HASHING_ALGORITHM) try: # Try and construct a User instance from data stored in the cache session_user_id = SafeSessionMiddleware.get_user_id_from_session(request) @@ -141,9 +143,29 @@ class CacheBackedAuthenticationMiddleware(AuthenticationMiddleware, MiddlewareMi auto_auth_enabled = settings.FEATURES.get('AUTOMATIC_AUTH_FOR_TESTING', False) if not auto_auth_enabled and hasattr(request.user, 'get_session_auth_hash'): session_hash = request.session.get(HASH_SESSION_KEY) - if not (session_hash and constant_time_compare(session_hash, request.user.get_session_auth_hash())): - # The session hash has changed due to a password - # change. Log the user out. - request.session.flush() - request.user = AnonymousUser() - _mark_cookie_for_deletion(request) + session_hash_verified = session_hash and constant_time_compare( + session_hash, request.user.get_session_auth_hash()) + + # session hash is verified from the default algo, so skip legacy check + if session_hash_verified: + set_custom_attribute('session_hash_verified', "default") + return + + if ( + session_hash and + hasattr(request.user, '_legacy_get_session_auth_hash') and + constant_time_compare( + session_hash, + request.user._legacy_get_session_auth_hash() # pylint: disable=protected-access + ) + ): + # session hash is verified from legacy hashing algorithm. + set_custom_attribute('session_hash_verified', "fallback") + return + + # The session hash has changed due to a password + # change. Log the user out. + request.session.flush() + request.user = AnonymousUser() + _mark_cookie_for_deletion(request) + set_custom_attribute('failed_session_verification', True) diff --git a/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py b/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py index 775229d42b..c324cf6c52 100644 --- a/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py +++ b/openedx/core/djangoapps/cache_toolbox/tests/test_middleware.py @@ -1,17 +1,18 @@ """Tests for cached authentication middleware.""" -from unittest.mock import patch +from unittest.mock import call, patch +import django from django.conf import settings -from django.contrib.auth.models import User, AnonymousUser # lint-amnesty, pylint: disable=imported-auth-user -from django.urls import reverse -from django.test import TestCase from django.contrib.auth import SESSION_KEY +from django.contrib.auth.models import AnonymousUser, User # lint-amnesty, pylint: disable=imported-auth-user from django.http import HttpResponse, SimpleCookie +from django.test import TestCase +from django.urls import reverse +from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.cache_toolbox.middleware import CacheBackedAuthenticationMiddleware from openedx.core.djangoapps.safe_sessions.middleware import SafeCookieData, SafeSessionMiddleware -from openedx.core.djangolib.testing.utils import skip_unless_cms, skip_unless_lms, get_mock_request -from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import get_mock_request, skip_unless_cms, skip_unless_lms class CachedAuthMiddlewareTestCase(TestCase): @@ -36,9 +37,68 @@ class CachedAuthMiddlewareTestCase(TestCase): """ response = self.client.get(test_url) assert response.status_code == 200 - with patch.object(User, 'get_session_auth_hash', return_value='abc123'): - response = self.client.get(test_url) - self.assertRedirects(response, redirect_url, target_status_code=target_status_code) + + with patch( + "openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute" + ) as mock_set_custom_attribute: + with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True): + # Django 3.2 has _legacy_get_session_auth_hash, and Django 4 does not + # Remove once we reach Django 4 + if hasattr(User, '_legacy_get_session_auth_hash'): + with patch.object(User, '_legacy_get_session_auth_hash', return_value='abc123'): + response = self.client.get(test_url) + else: + response = self.client.get(test_url) + + self.assertRedirects(response, redirect_url, target_status_code=target_status_code) + mock_set_custom_attribute.assert_any_call('failed_session_verification', True) + + def _test_custom_attribute_after_changing_hash(self, test_url, mock_set_custom_attribute): + """verify that set_custom_attribute is called with expected values""" + password = 'test-password' + + # Test DEFAULT_HASHING_ALGORITHM of 'sha1' for both login and client get + with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'): + self.client.login(username=self.user.username, password=password) + self.client.get(test_url) + # For Django 3.2, the setting 'sha1' applies and is the "default". + # For Django 4, the setting no longer applies, and 'sha256' will be used for both as the "default". + mock_set_custom_attribute.assert_has_calls([ + call('DEFAULT_HASHING_ALGORITHM', 'sha1'), + call('session_hash_verified', "default"), + ]) + mock_set_custom_attribute.reset_mock() + + # Test DEFAULT_HASHING_ALGORITHM of 'sha1' for login and switch to 'sha256' for client get. + with self.settings(DEFAULT_HASHING_ALGORITHM='sha1'): + self.client.login(username=self.user.username, password=password) + with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'): + self.client.get(test_url) + if django.VERSION < (4, 0): + # For Django 3.2, the setting 'sha1' applies to login, and uses 'she256' for client get, + # and should "fallback" to 'sha1". + mock_set_custom_attribute.assert_has_calls([ + call('DEFAULT_HASHING_ALGORITHM', 'sha256'), + call('session_hash_verified', "fallback"), + ]) + else: + # For Django 4, the setting no longer applies, and again 'sha256' will be used for both as the "default". + mock_set_custom_attribute.assert_has_calls([ + call('DEFAULT_HASHING_ALGORITHM', 'sha256'), + call('session_hash_verified', "default"), + ]) + mock_set_custom_attribute.reset_mock() + + # Test DEFAULT_HASHING_ALGORITHM of 'sha256' for both login and client get + with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'): + self.client.login(username=self.user.username, password=password) + self.client.get(test_url) + # For Django 3.2, the setting 'sha256' applies and is the "default". + # For Django 4, the setting no longer applies, and 'sha256' will be used for both as the "default". + mock_set_custom_attribute.assert_has_calls([ + call('DEFAULT_HASHING_ALGORITHM', 'sha256'), + call('session_hash_verified', "default"), + ]) @skip_unless_lms def test_session_change_lms(self): @@ -53,6 +113,20 @@ class CachedAuthMiddlewareTestCase(TestCase): # Studio login redirects to LMS login self._test_change_session_hash(home_url, settings.LOGIN_URL + '?next=' + home_url, target_status_code=302) + @skip_unless_lms + @patch("openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute") + def test_custom_attribute_after_changing_hash_lms(self, mock_set_custom_attribute): + """Test set_custom_attribute is called with expected values in LMS""" + test_url = reverse('dashboard') + self._test_custom_attribute_after_changing_hash(test_url, mock_set_custom_attribute) + + @skip_unless_cms + @patch("openedx.core.djangoapps.cache_toolbox.middleware.set_custom_attribute") + def test_custom_attribute_after_changing_hash_cms(self, mock_set_custom_attribute): + """Test set_custom_attribute is called with expected values in CMS""" + test_url = reverse('home') + self._test_custom_attribute_after_changing_hash(test_url, mock_set_custom_attribute) + def test_user_logout_on_session_hash_change(self): """ Verify that if a user's session auth hash and the request's hash @@ -75,8 +149,15 @@ class CachedAuthMiddlewareTestCase(TestCase): assert self.client.response.cookies.get(settings.SESSION_COOKIE_NAME).value == session_id assert self.client.response.cookies.get('edx-jwt-cookie-header-payload').value == 'test-jwt-payload' - with patch.object(User, 'get_session_auth_hash', return_value='abc123'): - CacheBackedAuthenticationMiddleware(get_response=lambda request: None).process_request(self.request) + with patch.object(User, 'get_session_auth_hash', return_value='abc123', autospec=True): + # Django 3.2 has _legacy_get_session_auth_hash, and Django 4 does not + # Remove once we reach Django 4 + if hasattr(User, '_legacy_get_session_auth_hash'): + with patch.object(User, '_legacy_get_session_auth_hash', return_value='abc123'): + CacheBackedAuthenticationMiddleware(get_response=lambda request: None).process_request(self.request) + + else: + CacheBackedAuthenticationMiddleware(get_response=lambda request: None).process_request(self.request) SafeSessionMiddleware(get_response=lambda request: None).process_response( self.request, self.client.response ) diff --git a/openedx/core/djangoapps/catalog/tests/factories.py b/openedx/core/djangoapps/catalog/tests/factories.py index f2760edf86..600b187f9d 100644 --- a/openedx/core/djangoapps/catalog/tests/factories.py +++ b/openedx/core/djangoapps/catalog/tests/factories.py @@ -236,7 +236,6 @@ class ProgramTypeFactory(DictFactoryBase): class ProgramTypeAttrsFactory(DictFactoryBase): uuid = factory.Faker('uuid4') slug = factory.Faker('word') - coaching_supported = False class ProgramFactory(DictFactoryBase): diff --git a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py index fefe1536a2..f166cee546 100644 --- a/openedx/core/djangoapps/content_libraries/tests/test_runtime.py +++ b/openedx/core/djangoapps/content_libraries/tests/test_runtime.py @@ -546,14 +546,14 @@ class ContentLibraryXBlockUserStateTestMixin(ContentLibraryContentTestMixin): @requires_blockstore -class ContentLibraryXBlockUserStateBServiceTest(ContentLibraryXBlockUserStateTestMixin, TestCase): +class ContentLibraryXBlockUserStateBServiceTest(ContentLibraryXBlockUserStateTestMixin, TestCase): # type: ignore[misc] """ Tests XBlock user state for XBlocks in a content library using the standalone Blockstore service. """ @requires_blockstore_app -class ContentLibraryXBlockUserStateTest( +class ContentLibraryXBlockUserStateTest( # type: ignore[misc] ContentLibraryXBlockUserStateTestMixin, BlockstoreAppTestMixin, LiveServerTestCase, diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index bc351b6b95..00c4466b7d 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -364,7 +364,7 @@ class ClipboardTestCase(ModuleStoreTestCase): response = nonstaff_client.get(olx_url) assert response.status_code == 403 - def assertXmlEqual(self, xml_str_a: str, xml_str_b: str) -> bool: + def assertXmlEqual(self, xml_str_a: str, xml_str_b: str): """ Assert that the given XML strings are equal, ignoring attribute order and some whitespace variations. """ a = ElementTree.canonicalize(xml_str_a, strip_text=True) b = ElementTree.canonicalize(xml_str_b, strip_text=True) diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py index bc420b9084..a8630e29e5 100644 --- a/openedx/core/djangoapps/content_tagging/api.py +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -3,46 +3,46 @@ Content Tagging APIs """ from __future__ import annotations -from typing import Iterator, List, Type +from typing import Iterator import openedx_tagging.core.tagging.api as oel_tagging -from django.db.models import QuerySet -from opaque_keys.edx.keys import CourseKey, UsageKey +from django.db.models import Q, QuerySet, Exists, OuterRef from openedx_tagging.core.tagging.models import Taxonomy from organizations.models import Organization -from .models import ContentObjectTag, ContentTaxonomy, TaxonomyOrg +from .models import ContentObjectTag, TaxonomyOrg +from .types import ContentKey def create_taxonomy( name: str, - description: str = None, + description: str | None = None, enabled=True, - required=False, allow_multiple=False, allow_free_text=False, - taxonomy_class: Type = ContentTaxonomy, + orgs: list[Organization] | None = None, ) -> Taxonomy: """ Creates, saves, and returns a new Taxonomy with the given attributes. - - If `taxonomy_class` not provided, then uses ContentTaxonomy. """ - return oel_tagging.create_taxonomy( + taxonomy = oel_tagging.create_taxonomy( name=name, description=description, enabled=enabled, - required=required, allow_multiple=allow_multiple, allow_free_text=allow_free_text, - taxonomy_class=taxonomy_class, ) + if orgs is not None: + set_taxonomy_orgs(taxonomy=taxonomy, all_orgs=False, orgs=orgs) + + return taxonomy + def set_taxonomy_orgs( taxonomy: Taxonomy, all_orgs=False, - orgs: List[Organization] = None, + orgs: list[Organization] | None = None, relationship: TaxonomyOrg.RelType = TaxonomyOrg.RelType.OWNER, ): """ @@ -81,7 +81,7 @@ def set_taxonomy_orgs( def get_taxonomies_for_org( enabled=True, - org_owner: Organization = None, + org_owner: Organization | None = None, ) -> QuerySet: """ Generates a list of the enabled Taxonomies available for the given org, sorted by name. @@ -94,33 +94,39 @@ def get_taxonomies_for_org( If you want the disabled Taxonomies, pass enabled=False. If you want all Taxonomies (both enabled and disabled), pass enabled=None. """ - taxonomies = oel_tagging.get_taxonomies(enabled=enabled) - return ContentTaxonomy.taxonomies_for_org( - org=org_owner, - queryset=taxonomies, + org_short_name = org_owner.short_name if org_owner else None + return oel_tagging.get_taxonomies(enabled=enabled).filter( + Exists( + TaxonomyOrg.get_relationships( + taxonomy=OuterRef("pk"), + rel_type=TaxonomyOrg.RelType.OWNER, + org_short_name=org_short_name, + ) + ) ) def get_content_tags( - object_id: str, taxonomy_id: str = None + object_key: ContentKey, + taxonomy_id: int | None = None, ) -> Iterator[ContentObjectTag]: """ Generates a list of content tags for a given object. Pass taxonomy to limit the returned object_tags to a specific taxonomy. """ - for object_tag in oel_tagging.get_object_tags( - object_id=object_id, + return oel_tagging.get_object_tags( + object_id=str(object_key), taxonomy_id=taxonomy_id, - ): - yield ContentObjectTag.cast(object_tag) + object_tag_class=ContentObjectTag, + ) def tag_content_object( + object_key: ContentKey, taxonomy: Taxonomy, tags: list, - object_id: CourseKey | UsageKey, -) -> list[ContentObjectTag]: +) -> Iterator[ContentObjectTag]: """ This is the main API to use when you want to add/update/delete tags from a content object (e.g. an XBlock or course). @@ -136,14 +142,18 @@ def tag_content_object( Raises ValueError if the proposed tags are invalid for this taxonomy. Preserves existing (valid) tags, adds new (valid) tags, and removes omitted (or invalid) tags. """ - content_tags = [] - for object_tag in oel_tagging.tag_object( + if not taxonomy.system_defined: + # We require that this taxonomy is linked to the content object's "org" or linked to "all orgs" (None): + org_short_name = object_key.org # type: ignore + if not taxonomy.taxonomyorg_set.filter(Q(org__short_name=org_short_name) | Q(org=None)).exists(): + raise ValueError(f"The specified Taxonomy is not enabled for the content object's org ({org_short_name})") + oel_tagging.tag_object( taxonomy=taxonomy, tags=tags, - object_id=str(object_id), - ): - content_tags.append(ContentObjectTag.cast(object_tag)) - return content_tags + object_id=str(object_key), + object_tag_class=ContentObjectTag, + ) + return get_content_tags(object_key, taxonomy_id=taxonomy.id) # Expose the oel_tagging APIs diff --git a/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py b/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py new file mode 100644 index 0000000000..fa00df307c --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0001_squashed.py @@ -0,0 +1,54 @@ +# Generated by Django 3.2.21 on 2023-10-09 23:12 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + replaces = [ + ('content_tagging', '0001_initial'), + ('content_tagging', '0002_system_defined_taxonomies'), + ('content_tagging', '0003_system_defined_fixture'), + ('content_tagging', '0004_system_defined_org'), + ('content_tagging', '0005_auto_20230830_1517'), + ('content_tagging', '0006_simplify_models'), + ] + + initial = True + + dependencies = [ + ("oel_tagging", "0001_squashed"), + ('organizations', '0003_historicalorganizationcourse'), + ] + + operations = [ + migrations.CreateModel( + name='ContentObjectTag', + fields=[ + ], + options={ + 'proxy': True, + 'indexes': [], + 'constraints': [], + }, + bases=('oel_tagging.objecttag',), + ), + migrations.CreateModel( + name='TaxonomyOrg', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('rel_type', models.CharField(choices=[('OWN', 'owner')], default='OWN', max_length=3)), + ('org', models.ForeignKey(default=None, help_text='Organization that is related to this taxonomy.If None, then this taxonomy is related to all organizations.', null=True, on_delete=django.db.models.deletion.CASCADE, to='organizations.organization')), + ('taxonomy', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, to='oel_tagging.taxonomy')), + ], + ), + migrations.AddIndex( + model_name='taxonomyorg', + index=models.Index(fields=['taxonomy', 'rel_type'], name='content_tag_taxonom_b04dd1_idx'), + ), + migrations.AddIndex( + model_name='taxonomyorg', + index=models.Index(fields=['taxonomy', 'rel_type', 'org'], name='content_tag_taxonom_70d60b_idx'), + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py b/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py index 78b0baa949..14a3e6ace1 100644 --- a/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py +++ b/openedx/core/djangoapps/content_tagging/migrations/0002_system_defined_taxonomies.py @@ -21,7 +21,7 @@ class Migration(migrations.Migration): 'indexes': [], 'constraints': [], }, - bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.usersystemdefinedtaxonomy'), + bases=('oel_tagging.usersystemdefinedtaxonomy', ), ), migrations.CreateModel( name='ContentLanguageTaxonomy', @@ -32,7 +32,7 @@ class Migration(migrations.Migration): 'indexes': [], 'constraints': [], }, - bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.languagetaxonomy'), + bases=('oel_tagging.languagetaxonomy', ), ), migrations.CreateModel( name='ContentOrganizationTaxonomy', @@ -43,7 +43,7 @@ class Migration(migrations.Migration): 'indexes': [], 'constraints': [], }, - bases=(openedx.core.djangoapps.content_tagging.models.base.ContentTaxonomyMixin, 'oel_tagging.modelsystemdefinedtaxonomy'), + bases=('oel_tagging.modelsystemdefinedtaxonomy', ), ), migrations.CreateModel( name='OrganizationModelObjectTag', diff --git a/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py b/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py index 7846b907c4..ff855482e1 100644 --- a/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py +++ b/openedx/core/djangoapps/content_tagging/migrations/0003_system_defined_fixture.py @@ -38,12 +38,6 @@ def load_system_defined_taxonomies(apps, schema_editor): org_taxonomy.taxonomy_class = ContentOrganizationTaxonomy org_taxonomy.save() - # Adding taxonomy class to the language taxonomy - language_taxonomy = Taxonomy.objects.get(id=-1) - ContentLanguageTaxonomy = apps.get_model("content_tagging", "ContentLanguageTaxonomy") - language_taxonomy.taxonomy_class = ContentLanguageTaxonomy - language_taxonomy.save() - def revert_system_defined_taxonomies(apps, schema_editor): """ diff --git a/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py b/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py index 852d67ae4a..a60ef381cd 100644 --- a/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py +++ b/openedx/core/djangoapps/content_tagging/migrations/0004_system_defined_org.py @@ -6,8 +6,9 @@ def load_system_defined_org_taxonomies(apps, _schema_editor): Associates the system defined taxonomy Language (id=-1) to all orgs and removes the ContentOrganizationTaxonomy (id=-3) from the database """ - TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") - TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None) + # Disabled for now as the way that this taxonomy is created has changed. + # TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + # TaxonomyOrg.objects.create(id=-1, taxonomy_id=-1, org=None) Taxonomy = apps.get_model("oel_tagging", "Taxonomy") Taxonomy.objects.get(id=-3).delete() @@ -20,8 +21,8 @@ def revert_system_defined_org_taxonomies(apps, _schema_editor): Deletes association of system defined taxonomy Language (id=-1) to all orgs and creates the ContentOrganizationTaxonomy (id=-3) in the database """ - TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") - TaxonomyOrg.objects.get(id=-1).delete() + # TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + # TaxonomyOrg.objects.get(id=-1).delete() Taxonomy = apps.get_model("oel_tagging", "Taxonomy") org_taxonomy = Taxonomy( diff --git a/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py b/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py new file mode 100644 index 0000000000..7e8eb99ee7 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0006_simplify_models.py @@ -0,0 +1,22 @@ +# Generated by Django 3.2.21 on 2023-09-29 23:32 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('content_tagging', '0005_auto_20230830_1517'), + ] + + operations = [ + migrations.DeleteModel( + name='ContentAuthorTaxonomy', + ), + migrations.DeleteModel( + name='ContentLanguageTaxonomy', + ), + migrations.DeleteModel( + name='ContentTaxonomy', + ), + ] diff --git a/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py b/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py new file mode 100644 index 0000000000..0a48016ca2 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/migrations/0007_system_defined_org_2.py @@ -0,0 +1,28 @@ +from django.db import migrations + + +def mark_language_taxonomy_as_all_orgs(apps, _schema_editor): + """ + Associates the system defined taxonomy Language (id=-1) to all orgs. + """ + TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + TaxonomyOrg.objects.update_or_create(taxonomy_id=-1, defaults={"org": None}) + + +def revert_mark_language_taxonomy_as_all_orgs(apps, _schema_editor): + """ + Deletes association of system defined taxonomy Language (id=-1) to all orgs. + """ + TaxonomyOrg = apps.get_model("content_tagging", "TaxonomyOrg") + TaxonomyOrg.objects.get(taxonomy_id=-1, org=None).delete() + + +class Migration(migrations.Migration): + dependencies = [ + ('content_tagging', '0001_squashed'), + ("oel_tagging", "0012_language_taxonomy"), + ] + + operations = [ + migrations.RunPython(mark_language_taxonomy_as_all_orgs, revert_mark_language_taxonomy_as_all_orgs), + ] diff --git a/openedx/core/djangoapps/content_tagging/models/__init__.py b/openedx/core/djangoapps/content_tagging/models/__init__.py index 4606c5b853..f330b8ae81 100644 --- a/openedx/core/djangoapps/content_tagging/models/__init__.py +++ b/openedx/core/djangoapps/content_tagging/models/__init__.py @@ -4,9 +4,4 @@ Content Tagging and System defined models from .base import ( TaxonomyOrg, ContentObjectTag, - ContentTaxonomy, -) -from .system_defined import ( - ContentLanguageTaxonomy, - ContentAuthorTaxonomy, ) diff --git a/openedx/core/djangoapps/content_tagging/models/base.py b/openedx/core/djangoapps/content_tagging/models/base.py index 38dcfb5b75..77061e4611 100644 --- a/openedx/core/djangoapps/content_tagging/models/base.py +++ b/openedx/core/djangoapps/content_tagging/models/base.py @@ -3,12 +3,12 @@ Content Tagging models """ from __future__ import annotations +from django.core.exceptions import ValidationError from django.db import models -from django.db.models import Exists, OuterRef, Q, QuerySet +from django.db.models import Q, QuerySet from django.utils.translation import gettext as _ from opaque_keys import InvalidKeyError -from opaque_keys.edx.keys import LearningContextKey -from opaque_keys.edx.locator import BlockUsageLocator +from opaque_keys.edx.keys import LearningContextKey, UsageKey from openedx_tagging.core.tagging.models import ObjectTag, Taxonomy from organizations.models import Organization @@ -91,7 +91,7 @@ class ContentObjectTag(ObjectTag): proxy = True @property - def object_key(self) -> BlockUsageLocator | LearningContextKey: + def object_key(self) -> UsageKey | LearningContextKey: """ Returns the object ID parsed as a UsageKey or LearningContextKey. Raises InvalidKeyError object_id cannot be parse into one of those key types. @@ -99,75 +99,14 @@ class ContentObjectTag(ObjectTag): Returns None if there's no object_id. """ try: - return LearningContextKey.from_string(str(self.object_id)) + return LearningContextKey.from_string(self.object_id) except InvalidKeyError: - return BlockUsageLocator.from_string(str(self.object_id)) + return UsageKey.from_string(self.object_id) - -class ContentTaxonomyMixin: - """ - Taxonomy which can only tag Content objects (e.g. XBlocks or Courses) via ContentObjectTag. - - Also ensures a valid TaxonomyOrg owner relationship with the content object. - """ - - @classmethod - def taxonomies_for_org( - cls, - queryset: QuerySet, - org: Organization | None = None, - ) -> QuerySet: - """ - Filters the given QuerySet to those ContentTaxonomies which are available for the given organization. - - If no `org` is provided, then only ContentTaxonomies available to all organizations are returned. - If `org` is provided, then ContentTaxonomies available to this organizations are also returned. - """ - org_short_name = org.short_name if org else None - return queryset.filter( - Exists( - TaxonomyOrg.get_relationships( - taxonomy=OuterRef("pk"), - rel_type=TaxonomyOrg.RelType.OWNER, - org_short_name=org_short_name, - ) - ) - ) - - def _check_object(self, object_tag: ObjectTag) -> bool: - """ - Returns True if this ObjectTag has a valid object_id. - """ - content_tag = ContentObjectTag.cast(object_tag) + def clean(self): + super().clean() + # Make sure that object_id is a valid key try: - content_tag.object_key - except InvalidKeyError: - return False - return super()._check_object(content_tag) - - def _check_taxonomy(self, object_tag: ObjectTag) -> bool: - """ - Returns True if this taxonomy is owned by the tag's org. - """ - content_tag = ContentObjectTag.cast(object_tag) - try: - object_key = content_tag.object_key - except InvalidKeyError: - return False - if not TaxonomyOrg.get_relationships( - taxonomy=self, - rel_type=TaxonomyOrg.RelType.OWNER, - org_short_name=object_key.org, - ).exists(): - return False - return super()._check_taxonomy(content_tag) - - -class ContentTaxonomy(ContentTaxonomyMixin, Taxonomy): - """ - Taxonomy that accepts ContentTags, - and ensures a valid TaxonomyOrg owner relationship with the content object. - """ - - class Meta: - proxy = True + self.object_key + except InvalidKeyError as err: + raise ValidationError("object_id is not a valid opaque key string.") from err diff --git a/openedx/core/djangoapps/content_tagging/models/system_defined.py b/openedx/core/djangoapps/content_tagging/models/system_defined.py deleted file mode 100644 index 9948625455..0000000000 --- a/openedx/core/djangoapps/content_tagging/models/system_defined.py +++ /dev/null @@ -1,27 +0,0 @@ -""" -System defined models -""" -from openedx_tagging.core.tagging.models import ( - UserSystemDefinedTaxonomy, - LanguageTaxonomy, -) - -from .base import ContentTaxonomyMixin - - -class ContentLanguageTaxonomy(ContentTaxonomyMixin, LanguageTaxonomy): - """ - Language system-defined taxonomy that accepts ContentTags - """ - - class Meta: - proxy = True - - -class ContentAuthorTaxonomy(ContentTaxonomyMixin, UserSystemDefinedTaxonomy): - """ - Author system-defined taxonomy that accepts ContentTags - """ - - class Meta: - proxy = True diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py index 9ad192f545..723a90d877 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/filters.py @@ -2,20 +2,92 @@ API Filters for content tagging org """ +from django.db.models import Exists, OuterRef, Q from rest_framework.filters import BaseFilterBackend import openedx_tagging.core.tagging.rules as oel_tagging +from organizations.models import Organization + +from ...rules import get_admin_orgs, get_user_orgs +from ...models import TaxonomyOrg class UserOrgFilterBackend(BaseFilterBackend): """ - Taxonomy admin can see all taxonomies - Everyone else can see only enabled taxonomies + Filter taxonomies based on user's orgs roles + Taxonomy admin can see all taxonomies + Org staff can see all taxonomies from their orgs + Content creators and instructors can see enabled taxonomies avaliable to their orgs """ def filter_queryset(self, request, queryset, _): if oel_tagging.is_taxonomy_admin(request.user): return queryset - return queryset.filter(enabled=True) + orgs = list(Organization.objects.all()) + user_admin_orgs = get_admin_orgs(request.user, orgs) + user_orgs = get_user_orgs(request.user, orgs) # Orgs that the user is a content creator or instructor + + if len(user_orgs) == 0 and len(user_admin_orgs) == 0: + return queryset.none() + + return queryset.filter( + # Get enabled taxonomies available to all orgs, or from orgs that the user is + # a content creator or instructor + Q( + Exists( + TaxonomyOrg.objects + .filter( + taxonomy=OuterRef("pk"), + rel_type=TaxonomyOrg.RelType.OWNER, + ) + .filter( + Q(org=None) | + Q(org__in=user_orgs) + ) + ), + enabled=True, + ) | + # Get all taxonomies from orgs that the user is OrgStaff + Q( + Exists( + TaxonomyOrg.objects + .filter(taxonomy=OuterRef("pk"), rel_type=TaxonomyOrg.RelType.OWNER) + .filter(org__in=user_admin_orgs) + ) + ) + ) + + +class ObjectTagTaxonomyOrgFilterBackend(BaseFilterBackend): + """ + Filter for ObjectTagViewSet to only show taxonomies that the user can view. + """ + + def filter_queryset(self, request, queryset, _): + if oel_tagging.is_taxonomy_admin(request.user): + return queryset + + orgs = list(Organization.objects.all()) + user_admin_orgs = get_admin_orgs(request.user, orgs) + user_orgs = get_user_orgs(request.user, orgs) + user_or_admin_orgs = list(set(user_orgs) | set(user_admin_orgs)) + + return queryset.filter(taxonomy__enabled=True).filter( + # Get ObjectTags from taxonomies available to all orgs, or from orgs that the user is + # a OrgStaff, content creator or instructor + Q( + Exists( + TaxonomyOrg.objects + .filter( + taxonomy=OuterRef("taxonomy_id"), + rel_type=TaxonomyOrg.RelType.OWNER, + ) + .filter( + Q(org=None) | + Q(org__in=user_or_admin_orgs) + ) + ) + ) + ) diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py index 1771816a14..0a7ff92409 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/serializers.py @@ -2,7 +2,7 @@ API Serializers for content tagging org """ -from rest_framework import serializers +from rest_framework import serializers, fields from openedx_tagging.core.tagging.rest_api.v1.serializers import ( TaxonomyListQueryParamsSerializer, @@ -16,7 +16,7 @@ class TaxonomyOrgListQueryParamsSerializer(TaxonomyListQueryParamsSerializer): Serializer for the query params for the GET view """ - org = serializers.SlugRelatedField( + org: fields.Field = serializers.SlugRelatedField( slug_field="short_name", queryset=Organization.objects.all(), required=False, diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py index 2eafb933b4..d37b5df26c 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/tests/test_views.py @@ -2,11 +2,13 @@ Tests tagging rest api views """ +from __future__ import annotations + from urllib.parse import parse_qs, urlparse +import abc import ddt from django.contrib.auth import get_user_model -from django.test.testcases import override_settings from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from openedx_tagging.core.tagging.models import Tag, Taxonomy from openedx_tagging.core.tagging.models.system_defined import SystemDefinedTaxonomy @@ -16,15 +18,30 @@ from rest_framework import status from rest_framework.test import APITestCase from common.djangoapps.student.auth import add_users, update_org_role -from common.djangoapps.student.roles import CourseStaffRole, OrgContentCreatorRole +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, + OrgContentCreatorRole, + OrgInstructorRole, + OrgLibraryUserRole, + OrgStaffRole, +) +from openedx.core.djangoapps.content_libraries.api import ( + AccessLevel, + create_library, + COMPLEX, + set_library_user_permissions, +) from openedx.core.djangoapps.content_tagging.models import TaxonomyOrg from openedx.core.djangolib.testing.utils import skip_unless_cms +from openedx.core.lib import blockstore_api User = get_user_model() TAXONOMY_ORG_LIST_URL = "/api/content_tagging/v1/taxonomies/" TAXONOMY_ORG_DETAIL_URL = "/api/content_tagging/v1/taxonomies/{pk}/" OBJECT_TAG_UPDATE_URL = "/api/content_tagging/v1/object_tags/{object_id}/?taxonomy={taxonomy_id}" +TAXONOMY_TEMPLATE_URL = "/api/content_tagging/v1/taxonomies/import/{filename}" def check_taxonomy( @@ -33,8 +50,7 @@ def check_taxonomy( name, description=None, enabled=True, - required=False, - allow_multiple=False, + allow_multiple=True, allow_free_text=False, system_defined=False, visible_to_authors=True, @@ -47,7 +63,6 @@ def check_taxonomy( assert data["name"] == name assert data["description"] == description assert data["enabled"] == enabled - assert data["required"] == required assert data["allow_multiple"] == allow_multiple assert data["allow_free_text"] == allow_free_text assert data["system_defined"] == system_defined @@ -58,29 +73,102 @@ class TestTaxonomyObjectsMixin: """ Sets up data for testing Content Taxonomies. """ + def _setUp_orgs(self): + """ + Create orgs for testing + """ + self.orgA = Organization.objects.create(name="Organization A", short_name="orgA") + self.orgB = Organization.objects.create(name="Organization B", short_name="orgB") + self.orgX = Organization.objects.create(name="Organization X", short_name="orgX") - def setUp(self): - super().setUp() + def _setUp_courses(self): + """ + Create courses for testing + """ + self.courseA = CourseLocator("orgA", "101", "test") + self.courseB = CourseLocator("orgB", "101", "test") + + def _setUp_library(self): + """ + Create library for testing + """ + self.collection = blockstore_api.create_collection("Test library collection") + self.content_libraryA = create_library( + collection_uuid=self.collection.uuid, + org=self.orgA, + slug="lib_a", + library_type=COMPLEX, + title="Library Org A", + description="This is a library from Org A", + allow_public_learning=False, + allow_public_read=False, + library_license="", + ) + + def _setUp_users(self): + """ + Create users for testing + """ self.user = User.objects.create( username="user", email="user@example.com", ) - self.userS = User.objects.create( + self.staff = User.objects.create( username="staff", email="staff@example.com", is_staff=True, ) - self.orgA = Organization.objects.create(name="Organization A", short_name="orgA") - self.orgB = Organization.objects.create(name="Organization B", short_name="orgB") - self.orgX = Organization.objects.create(name="Organization X", short_name="orgX") - - self.userA = User.objects.create( - username="userA", + self.staffA = User.objects.create( + username="staffA", email="userA@example.com", ) - update_org_role(self.userS, OrgContentCreatorRole, self.userA, [self.orgA.short_name]) + update_org_role(self.staff, OrgStaffRole, self.staffA, [self.orgA.short_name]) + self.content_creatorA = User.objects.create( + username="content_creatorA", + email="content_creatorA@example.com", + ) + update_org_role(self.staff, OrgContentCreatorRole, self.content_creatorA, [self.orgA.short_name]) + + self.instructorA = User.objects.create( + username="instructorA", + email="instructorA@example.com", + ) + update_org_role(self.staff, OrgInstructorRole, self.instructorA, [self.orgA.short_name]) + + self.library_staffA = User.objects.create( + username="library_staffA", + email="library_staffA@example.com", + ) + update_org_role(self.staff, OrgLibraryUserRole, self.library_staffA, [self.orgA.short_name]) + + self.course_instructorA = User.objects.create( + username="course_instructorA", + email="course_instructorA@example.com", + ) + add_users(self.staff, CourseInstructorRole(self.courseA), self.course_instructorA) + + self.course_staffA = User.objects.create( + username="course_staffA", + email="course_staffA@example.com", + ) + add_users(self.staff, CourseStaffRole(self.courseA), self.course_staffA) + + self.library_userA = User.objects.create( + username="library_userA", + email="library_userA@example.com", + ) + set_library_user_permissions( + self.content_libraryA.key, + self.library_userA, + AccessLevel.READ_LEVEL + ) + + def _setUp_taxonomies(self): + """ + Create taxonomies for testing + """ # Orphaned taxonomy self.ot1 = Taxonomy.objects.create(name="ot1", enabled=True) self.ot2 = Taxonomy.objects.create(name="ot2", enabled=False) @@ -118,9 +206,7 @@ class TestTaxonomyObjectsMixin: self.tA1 = Taxonomy.objects.create(name="tA1", enabled=True) TaxonomyOrg.objects.create( taxonomy=self.tA1, - org=self.orgA, - rel_type=TaxonomyOrg.RelType.OWNER, - ) + org=self.orgA, rel_type=TaxonomyOrg.RelType.OWNER,) self.tA2 = Taxonomy.objects.create(name="tA2", enabled=False) TaxonomyOrg.objects.create( taxonomy=self.tA2, @@ -143,92 +229,139 @@ class TestTaxonomyObjectsMixin: ) # OrgA and OrgB taxonomy - self.tC1 = Taxonomy.objects.create(name="tC1", enabled=True) + self.tBA1 = Taxonomy.objects.create(name="tBA1", enabled=True) TaxonomyOrg.objects.create( - taxonomy=self.tC1, + taxonomy=self.tBA1, org=self.orgA, rel_type=TaxonomyOrg.RelType.OWNER, ) TaxonomyOrg.objects.create( - taxonomy=self.tC1, + taxonomy=self.tBA1, org=self.orgB, rel_type=TaxonomyOrg.RelType.OWNER, ) - self.tC2 = Taxonomy.objects.create(name="tC2", enabled=False) + self.tBA2 = Taxonomy.objects.create(name="tBA2", enabled=False) TaxonomyOrg.objects.create( - taxonomy=self.tC2, + taxonomy=self.tBA2, org=self.orgA, rel_type=TaxonomyOrg.RelType.OWNER, ) TaxonomyOrg.objects.create( - taxonomy=self.tC2, + taxonomy=self.tBA2, org=self.orgB, rel_type=TaxonomyOrg.RelType.OWNER, ) + def setUp(self): + + super().setUp() + + self._setUp_orgs() + self._setUp_courses() + self._setUp_library() + self._setUp_users() + self._setUp_taxonomies() + @skip_unless_cms @ddt.ddt -@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": True}) -class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): +class TestTaxonomyListCreateViewSet(TestTaxonomyObjectsMixin, APITestCase): """ - Test cases for TaxonomyViewSet when ENABLE_CREATOR_GROUP is True + Test cases for TaxonomyViewSet for list and create actions """ - @ddt.data( - ("user", None, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")), - ("userA", None, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")), - ("userS", None, None, ("ot1", "ot2", "st1", "st2", "t1", "t2", "tA1", "tA2", "tB1", "tB2")), - # Default page_size=10, and so "tC1" and "tC2" appear on the second page - ("user", True, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")), - ("userA", True, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")), - ("userS", True, None, ("ot1", "st1", "t1", "tA1", "tB1", "tC1")), - ("user", False, None, ()), - ("userA", False, None, ()), - ("userS", False, None, ("ot2", "st2", "t2", "tA2", "tB2", "tC2")), - ("user", None, "orgA", ("st1", "t1", "tA1", "tC1")), - ("userA", None, "orgA", ("st1", "t1", "tA1", "tC1")), - ("userS", None, "orgA", ("st1", "st2", "t1", "t2", "tA1", "tA2", "tC1", "tC2")), - ("user", True, "orgA", ("st1", "t1", "tA1", "tC1")), - ("userA", True, "orgA", ("st1", "t1", "tA1", "tC1")), - ("userS", True, "orgA", ("st1", "t1", "tA1", "tC1")), - ("user", False, "orgA", ()), - ("userA", False, "orgA", ()), - ("userS", False, "orgA", ("st2", "t2", "tA2", "tC2")), - ("user", None, "orgX", ("st1", "t1")), - ("userA", None, "orgX", ("st1", "t1")), - ("userS", None, "orgX", ("st1", "st2", "t1", "t2")), - ("user", True, "orgX", ("st1", "t1")), - ("userA", True, "orgX", ("st1", "t1")), - ("userS", True, "orgX", ("st1", "t1")), - ("user", False, "orgX", ()), - ("userA", False, "orgX", ()), - ("userS", False, "orgX", ("st2", "t2")), - ) - @ddt.unpack - def test_list_taxonomy(self, user_attr, enabled_parameter, org_name, expected_taxonomies): + def _test_list_taxonomy( + self, + user_attr: str, + expected_taxonomies: list[str], + enabled_parameter: bool | None = None, + org_parameter: str | None = None + ) -> None: + """ + Helper function to call the list endpoint and check the response + """ url = TAXONOMY_ORG_LIST_URL - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) # Set parameters cleaning empty values - query_params = {k: v for k, v in {"enabled": enabled_parameter, "org": org_name}.items() if v is not None} + query_params = {k: v for k, v in {"enabled": enabled_parameter, "org": org_parameter}.items() if v is not None} response = self.client.get(url, query_params, format="json") assert response.status_code == status.HTTP_200_OK self.assertEqual(set(t["name"] for t in response.data["results"]), set(expected_taxonomies)) - def test_list_taxonomy_invalid_org( - self, - ): + def test_list_taxonomy_staff(self) -> None: + """ + Tests that staff users see all taxonomies + """ + # Default page_size=10, and so "tBA1" and "tBA2" appear on the second page + expected_taxonomies = ["ot1", "ot2", "st1", "st2", "t1", "t2", "tA1", "tA2", "tB1", "tB2"] + self._test_list_taxonomy( + user_attr="staff", + expected_taxonomies=expected_taxonomies, + ) + + @ddt.data( + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA", + ) + def test_list_taxonomy_orgA(self, user_attr: str) -> None: + """ + Tests that non staff users from orgA can see only enabled taxonomies from orgA and global taxonomies + """ + expected_taxonomies = ["st1", "t1", "tA1", "tBA1"] + self._test_list_taxonomy( + user_attr=user_attr, + enabled_parameter=True, + expected_taxonomies=expected_taxonomies, + ) + + @ddt.data( + (True, ["ot1", "st1", "t1", "tA1", "tB1", "tBA1"]), + (False, ["ot2", "st2", "t2", "tA2", "tB2", "tBA2"]), + ) + @ddt.unpack + def test_list_taxonomy_enabled_filter(self, enabled_parameter: bool, expected_taxonomies: list[str]) -> None: + """ + Tests that the enabled filter works as expected + """ + self._test_list_taxonomy( + user_attr="staff", + enabled_parameter=enabled_parameter, + expected_taxonomies=expected_taxonomies + ) + + @ddt.data( + ("orgA", ["st1", "st2", "t1", "t2", "tA1", "tA2", "tBA1", "tBA2"]), + ("orgB", ["st1", "st2", "t1", "t2", "tB1", "tB2", "tBA1", "tBA2"]), + ("orgX", ["st1", "st2", "t1", "t2"]), + ) + @ddt.unpack + def test_list_taxonomy_org_filter(self, org_parameter: str, expected_taxonomies: list[str]) -> None: + """ + Tests that the org filter works as expected + """ + self._test_list_taxonomy( + user_attr="staff", + org_parameter=org_parameter, + expected_taxonomies=expected_taxonomies, + ) + + def test_list_taxonomy_invalid_org(self) -> None: + """ + Tests that using an invalid org in the filter will raise BAD_REQUEST + """ url = TAXONOMY_ORG_LIST_URL - self.client.force_authenticate(user=self.userS) + self.client.force_authenticate(user=self.staff) - # Set parameters cleaning empty values query_params = {"org": "invalidOrg"} response = self.client.get(url, query_params, format="json") @@ -236,30 +369,38 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): assert response.status_code == status.HTTP_400_BAD_REQUEST @ddt.data( - ("user", ("tA1", "tB1", "tC1"), None), - ("userA", ("tA1", "tB1", "tC1"), None), - ("userS", ("st2", "t1", "t2"), "3"), + ("user", (), None), + ("staffA", ["tA2", "tBA1", "tBA2"], None), + ("staff", ["st2", "t1", "t2"], "3"), ) @ddt.unpack - def test_list_taxonomy_pagination(self, user_attr, expected_taxonomies, expected_next_page): + def test_list_taxonomy_pagination( + self, user_attr: str, expected_taxonomies: list[str], expected_next_page: str | None + ) -> None: + """ + Tests that the pagination works as expected + """ url = TAXONOMY_ORG_LIST_URL - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) query_params = {"page_size": 3, "page": 2} response = self.client.get(url, query_params, format="json") - assert response.status_code == status.HTTP_200_OK - self.assertEqual(set(t["name"] for t in response.data["results"]), set(expected_taxonomies)) - parsed_url = urlparse(response.data["next"]) + assert response.status_code == status.HTTP_200_OK if len(expected_taxonomies) > 0 else status.HTTP_404_NOT_FOUND + if status.is_success(response.status_code): + self.assertEqual(set(t["name"] for t in response.data["results"]), set(expected_taxonomies)) + parsed_url = urlparse(response.data["next"]) - next_page = parse_qs(parsed_url.query).get("page", [None])[0] - assert next_page == expected_next_page + next_page = parse_qs(parsed_url.query).get("page", [None])[0] + assert next_page == expected_next_page - def test_list_invalid_page(self): + def test_list_invalid_page(self) -> None: + """ + Tests that using an invalid page will raise NOT_FOUND + """ url = TAXONOMY_ORG_LIST_URL self.client.force_authenticate(user=self.user) @@ -271,86 +412,28 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): assert response.status_code == status.HTTP_404_NOT_FOUND @ddt.data( - (None, "ot1", status.HTTP_403_FORBIDDEN), - (None, "ot2", status.HTTP_403_FORBIDDEN), - (None, "st1", status.HTTP_403_FORBIDDEN), - (None, "st2", status.HTTP_403_FORBIDDEN), - (None, "t1", status.HTTP_403_FORBIDDEN), - (None, "t2", status.HTTP_403_FORBIDDEN), - (None, "tA1", status.HTTP_403_FORBIDDEN), - (None, "tA2", status.HTTP_403_FORBIDDEN), - (None, "tB1", status.HTTP_403_FORBIDDEN), - (None, "tB2", status.HTTP_403_FORBIDDEN), - (None, "tC1", status.HTTP_403_FORBIDDEN), - (None, "tC2", status.HTTP_403_FORBIDDEN), - ("user", "ot1", status.HTTP_200_OK), - ("user", "ot2", status.HTTP_404_NOT_FOUND), - ("user", "st1", status.HTTP_200_OK), - ("user", "st2", status.HTTP_404_NOT_FOUND), - ("user", "t1", status.HTTP_200_OK), - ("user", "t2", status.HTTP_404_NOT_FOUND), - ("user", "tA1", status.HTTP_200_OK), - ("user", "tA2", status.HTTP_404_NOT_FOUND), - ("user", "tB1", status.HTTP_200_OK), - ("user", "tB2", status.HTTP_404_NOT_FOUND), - ("user", "tC1", status.HTTP_200_OK), - ("user", "tC2", status.HTTP_404_NOT_FOUND), - ("userA", "ot1", status.HTTP_200_OK), - ("userA", "ot2", status.HTTP_404_NOT_FOUND), - ("userA", "st1", status.HTTP_200_OK), - ("userA", "st2", status.HTTP_404_NOT_FOUND), - ("userA", "t1", status.HTTP_200_OK), - ("userA", "t2", status.HTTP_404_NOT_FOUND), - ("userA", "tA1", status.HTTP_200_OK), - ("userA", "tA2", status.HTTP_404_NOT_FOUND), - ("userA", "tB1", status.HTTP_200_OK), - ("userA", "tB2", status.HTTP_404_NOT_FOUND), - ("userA", "tC1", status.HTTP_200_OK), - ("userA", "tC2", status.HTTP_404_NOT_FOUND), - ("userS", "ot1", status.HTTP_200_OK), - ("userS", "ot2", status.HTTP_200_OK), - ("userS", "st1", status.HTTP_200_OK), - ("userS", "st2", status.HTTP_200_OK), - ("userS", "t1", status.HTTP_200_OK), - ("userS", "t2", status.HTTP_200_OK), - ("userS", "tA1", status.HTTP_200_OK), - ("userS", "tA2", status.HTTP_200_OK), - ("userS", "tB1", status.HTTP_200_OK), - ("userS", "tB2", status.HTTP_200_OK), - ("userS", "tC1", status.HTTP_200_OK), - ("userS", "tC2", status.HTTP_200_OK), - ) - @ddt.unpack - def test_detail_taxonomy(self, user_attr, taxonomy_attr, expected_status): - taxonomy = getattr(self, taxonomy_attr) - - url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) - - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) - - response = self.client.get(url) - assert response.status_code == expected_status - - if status.is_success(expected_status): - check_taxonomy(response.data, taxonomy.pk, **(TaxonomySerializer(taxonomy.cast()).data)) - - @ddt.data( - (None, status.HTTP_403_FORBIDDEN), + (None, status.HTTP_401_UNAUTHORIZED), ("user", status.HTTP_403_FORBIDDEN), - ("userA", status.HTTP_403_FORBIDDEN), - ("userS", status.HTTP_201_CREATED), + ("content_creatorA", status.HTTP_403_FORBIDDEN), + ("instructorA", status.HTTP_403_FORBIDDEN), + ("library_staffA", status.HTTP_403_FORBIDDEN), + ("course_instructorA", status.HTTP_403_FORBIDDEN), + ("course_staffA", status.HTTP_403_FORBIDDEN), + ("library_userA", status.HTTP_403_FORBIDDEN), + ("staffA", status.HTTP_201_CREATED), + ("staff", status.HTTP_201_CREATED), ) @ddt.unpack - def test_create_taxonomy(self, user_attr, expected_status): + def test_create_taxonomy(self, user_attr: str, expected_status: int) -> None: + """ + Tests that only Taxonomy admins and org level admins can create taxonomies + """ url = TAXONOMY_ORG_LIST_URL create_data = { "name": "taxonomy_data", "description": "This is a description", "enabled": True, - "required": True, "allow_multiple": True, } @@ -369,73 +452,514 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): response = self.client.get(url) check_taxonomy(response.data, response.data["id"], **create_data) + # Also checks if the taxonomy was associated with the org + if user_attr == "staffA": + assert TaxonomyOrg.objects.filter(taxonomy=response.data["id"], org=self.orgA).exists() + + +@ddt.ddt +class TestTaxonomyDetailExportMixin(TestTaxonomyObjectsMixin): + """ + Test cases to be used with detail and export actions + """ + + @abc.abstractmethod + def _test_api_call(self, **_kwargs) -> None: + """ + Helper function to call the detail/export endpoint and check the response + """ + @ddt.data( - (None, "ot1", status.HTTP_403_FORBIDDEN), - (None, "ot2", status.HTTP_403_FORBIDDEN), - (None, "st1", status.HTTP_403_FORBIDDEN), - (None, "st2", status.HTTP_403_FORBIDDEN), - (None, "t1", status.HTTP_403_FORBIDDEN), - (None, "t2", status.HTTP_403_FORBIDDEN), - (None, "tA1", status.HTTP_403_FORBIDDEN), - (None, "tA2", status.HTTP_403_FORBIDDEN), - (None, "tB1", status.HTTP_403_FORBIDDEN), - (None, "tB2", status.HTTP_403_FORBIDDEN), - (None, "tC1", status.HTTP_403_FORBIDDEN), - (None, "tC2", status.HTTP_403_FORBIDDEN), - ("user", "ot1", status.HTTP_403_FORBIDDEN), - ("user", "ot2", status.HTTP_403_FORBIDDEN), - ("user", "st1", status.HTTP_403_FORBIDDEN), - ("user", "st2", status.HTTP_403_FORBIDDEN), - ("user", "t1", status.HTTP_403_FORBIDDEN), - ("user", "t2", status.HTTP_403_FORBIDDEN), - ("user", "tA1", status.HTTP_403_FORBIDDEN), - ("user", "tA2", status.HTTP_403_FORBIDDEN), - ("user", "tB1", status.HTTP_403_FORBIDDEN), - ("user", "tB2", status.HTTP_403_FORBIDDEN), - ("user", "tC1", status.HTTP_403_FORBIDDEN), - ("user", "tC2", status.HTTP_403_FORBIDDEN), - ("userA", "ot1", status.HTTP_403_FORBIDDEN), - ("userA", "ot2", status.HTTP_403_FORBIDDEN), - ("userA", "st1", status.HTTP_403_FORBIDDEN), - ("userA", "st2", status.HTTP_403_FORBIDDEN), - ("userA", "t1", status.HTTP_403_FORBIDDEN), - ("userA", "t2", status.HTTP_403_FORBIDDEN), - ("userA", "tA1", status.HTTP_403_FORBIDDEN), - ("userA", "tA2", status.HTTP_403_FORBIDDEN), - ("userA", "tB1", status.HTTP_403_FORBIDDEN), - ("userA", "tB2", status.HTTP_403_FORBIDDEN), - ("userA", "tC1", status.HTTP_403_FORBIDDEN), - ("userA", "tC2", status.HTTP_403_FORBIDDEN), - ("userS", "ot1", status.HTTP_200_OK), - ("userS", "ot2", status.HTTP_200_OK), - ("userS", "st1", status.HTTP_403_FORBIDDEN), - ("userS", "st2", status.HTTP_403_FORBIDDEN), - ("userS", "t1", status.HTTP_200_OK), - ("userS", "t2", status.HTTP_200_OK), - ("userS", "t1", status.HTTP_200_OK), - ("userS", "t2", status.HTTP_200_OK), - ("userS", "tA1", status.HTTP_200_OK), - ("userS", "tA2", status.HTTP_200_OK), - ("userS", "tB1", status.HTTP_200_OK), - ("userS", "tB2", status.HTTP_200_OK), - ("userS", "tC1", status.HTTP_200_OK), - ("userS", "tC2", status.HTTP_200_OK), + "user", + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA", + ) + def test_detail_taxonomy_all_org_enabled(self, user_attr: str) -> None: + """ + Tests that everyone can see enabled global taxonomies + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr="t1", + expected_status=status.HTTP_200_OK, + reason="Everyone should see enabled global taxonomies", + ) + + @ddt.data( + ("content_creatorA", "tA1", "User with OrgContentCreatorRole(orgA) should see an enabled taxonomy from orgA"), + ("content_creatorA", "tBA1", "User with OrgContentCreatorRole(orgA) should see an enabled taxonomy from orgA"), + ("content_creatorA", "t1", "User with OrgContentCreatorRole(orgA) should see an enabled global taxonomy"), + ("instructorA", "tA1", "User with OrgInstructorRole(orgA) should see an enabled taxonomy from orgA"), + ("instructorA", "tBA1", "User with OrgInstructorRole(orgA) should see an enabled taxonomy from orgA"), + ("instructorA", "t1", "User with OrgInstructorRole(orgA) should see an enabled global taxonomy"), + ("library_staffA", "tA1", "User with OrgLibraryUserRole(orgA) should see an enabled taxonomy from orgA"), + ("library_staffA", "tBA1", "User with OrgLibraryUserRole(orgA) should see an enabled taxonomy from orgA"), + ("library_staffA", "t1", "User with OrgInstructorRole(orgA) should see an enabled global taxonomy"), + ( + "course_instructorA", + "tA1", + "User with CourseInstructorRole in a course from orgA should see an enabled taxonomy from orgA" + ), + ( + "course_instructorA", + "tBA1", + "User with CourseInstructorRole in a course from orgA should see an enabled taxonomy from orgA" + ), + ( + "course_instructorA", + "t1", + "User with CourseInstructorRole in a course from orgA should see an enabled global taxonomy" + ), + ( + "course_staffA", + "tA1", + "User with CourseStaffRole in a course from orgA should see an enabled taxonomy from orgA" + ), + ( + "course_staffA", + "tBA1", + "User with CourseStaffRole in a course from orgA should see an enabled taxonomy from orgA" + ), + ( + "course_staffA", + "t1", + "User with CourseStaffRole in a course from orgA should see an enabled global taxonomy" + ), + ( + "library_userA", + "tA1", + "User with permission on a library from orgA should see an enabled taxonomy from orgA" + ), + ( + "library_userA", + "tBA1", + "User with permission on a library from orgA should see an enabled taxonomy from orgA" + ), + ( + "library_userA", + "t1", + "User with permission on a library from orgA should see an enabled global taxonomy" + ), ) @ddt.unpack - def test_update_taxonomy(self, user_attr, taxonomy_attr, expected_status): + def test_detail_taxonomy_org_user_see_enabled(self, user_attr: str, taxonomy_attr: str, reason: str) -> None: + """ + Tests that org users (content creators and instructors) can see enabled global taxonomies and taxonomies + from their orgs + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_200_OK, + reason=reason, + ) + + @ddt.data( + "tA2", + "tBA2", + ) + def test_detail_taxonomy_org_admin_see_disabled(self, taxonomy_attr: str) -> None: + """ + Tests that org admins can see disabled taxonomies from their orgs + """ + self._test_api_call( + user_attr="staffA", + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_200_OK, + reason="User with OrgContentCreatorRole(orgA) should see a disabled taxonomy from orgA", + ) + + @ddt.data( + "st2", + "t2", + ) + def test_detail_taxonomy_org_admin_dont_see_disabled_global(self, taxonomy_attr: str) -> None: + """ + Tests that org admins can't see disabled global taxonomies + """ + self._test_api_call( + user_attr="staffA", + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_404_NOT_FOUND, + reason="User with OrgContentCreatorRole(orgA) shouldn't see a disabled global taxonomy", + ) + + @ddt.data( + ("content_creatorA", "t2", "User with OrgContentCreatorRole(orgA) shouldn't see a disabled global taxonomy"), + ("instructorA", "tA2", "User with OrgInstructorRole(orgA) shouldn't see a disabled taxonomy from orgA"), + ("instructorA", "tBA2", "User with OrgInstructorRole(orgA) shouldn't see a disabled taxonomy from orgA"), + ("instructorA", "t2", "User with OrgInstructorRole(orgA) shouldn't see a disabled global taxonomy"), + ("library_staffA", "tA2", "User with OrgLibraryUserRole(orgA) shouldn't see a disabled taxonomy from orgA"), + ("library_staffA", "tBA2", "User with OrgLibraryUserRole(orgA) shouldn't see a disabled taxonomy from orgA"), + ("library_staffA", "t2", "User with OrgInstructorRole(orgA) shouldn't see a disabled global taxonomy"), + ( + "course_instructorA", + "tA2", + "User with CourseInstructorRole in a course from orgA shouldn't see a disabled taxonomy from orgA" + ), + ( + "course_instructorA", + "tBA2", + "User with CourseInstructorRole in a course from orgA shouldn't see a disabled taxonomy from orgA" + ), + ( + "course_instructorA", + "t2", + "User with CourseInstructorRole in a course from orgA shouldn't see a disabled global taxonomy" + ), + ( + "course_staffA", + "tA2", + "User with CourseStaffRole in a course from orgA shouldn't see a disabled taxonomy from orgA" + ), + ( + "course_staffA", + "tBA2", + "User with CourseStaffRole in a course from orgA shouldn't see a disabled taxonomy from orgA" + ), + ( + "course_staffA", + "t2", + "User with CourseStaffRole in a course from orgA should't see a disabled global taxonomy" + ), + ( + "library_userA", + "tA2", + "User with permission on a library from orgA shouldn't see an disabled taxonomy from orgA" + ), + ( + "library_userA", + "tBA2", + "User with permission on a library from orgA shouldn't see an disabled taxonomy from orgA" + ), + ( + "library_userA", + "t2", + "User with permission on a library from orgA shouldn't see an disabled global taxonomy" + ), + ) + @ddt.unpack + def test_detail_taxonomy_org_user_dont_see_disabled(self, user_attr: str, taxonomy_attr: str, reason: str) -> None: + """ + Tests that org users (content creators and instructors) can't see disabled global taxonomies and taxonomies + from their orgs + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_404_NOT_FOUND, + reason=reason, + ) + + @ddt.data( + ("staff", "ot1", "Staff should see an enabled no org taxonomy"), + ("staff", "ot2", "Staff should see a disabled no org taxonomy"), + ) + @ddt.unpack + def test_detail_taxonomy_staff_see_no_org(self, user_attr: str, taxonomy_attr: str, reason: str) -> None: + """ + Tests that staff can see taxonomies with no org + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_200_OK, + reason=reason, + ) + + @ddt.data( + "staffA", + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA" + ) + def test_detail_taxonomy_other_dont_see_no_org(self, user_attr: str) -> None: + """ + Tests that org users can't see taxonomies with no org + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr="ot1", + expected_status=status.HTTP_404_NOT_FOUND, + reason="Only staff should see taxonomies with no org", + ) + + @ddt.data( + "staffA", + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA" + ) + def test_detail_taxonomy_dont_see_other_org(self, user_attr: str) -> None: + """ + Tests that org users can't see taxonomies from other orgs + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr="tB1", + expected_status=status.HTTP_404_NOT_FOUND, + reason="Users shouldn't see taxonomies from other orgs", + ) + + @ddt.data( + "ot1", + "ot2", + "st1", + "st2", + "t1", + "t2", + "tA1", + "tA2", + "tB1", + "tB2", + "tBA1", + "tBA2", + ) + def test_detail_taxonomy_staff_see_all(self, taxonomy_attr: str) -> None: + """ + Tests that staff can see all taxonomies + """ + self._test_api_call( + user_attr="staff", + taxonomy_attr=taxonomy_attr, + expected_status=status.HTTP_200_OK, + reason="Staff should see all taxonomies", + ) + + +@skip_unless_cms +class TestTaxonomyDetailViewSet(TestTaxonomyDetailExportMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with detail action + """ + + def _test_api_call(self, **kwargs) -> None: + """ + Helper function to call the retrieve endpoint and check the response + """ + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" + taxonomy = getattr(self, taxonomy_attr) url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.get(url) + assert response.status_code == expected_status, reason + + if status.is_success(expected_status): + check_taxonomy(response.data, taxonomy.pk, **(TaxonomySerializer(taxonomy.cast()).data)) + + +@skip_unless_cms +class TestTaxonomyExportViewSet(TestTaxonomyDetailExportMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with export action + """ + + def _test_api_call(self, **kwargs) -> None: + """ + Helper function to call the export endpoint and check the response + """ + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" + + taxonomy = getattr(self, taxonomy_attr) + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + response = self.client.get(url) + assert response.status_code == expected_status, reason + assert len(response.data) > 0 + + +@ddt.ddt +class TestTaxonomyChangeMixin(TestTaxonomyObjectsMixin): + """ + Test cases to be used with update, patch and delete actions + """ + + @abc.abstractmethod + def _test_api_call(self, **_kwargs) -> None: + """ + Helper function to call the update/patch/delete endpoint and check the response + """ + + @ddt.data( + "ot1", + "ot2", + "st1", + "st2", + "t1", + "t2", + "tA1", + "tA2", + "tB1", + "tB2", + "tBA1", + "tBA2", + ) + def test_regular_user_cant_edit_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that regular users can't edit taxonomies + """ + self._test_api_call( + user_attr="user", + taxonomy_attr=taxonomy_attr, + expected_status=[status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND], + reason="Regular users shouldn't be able to edit taxonomies", + ) + + @ddt.data( + "content_creatorA", + "instructorA", + "library_staffA", + "course_instructorA", + "course_staffA", + "library_userA", + ) + def test_org_user_cant_edit_org_taxonomies(self, user_attr: str) -> None: + """ + Tests that content creators and instructors from orgA can't edit taxonomies from orgA + """ + self._test_api_call( + user_attr=user_attr, + taxonomy_attr="tA1", + expected_status=[status.HTTP_403_FORBIDDEN], + reason="Content creators and instructors shouldn't be able to edit taxonomies", + ) + + @ddt.data( + "tA1", + "tA2", + "tBA1", + "tBA2", + ) + def test_org_staff_can_edit_org_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that org staff can edit taxonomies from their orgs + """ + self._test_api_call( + user_attr="staffA", + taxonomy_attr=taxonomy_attr, + # Check both status: 200 for update and 204 for delete + expected_status=[status.HTTP_200_OK, status.HTTP_204_NO_CONTENT], + reason="Org staff should be able to edit taxonomies from their orgs", + ) + + @ddt.data( + "tB1", + "tB2", + ) + def test_org_staff_cant_edit_other_org_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that org staff can't edit taxonomies from other orgs + """ + self._test_api_call( + user_attr="staffA", + taxonomy_attr=taxonomy_attr, + expected_status=[status.HTTP_403_FORBIDDEN, status.HTTP_404_NOT_FOUND], + reason="Org staff shouldn't be able to edit taxonomies from other orgs", + ) + + @ddt.data( + "ot1", + "ot2", + "t1", + "t2", + "tA1", + "tA2", + "tB1", + "tB2", + "tBA1", + "tBA2", + + ) + def test_staff_can_edit_almost_all_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that staff can edit all but system defined taxonomies + """ + self._test_api_call( + user_attr="staff", + taxonomy_attr=taxonomy_attr, + # Check both status: 200 for update and 204 for delete + expected_status=[status.HTTP_200_OK, status.HTTP_204_NO_CONTENT], + reason="Staff should be able to edit all but system defined taxonomies", + ) + + @ddt.data( + "st1", + "st2", + ) + def test_staff_cant_edit_system_defined_taxonomies(self, taxonomy_attr: str) -> None: + """ + Tests that staff can't edit system defined taxonomies + """ + self._test_api_call( + user_attr="staff", + taxonomy_attr=taxonomy_attr, + # Check both status: 200 for update and 204 for delete + expected_status=[status.HTTP_403_FORBIDDEN], + reason="Staff shouldn't be able to edit system defined ", + ) + + +@skip_unless_cms +class TestTaxonomyUpdateViewSet(TestTaxonomyChangeMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with PUT method + """ + + def _test_api_call(self, **kwargs) -> None: + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" + + taxonomy = getattr(self, taxonomy_attr) + + url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) + + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) response = self.client.put(url, {"name": "new name"}, format="json") - assert response.status_code == expected_status + assert response.status_code in expected_status, reason # If we were able to update the taxonomy, check if the name changed - if status.is_success(expected_status): + if status.is_success(response.status_code): response = self.client.get(url) check_taxonomy( response.data, @@ -444,94 +968,38 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): "name": "new name", "description": taxonomy.description, "enabled": taxonomy.enabled, - "required": taxonomy.required, }, ) - @ddt.data( - (False, status.HTTP_403_FORBIDDEN), - (True, status.HTTP_403_FORBIDDEN), - ) - @ddt.unpack - def test_update_taxonomy_system_defined(self, update_value, expected_status): - """ - Test that we can't update system_defined field - """ - url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.st1.pk) - self.client.force_authenticate(user=self.userS) - response = self.client.put(url, {"name": "new name", "system_defined": update_value}, format="json") - assert response.status_code == expected_status +@skip_unless_cms +class TestTaxonomyPatchViewSet(TestTaxonomyChangeMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with PATCH method + """ - # Verify that system_defined has not changed - response = self.client.get(url) - assert response.data["system_defined"] is True + def _test_api_call(self, **kwargs) -> None: + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" - @ddt.data( - (None, "ot1", status.HTTP_403_FORBIDDEN), - (None, "ot2", status.HTTP_403_FORBIDDEN), - (None, "st1", status.HTTP_403_FORBIDDEN), - (None, "st2", status.HTTP_403_FORBIDDEN), - (None, "t1", status.HTTP_403_FORBIDDEN), - (None, "t2", status.HTTP_403_FORBIDDEN), - (None, "tA1", status.HTTP_403_FORBIDDEN), - (None, "tA2", status.HTTP_403_FORBIDDEN), - (None, "tB1", status.HTTP_403_FORBIDDEN), - (None, "tB2", status.HTTP_403_FORBIDDEN), - (None, "tC1", status.HTTP_403_FORBIDDEN), - (None, "tC2", status.HTTP_403_FORBIDDEN), - ("user", "ot1", status.HTTP_403_FORBIDDEN), - ("user", "ot2", status.HTTP_403_FORBIDDEN), - ("user", "st1", status.HTTP_403_FORBIDDEN), - ("user", "st2", status.HTTP_403_FORBIDDEN), - ("user", "t1", status.HTTP_403_FORBIDDEN), - ("user", "t2", status.HTTP_403_FORBIDDEN), - ("user", "tA1", status.HTTP_403_FORBIDDEN), - ("user", "tA2", status.HTTP_403_FORBIDDEN), - ("user", "tB1", status.HTTP_403_FORBIDDEN), - ("user", "tB2", status.HTTP_403_FORBIDDEN), - ("user", "tC1", status.HTTP_403_FORBIDDEN), - ("user", "tC2", status.HTTP_403_FORBIDDEN), - ("userA", "ot1", status.HTTP_403_FORBIDDEN), - ("userA", "ot2", status.HTTP_403_FORBIDDEN), - ("userA", "st1", status.HTTP_403_FORBIDDEN), - ("userA", "st2", status.HTTP_403_FORBIDDEN), - ("userA", "t1", status.HTTP_403_FORBIDDEN), - ("userA", "t2", status.HTTP_403_FORBIDDEN), - ("userA", "tA1", status.HTTP_403_FORBIDDEN), - ("userA", "tA2", status.HTTP_403_FORBIDDEN), - ("userA", "tB1", status.HTTP_403_FORBIDDEN), - ("userA", "tB2", status.HTTP_403_FORBIDDEN), - ("userA", "tC1", status.HTTP_403_FORBIDDEN), - ("userA", "tC2", status.HTTP_403_FORBIDDEN), - ("userS", "ot1", status.HTTP_200_OK), - ("userS", "ot2", status.HTTP_200_OK), - ("userS", "st1", status.HTTP_403_FORBIDDEN), - ("userS", "st2", status.HTTP_403_FORBIDDEN), - ("userS", "t1", status.HTTP_200_OK), - ("userS", "t2", status.HTTP_200_OK), - ("userS", "tA1", status.HTTP_200_OK), - ("userS", "tA2", status.HTTP_200_OK), - ("userS", "tB1", status.HTTP_200_OK), - ("userS", "tB2", status.HTTP_200_OK), - ("userS", "tC1", status.HTTP_200_OK), - ("userS", "tC2", status.HTTP_200_OK), - ) - @ddt.unpack - def test_patch_taxonomy(self, user_attr, taxonomy_attr, expected_status): taxonomy = getattr(self, taxonomy_attr) url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) response = self.client.patch(url, {"name": "new name"}, format="json") - assert response.status_code == expected_status + assert response.status_code in expected_status, reason # If we were able to patch the taxonomy, check if the name changed - if status.is_success(expected_status): + if status.is_success(response.status_code): response = self.client.get(url) check_taxonomy( response.data, @@ -540,127 +1008,56 @@ class TestTaxonomyViewSet(TestTaxonomyObjectsMixin, APITestCase): "name": "new name", "description": taxonomy.description, "enabled": taxonomy.enabled, - "required": taxonomy.required, }, ) - @ddt.data( - (False, status.HTTP_403_FORBIDDEN), - (True, status.HTTP_403_FORBIDDEN), - ) - @ddt.unpack - def test_patch_taxonomy_system_defined(self, update_value, expected_status): - """ - Test that we can't patch system_defined field - """ - url = TAXONOMY_ORG_DETAIL_URL.format(pk=self.st1.pk) - self.client.force_authenticate(user=self.userS) - response = self.client.patch(url, {"name": "new name", "system_defined": update_value}, format="json") - assert response.status_code == expected_status +@skip_unless_cms +class TestTaxonomyDeleteViewSet(TestTaxonomyChangeMixin, APITestCase): + """ + Test cases for TaxonomyViewSet with DELETE method + """ - # Verify that system_defined has not changed - response = self.client.get(url) - assert response.data["system_defined"] is True + def _test_api_call(self, **kwargs) -> None: + user_attr = kwargs.get("user_attr") + taxonomy_attr = kwargs.get("taxonomy_attr") + expected_status = kwargs.get("expected_status") + reason = kwargs.get("reason", "Unexpected response status") + + assert taxonomy_attr is not None, "taxonomy_attr is required" + assert user_attr is not None, "user_attr is required" + assert expected_status is not None, "expected_status is required" - @ddt.data( - (None, "ot1", status.HTTP_403_FORBIDDEN), - (None, "ot2", status.HTTP_403_FORBIDDEN), - (None, "st1", status.HTTP_403_FORBIDDEN), - (None, "st2", status.HTTP_403_FORBIDDEN), - (None, "t1", status.HTTP_403_FORBIDDEN), - (None, "t2", status.HTTP_403_FORBIDDEN), - (None, "tA1", status.HTTP_403_FORBIDDEN), - (None, "tA2", status.HTTP_403_FORBIDDEN), - (None, "tB1", status.HTTP_403_FORBIDDEN), - (None, "tB2", status.HTTP_403_FORBIDDEN), - (None, "tC1", status.HTTP_403_FORBIDDEN), - (None, "tC2", status.HTTP_403_FORBIDDEN), - ("user", "ot1", status.HTTP_403_FORBIDDEN), - ("user", "ot2", status.HTTP_403_FORBIDDEN), - ("user", "st1", status.HTTP_403_FORBIDDEN), - ("user", "st2", status.HTTP_403_FORBIDDEN), - ("user", "t1", status.HTTP_403_FORBIDDEN), - ("user", "t2", status.HTTP_403_FORBIDDEN), - ("user", "tA1", status.HTTP_403_FORBIDDEN), - ("user", "tA2", status.HTTP_403_FORBIDDEN), - ("user", "tB1", status.HTTP_403_FORBIDDEN), - ("user", "tB2", status.HTTP_403_FORBIDDEN), - ("user", "tC1", status.HTTP_403_FORBIDDEN), - ("user", "tC2", status.HTTP_403_FORBIDDEN), - ("userA", "ot1", status.HTTP_403_FORBIDDEN), - ("userA", "ot2", status.HTTP_403_FORBIDDEN), - ("userA", "st1", status.HTTP_403_FORBIDDEN), - ("userA", "st2", status.HTTP_403_FORBIDDEN), - ("userA", "t1", status.HTTP_403_FORBIDDEN), - ("userA", "t2", status.HTTP_403_FORBIDDEN), - ("userA", "tA1", status.HTTP_403_FORBIDDEN), - ("userA", "tA2", status.HTTP_403_FORBIDDEN), - ("userA", "tB1", status.HTTP_403_FORBIDDEN), - ("userA", "tB2", status.HTTP_403_FORBIDDEN), - ("userA", "tC1", status.HTTP_403_FORBIDDEN), - ("userA", "tC2", status.HTTP_403_FORBIDDEN), - ("userS", "ot1", status.HTTP_204_NO_CONTENT), - ("userS", "ot2", status.HTTP_204_NO_CONTENT), - ("userS", "st1", status.HTTP_403_FORBIDDEN), - ("userS", "st2", status.HTTP_403_FORBIDDEN), - ("userS", "t1", status.HTTP_204_NO_CONTENT), - ("userS", "t2", status.HTTP_204_NO_CONTENT), - ("userS", "tA1", status.HTTP_204_NO_CONTENT), - ("userS", "tA2", status.HTTP_204_NO_CONTENT), - ("userS", "tB1", status.HTTP_204_NO_CONTENT), - ("userS", "tB2", status.HTTP_204_NO_CONTENT), - ("userS", "tC1", status.HTTP_204_NO_CONTENT), - ("userS", "tC2", status.HTTP_204_NO_CONTENT), - ) - @ddt.unpack - def test_delete_taxonomy(self, user_attr, taxonomy_attr, expected_status): taxonomy = getattr(self, taxonomy_attr) url = TAXONOMY_ORG_DETAIL_URL.format(pk=taxonomy.pk) - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) response = self.client.delete(url) - assert response.status_code == expected_status + assert response.status_code in expected_status, reason # If we were able to delete the taxonomy, check that it's really gone - if status.is_success(expected_status): + if status.is_success(response.status_code): response = self.client.get(url) assert response.status_code == status.HTTP_404_NOT_FOUND -@skip_unless_cms -@ddt.ddt -@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": False}) -class TestTaxonomyViewSetNoCreatorGroup(TestTaxonomyViewSet): # pylint: disable=test-inherits-tests +class TestObjectTagMixin(TestTaxonomyObjectsMixin): """ - Test cases for TaxonomyViewSet when ENABLE_CREATOR_GROUP is False - - The permissions are the same for when ENABLED_CREATOR_GRUP is True - """ - - -@skip_unless_cms -@ddt.ddt -class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): - """ - Testing various cases for the ObjectTagView. + Sets up data for testing ObjectTags. """ def setUp(self): """ Setup the test cases """ super().setUp() - self.courseA = CourseLocator("orgA", "101", "test") self.xblockA = BlockUsageLocator( course_key=self.courseA, block_type='problem', block_id='block_id' ) - self.courseB = CourseLocator("orgB", "101", "test") self.xblockB = BlockUsageLocator( course_key=self.courseB, block_type='problem', @@ -668,13 +1065,13 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): ) self.multiple_taxonomy = Taxonomy.objects.create(name="Multiple Taxonomy", allow_multiple=True) - self.required_taxonomy = Taxonomy.objects.create(name="Required Taxonomy", required=True) + self.single_value_taxonomy = Taxonomy.objects.create(name="Required Taxonomy", allow_multiple=False) for i in range(20): # Valid ObjectTags Tag.objects.create(taxonomy=self.tA1, value=f"Tag {i}") Tag.objects.create(taxonomy=self.tA2, value=f"Tag {i}") Tag.objects.create(taxonomy=self.multiple_taxonomy, value=f"Tag {i}") - Tag.objects.create(taxonomy=self.required_taxonomy, value=f"Tag {i}") + Tag.objects.create(taxonomy=self.single_value_taxonomy, value=f"Tag {i}") self.open_taxonomy = Taxonomy.objects.create(name="Enabled Free-Text Taxonomy", allow_free_text=True) @@ -685,7 +1082,7 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): rel_type=TaxonomyOrg.RelType.OWNER, ) TaxonomyOrg.objects.create( - taxonomy=self.required_taxonomy, + taxonomy=self.single_value_taxonomy, org=self.orgA, rel_type=TaxonomyOrg.RelType.OWNER, ) @@ -695,37 +1092,41 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): rel_type=TaxonomyOrg.RelType.OWNER, ) - add_users(self.userS, CourseStaffRole(self.courseA), self.userA) + add_users(self.staff, CourseStaffRole(self.courseA), self.staffA) + + +@skip_unless_cms +@ddt.ddt +class TestObjectTagViewSet(TestObjectTagMixin, APITestCase): + """ + Testing various cases for the ObjectTagView. + """ + + def test_get_tags(self): + pass @ddt.data( # userA and userS are staff in courseA and can tag using enabled taxonomies - (None, "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), ("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), - ("userA", "tA1", ["Tag 1"], status.HTTP_200_OK), - ("userS", "tA1", ["Tag 1"], status.HTTP_200_OK), - (None, "tA1", [], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK), ("user", "tA1", [], status.HTTP_403_FORBIDDEN), - ("userA", "tA1", [], status.HTTP_200_OK), - ("userS", "tA1", [], status.HTTP_200_OK), - (None, "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", [], status.HTTP_200_OK), + ("staff", "tA1", [], status.HTTP_200_OK), ("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), - ("userA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), - ("userS", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), - (None, "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), + ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), ("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), - ("userA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), - ("userS", "open_taxonomy", ["tag1"], status.HTTP_200_OK), - # Only userS is Tagging Admin and can tag objects using disabled taxonomies - (None, "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN), - ("user", "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN), - ("userA", "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN), - ("userS", "tA2", ["Tag 1"], status.HTTP_200_OK), + ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK), ) @ddt.unpack def test_tag_course(self, user_attr, taxonomy_attr, tag_values, expected_status): - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + """ + Tests that only staff and org level users can tag courses + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) taxonomy = getattr(self, taxonomy_attr) @@ -738,62 +1139,72 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): assert len(response.data) == len(tag_values) assert set(t["value"] for t in response.data) == set(tag_values) + # Check that re-fetching the tags returns what we set + response = self.client.get(url, format="json") + assert status.is_success(response.status_code) + assert set(t["value"] for t in response.data) == set(tag_values) + @ddt.data( - # Can't add invalid tags to a object using a closed taxonomy - (None, "tA1", ["invalid"], status.HTTP_403_FORBIDDEN), - ("user", "tA1", ["invalid"], status.HTTP_403_FORBIDDEN), - ("userA", "tA1", ["invalid"], status.HTTP_400_BAD_REQUEST), - ("userS", "tA1", ["invalid"], status.HTTP_400_BAD_REQUEST), - (None, "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN), - ("user", "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN), - ("userA", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), - ("userS", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), - # Staff can't add invalid tags to a object using a closed taxonomy - ("userS", "tA2", ["invalid"], status.HTTP_400_BAD_REQUEST), + "staffA", + "staff", + ) + def test_tag_course_disabled_taxonomy(self, user_attr): + """ + Nobody can use disable taxonomies to tag objects + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + disabled_taxonomy = self.tA2 + assert disabled_taxonomy.enabled is False + + url = OBJECT_TAG_UPDATE_URL.format(object_id=self.courseA, taxonomy_id=disabled_taxonomy.pk) + response = self.client.put(url, {"tags": ["Tag 1"]}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @ddt.data( + ("staffA", "tA1"), + ("staff", "tA1"), + ("staffA", "multiple_taxonomy"), + ("staff", "multiple_taxonomy"), ) @ddt.unpack - def test_tag_course_invalid(self, user_attr, taxonomy_attr, tag_values, expected_status): - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + def test_tag_course_invalid(self, user_attr, taxonomy_attr): + """ + Tests that nobody can add invalid tags to a course using a closed taxonomy + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) taxonomy = getattr(self, taxonomy_attr) url = OBJECT_TAG_UPDATE_URL.format(object_id=self.courseA, taxonomy_id=taxonomy.pk) - response = self.client.put(url, {"tags": tag_values}, format="json") - assert response.status_code == expected_status - assert not status.is_success(expected_status) # No success cases here + response = self.client.put(url, {"tags": ["invalid"]}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST @ddt.data( - # userA and userS are staff in courseA (owner of xblockA) and can tag using enabled taxonomies - (None, "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), + # userA and userS are staff in courseA (owner of xblockA) and can tag using any taxonomies ("user", "tA1", ["Tag 1"], status.HTTP_403_FORBIDDEN), - ("userA", "tA1", ["Tag 1"], status.HTTP_200_OK), - ("userS", "tA1", ["Tag 1"], status.HTTP_200_OK), - (None, "tA1", [], status.HTTP_403_FORBIDDEN), - ("user", "tA1", [], status.HTTP_403_FORBIDDEN), - ("userA", "tA1", [], status.HTTP_200_OK), - ("userS", "tA1", [], status.HTTP_200_OK), - (None, "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", ["Tag 1"], status.HTTP_200_OK), + ("staff", "tA1", ["Tag 1"], status.HTTP_200_OK), ("user", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_403_FORBIDDEN), - ("userA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), - ("userS", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), - (None, "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), + ("staffA", "tA1", [], status.HTTP_200_OK), + ("staff", "tA1", [], status.HTTP_200_OK), + ("staffA", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), + ("staff", "multiple_taxonomy", ["Tag 1", "Tag 2"], status.HTTP_200_OK), ("user", "open_taxonomy", ["tag1"], status.HTTP_403_FORBIDDEN), - ("userA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), - ("userS", "open_taxonomy", ["tag1"], status.HTTP_200_OK), - # Only userS is Tagging Admin and can tag objects using disabled taxonomies - (None, "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN), - ("user", "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN), - ("userA", "tA2", ["Tag 1"], status.HTTP_403_FORBIDDEN), - ("userS", "tA2", ["Tag 1"], status.HTTP_200_OK), + ("staffA", "open_taxonomy", ["tag1"], status.HTTP_200_OK), + ("staff", "open_taxonomy", ["tag1"], status.HTTP_200_OK), ) @ddt.unpack def test_tag_xblock(self, user_attr, taxonomy_attr, tag_values, expected_status): - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + """ + Tests that only staff and org level users can tag xblocks + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) taxonomy = getattr(self, taxonomy_attr) @@ -806,32 +1217,67 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): assert len(response.data) == len(tag_values) assert set(t["value"] for t in response.data) == set(tag_values) + # Check that re-fetching the tags returns what we set + response = self.client.get(url, format="json") + assert status.is_success(response.status_code) + assert set(t["value"] for t in response.data) == set(tag_values) + @ddt.data( - # Can't add invalid tags to a object using a closed taxonomy - (None, "tA1", ["invalid"], status.HTTP_403_FORBIDDEN), - ("user", "tA1", ["invalid"], status.HTTP_403_FORBIDDEN), - ("userA", "tA1", ["invalid"], status.HTTP_400_BAD_REQUEST), - ("userS", "tA1", ["invalid"], status.HTTP_400_BAD_REQUEST), - (None, "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN), - ("user", "multiple_taxonomy", ["invalid"], status.HTTP_403_FORBIDDEN), - ("userA", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), - ("userS", "multiple_taxonomy", ["invalid"], status.HTTP_400_BAD_REQUEST), - # Staff can't add invalid tags to a object using a closed taxonomy - ("userS", "tA2", ["invalid"], status.HTTP_400_BAD_REQUEST), + "staffA", + "staff", + ) + def test_tag_xblock_disabled_taxonomy(self, user_attr): + """ + Tests that nobody can use disabled taxonomies to tag xblocks + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) + + disabled_taxonomy = self.tA2 + assert disabled_taxonomy.enabled is False + + url = OBJECT_TAG_UPDATE_URL.format(object_id=self.xblockA, taxonomy_id=disabled_taxonomy.pk) + response = self.client.put(url, {"tags": ["Tag 1"]}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN + + @ddt.data( + ("staffA", "tA1"), + ("staff", "tA1"), + ("staffA", "multiple_taxonomy"), + ("staff", "multiple_taxonomy"), ) @ddt.unpack - def test_tag_xblock_invalid(self, user_attr, taxonomy_attr, tag_values, expected_status): - if user_attr: - user = getattr(self, user_attr) - self.client.force_authenticate(user=user) + def test_tag_xblock_invalid(self, user_attr, taxonomy_attr): + """ + Tests that staff can't add invalid tags to a xblock using a closed taxonomy + """ + user = getattr(self, user_attr) + self.client.force_authenticate(user=user) taxonomy = getattr(self, taxonomy_attr) url = OBJECT_TAG_UPDATE_URL.format(object_id=self.xblockA, taxonomy_id=taxonomy.pk) - response = self.client.put(url, {"tags": tag_values}, format="json") - assert response.status_code == expected_status - assert not status.is_success(expected_status) # No success cases here + response = self.client.put(url, {"tags": ["invalid"]}, format="json") + assert response.status_code == status.HTTP_400_BAD_REQUEST + + @ddt.data( + "courseB", + "xblockB", + ) + def test_tag_no_permission(self, objectid_attr): + """ + Test that a user without access to courseB can't apply tags to it + """ + self.client.force_authenticate(user=self.staffA) + object_id = getattr(self, objectid_attr) + + url = OBJECT_TAG_UPDATE_URL.format(object_id=object_id, taxonomy_id=self.tA1.pk) + + response = self.client.put(url, {"tags": ["Tag 1"]}, format="json") + + assert response.status_code == status.HTTP_403_FORBIDDEN @ddt.data( "courseB", @@ -841,11 +1287,40 @@ class TestObjectTagViewSet(TestTaxonomyObjectsMixin, APITestCase): """ Test that a user without access to courseB can't apply tags to it """ - self.client.force_authenticate(user=self.userA) object_id = getattr(self, objectid_attr) url = OBJECT_TAG_UPDATE_URL.format(object_id=object_id, taxonomy_id=self.tA1.pk) response = self.client.put(url, {"tags": ["Tag 1"]}, format="json") - assert response.status_code == status.HTTP_403_FORBIDDEN + assert response.status_code == status.HTTP_401_UNAUTHORIZED + + +@skip_unless_cms +@ddt.ddt +class TestDownloadTemplateView(APITestCase): + """ + Tests the taxonomy template downloads. + """ + @ddt.data( + ("template.csv", "text/csv"), + ("template.json", "application/json"), + ) + @ddt.unpack + def test_download(self, filename, content_type): + url = TAXONOMY_TEMPLATE_URL.format(filename=filename) + response = self.client.get(url) + assert response.status_code == status.HTTP_200_OK + assert response.headers['Content-Type'] == content_type + assert response.headers['Content-Disposition'] == f'attachment; filename="{filename}"' + assert int(response.headers['Content-Length']) > 0 + + def test_download_not_found(self): + url = TAXONOMY_TEMPLATE_URL.format(filename="template.txt") + response = self.client.get(url) + assert response.status_code == status.HTTP_404_NOT_FOUND + + def test_download_method_not_allowed(self): + url = TAXONOMY_TEMPLATE_URL.format(filename="template.txt") + response = self.client.post(url) + assert response.status_code == status.HTTP_405_METHOD_NOT_ALLOWED diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py index 5c0bceb38e..38bb0a9ac1 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/urls.py @@ -6,14 +6,21 @@ from rest_framework.routers import DefaultRouter from django.urls.conf import path, include -from openedx_tagging.core.tagging.rest_api.v1 import views as oel_tagging_views +from openedx_tagging.core.tagging.rest_api.v1 import ( + views_import as oel_tagging_views_import, +) from . import views router = DefaultRouter() router.register("taxonomies", views.TaxonomyOrgView, basename="taxonomy") -router.register("object_tags", oel_tagging_views.ObjectTagView, basename="object_tag") +router.register("object_tags", views.ObjectTagOrgView, basename="object_tag") urlpatterns = [ + path( + "taxonomies/import/template.<str:file_ext>", + oel_tagging_views_import.TemplateView.as_view(), + name="taxonomy-import-template", + ), path('', include(router.urls)) ] diff --git a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py index 7bdfe7cd39..6f1f7dd73a 100644 --- a/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py +++ b/openedx/core/djangoapps/content_tagging/rest_api/v1/views.py @@ -2,7 +2,7 @@ Tagging Org API Views """ -from openedx_tagging.core.tagging.rest_api.v1.views import TaxonomyView +from openedx_tagging.core.tagging.rest_api.v1.views import ObjectTagView, TaxonomyView from ...api import ( @@ -10,8 +10,9 @@ from ...api import ( get_taxonomies, get_taxonomies_for_org, ) +from ...rules import get_admin_orgs from .serializers import TaxonomyOrgListQueryParamsSerializer -from .filters import UserOrgFilterBackend +from .filters import ObjectTagTaxonomyOrgFilterBackend, UserOrgFilterBackend class TaxonomyOrgView(TaxonomyView): @@ -57,4 +58,15 @@ class TaxonomyOrgView(TaxonomyView): """ Create a new taxonomy. """ - serializer.instance = create_taxonomy(**serializer.validated_data) + user_admin_orgs = get_admin_orgs(self.request.user) + serializer.instance = create_taxonomy(**serializer.validated_data, orgs=user_admin_orgs) + + +class ObjectTagOrgView(ObjectTagView): + """ + View to create and retrieve ObjectTags for a provided Object ID (object_id). + This view extends the ObjectTagView to add Organization filters for the results. + + Refer to ObjectTagView docstring for usage details. + """ + filter_backends = [ObjectTagTaxonomyOrgFilterBackend] diff --git a/openedx/core/djangoapps/content_tagging/rules.py b/openedx/core/djangoapps/content_tagging/rules.py index bad38019ce..5206f62045 100644 --- a/openedx/core/djangoapps/content_tagging/rules.py +++ b/openedx/core/djangoapps/content_tagging/rules.py @@ -6,35 +6,210 @@ from typing import Union import django.contrib.auth.models import openedx_tagging.core.tagging.rules as oel_tagging +from organizations.models import Organization import rules from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey, UsageKey -from common.djangoapps.student.auth import is_content_creator, has_studio_write_access +from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access +from common.djangoapps.student.models import CourseAccessRole +from common.djangoapps.student.roles import ( + CourseInstructorRole, + CourseStaffRole, + OrgContentCreatorRole, + OrgInstructorRole, + OrgLibraryUserRole, + OrgStaffRole +) +from openedx.core.djangoapps.content_libraries.api import get_libraries_for_user from .models import TaxonomyOrg UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser] -def is_taxonomy_user(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool: +def is_org_admin(user: UserType, orgs: list[Organization] | None = None) -> bool: + """ + Return True if the given user is an admin for any of the given orgs. """ - Returns True if the given user is a Taxonomy User for the given content taxonomy. - Taxonomy users include global staff and superusers, plus course creators who can create courses for any org. - Otherwise, we need to check taxonomy provided to determine if the user is an org-level course creator for one of - the orgs allowed to use this taxonomy. Only global staff and superusers can use disabled system taxonomies. + return len(get_admin_orgs(user, orgs)) > 0 + + +def is_org_user(user: UserType, orgs: list[Organization]) -> bool: """ + Return True if the given user is a member of any of the given orgs. + """ + return len(get_user_orgs(user, orgs)) > 0 + + +def get_admin_orgs(user: UserType, orgs: list[Organization] | None = None) -> list[Organization]: + """ + Returns a list of orgs that the given user is an admin, from the given list of orgs. + + If no orgs are provided, check all orgs + """ + org_list = Organization.objects.all() if orgs is None else orgs + return [ + org for org in org_list if OrgStaffRole(org=org.short_name).has_user(user) + ] + + +def get_content_creator_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]: + """ + Returns a list of orgs that the given user is a content creator, the given list of orgs. + """ + return [ + org for org in orgs if ( + OrgLibraryUserRole(org=org.short_name).has_user(user) or + OrgInstructorRole(org=org.short_name).has_user(user) or + OrgContentCreatorRole(org=org.short_name).has_user(user) + ) + ] + + +def get_instructor_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]: + """ + Returns a list of orgs that the given user is an instructor, from the given list of orgs. + """ + instructor_roles = CourseAccessRole.objects.filter( + org__in=(org.short_name for org in orgs), + user=user, + role__in=(CourseStaffRole.ROLE, CourseInstructorRole.ROLE), + ) + instructor_orgs = [role.org for role in instructor_roles] + return [org for org in orgs if org.short_name in instructor_orgs] + + +def get_library_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]: + """ + Returns a list of orgs that the given user has explicity permission, from the given list of orgs. + """ + return [ + org for org in orgs if ( + len(get_libraries_for_user(user, org=org.short_name)) > 0 + ) + ] + + +def get_user_orgs(user: UserType, orgs: list[Organization]) -> list[Organization]: + """ + Return a list of orgs that the given user is a member of (instructor or content creator), + from the given list of orgs. + """ + content_creator_orgs = get_content_creator_orgs(user, orgs) + instructor_orgs = get_instructor_orgs(user, orgs) + library_user_orgs = get_library_user_orgs(user, orgs) + user_orgs = list(set(content_creator_orgs) | set(instructor_orgs) | set(library_user_orgs)) + + return user_orgs + + +def can_create_taxonomy(user: UserType) -> bool: + """ + Returns True if the given user can create a taxonomy. + + Taxonomy admins and org-level staff can create taxonomies. + """ + # Taxonomy admins can view any taxonomy if oel_tagging.is_taxonomy_admin(user): return True + # Org-level staff can create taxonomies associated with one of their orgs. + if is_org_admin(user): + return True + + return False + + +@rules.predicate +def can_view_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool: + """ + Returns True if the given user can view the given taxonomy. + + Taxonomy admins can view any taxonomy. + Org-level staff can view any taxonomy that is associated with one of their orgs. + Org-level course creators and instructors can view any enabled taxonomy that is owned by one of their orgs. + """ + # The following code allows METHOD permission (GET) in the viewset for everyone + if not taxonomy: + return True + + taxonomy = taxonomy.cast() + + # Taxonomy admins can view any taxonomy + if oel_tagging.is_taxonomy_admin(user): + return True + + is_all_org = TaxonomyOrg.objects.filter( + taxonomy=taxonomy, + org=None, + rel_type=TaxonomyOrg.RelType.OWNER, + ).exists() + + # Enabled all-org taxonomies can be viewed by any registred user + if is_all_org: + return taxonomy.enabled + taxonomy_orgs = TaxonomyOrg.get_organizations( taxonomy=taxonomy, rel_type=TaxonomyOrg.RelType.OWNER, ) - for org in taxonomy_orgs: - if is_content_creator(user, org.short_name): - return True + + # Org-level staff can view any taxonomy that is associated with one of their orgs. + if is_org_admin(user, taxonomy_orgs): + return True + + # Org-level course creators and instructors can view any enabled taxonomy that is owned by one of their orgs. + if is_org_user(user, taxonomy_orgs): + return taxonomy.enabled + + return False + + +@rules.predicate +def can_change_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool: + """ + Returns True if the given user can edit the given taxonomy. + + System definied taxonomies cannot be edited + Taxonomy admins can edit any non system defined taxonomies + Only taxonomy admins can edit all org taxonomies + Org-level staff can edit any taxonomy that is associated with one of their orgs. + """ + # The following code allows METHOD permission (PUT, PATCH) in the viewset for everyone + if not taxonomy: + return True + + taxonomy = taxonomy.cast() + + # System definied taxonomies cannot be edited + if taxonomy.system_defined: + return False + + # Taxonomy admins can edit any non system defined taxonomies + if oel_tagging.is_taxonomy_admin(user): + return True + + is_all_org = TaxonomyOrg.objects.filter( + taxonomy=taxonomy, + org=None, + rel_type=TaxonomyOrg.RelType.OWNER, + ).exists() + + # Only taxonomy admins can edit all org taxonomies + if is_all_org: + return False + + taxonomy_orgs = TaxonomyOrg.get_organizations( + taxonomy=taxonomy, + rel_type=TaxonomyOrg.RelType.OWNER, + ) + + # Org-level staff can edit any taxonomy that is associated with one of their orgs. + if is_org_admin(user, taxonomy_orgs): + return True + return False @@ -57,12 +232,32 @@ def can_change_object_tag_objectid(user: UserType, object_id: str) -> bool: @rules.predicate -def can_change_object_tag_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool: +def can_view_object_tag_taxonomy(user: UserType, taxonomy: oel_tagging.Taxonomy) -> bool: """ - Taxonomy users can tag objects using tags from any taxonomy that they have permission to view. Only taxonomy admins - can tag objects using tags from disabled taxonomies. + Only enabled taxonomy and users with permission to view this taxonomy can view object tags + from that taxonomy. + + This rule is different from can_view_taxonomy because it checks if the taxonomy is enabled. """ - return oel_tagging.is_taxonomy_admin(user) or (taxonomy.cast().enabled and is_taxonomy_user(user, taxonomy)) + return taxonomy.cast().enabled and can_view_taxonomy(user, taxonomy) + + +@rules.predicate +def can_view_object_tag_objectid(user: UserType, object_id: str) -> bool: + """ + Everyone that has permission to view the object should be able to tag it. + """ + if not object_id: + raise ValueError("object_id must be provided") + try: + usage_key = UsageKey.from_string(object_id) + if not usage_key.course_key.is_course: + raise ValueError("object_id must be from a block or a course") + course_key = usage_key.course_key + except InvalidKeyError: + course_key = CourseKey.from_string(object_id) + + return has_studio_read_access(user, course_key) @rules.predicate @@ -82,10 +277,11 @@ def can_change_taxonomy_tag(user: UserType, tag: oel_tagging.Tag | None = None) # Taxonomy -rules.set_perm("oel_tagging.add_taxonomy", oel_tagging.is_taxonomy_admin) -rules.set_perm("oel_tagging.change_taxonomy", oel_tagging.can_change_taxonomy) -rules.set_perm("oel_tagging.delete_taxonomy", oel_tagging.can_change_taxonomy) -rules.set_perm("oel_tagging.view_taxonomy", oel_tagging.can_view_taxonomy) +rules.set_perm("oel_tagging.add_taxonomy", can_create_taxonomy) +rules.set_perm("oel_tagging.change_taxonomy", can_change_taxonomy) +rules.set_perm("oel_tagging.delete_taxonomy", can_change_taxonomy) +rules.set_perm("oel_tagging.view_taxonomy", can_view_taxonomy) +rules.set_perm("oel_tagging.export_taxonomy", can_view_taxonomy) # Tag rules.set_perm("oel_tagging.add_tag", can_change_taxonomy_tag) @@ -95,11 +291,13 @@ rules.set_perm("oel_tagging.view_tag", rules.always_allow) # ObjectTag rules.set_perm("oel_tagging.add_object_tag", oel_tagging.can_change_object_tag) -rules.set_perm("oel_tagging.change_object_tag", oel_tagging.can_change_object_tag) -rules.set_perm("oel_tagging.delete_object_tag", oel_tagging.can_change_object_tag) -rules.set_perm("oel_tagging.view_object_tag", rules.always_allow) +rules.set_perm("oel_tagging.change_objecttag", oel_tagging.can_change_object_tag) +rules.set_perm("oel_tagging.delete_objecttag", oel_tagging.can_change_object_tag) +rules.set_perm("oel_tagging.view_objecttag", oel_tagging.can_view_object_tag) # This perms are used in the tagging rest api from openedx_tagging that is exposed in the CMS. They are overridden here # to include Organization and objects permissions. -rules.set_perm("oel_tagging.change_objecttag_taxonomy", can_change_object_tag_taxonomy) +rules.set_perm("oel_tagging.view_objecttag_taxonomy", can_view_object_tag_taxonomy) +rules.set_perm("oel_tagging.view_objecttag_objectid", can_view_object_tag_objectid) +rules.set_perm("oel_tagging.change_objecttag_taxonomy", can_view_object_tag_taxonomy) rules.set_perm("oel_tagging.change_objecttag_objectid", can_change_object_tag_objectid) diff --git a/openedx/core/djangoapps/content_tagging/tasks.py b/openedx/core/djangoapps/content_tagging/tasks.py index 3ffa6c29f1..063a24072e 100644 --- a/openedx/core/djangoapps/content_tagging/tasks.py +++ b/openedx/core/djangoapps/content_tagging/tasks.py @@ -10,12 +10,13 @@ from celery_utils.logged_task import LoggedTask from django.conf import settings from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute -from opaque_keys.edx.keys import CourseKey, UsageKey +from opaque_keys.edx.keys import LearningContextKey, UsageKey from openedx_tagging.core.tagging.models import Taxonomy from xmodule.modulestore.django import modulestore from . import api +from .types import ContentKey LANGUAGE_TAXONOMY_ID = -1 @@ -23,53 +24,32 @@ log = logging.getLogger(__name__) User = get_user_model() -def _has_taxonomy(taxonomy: Taxonomy, content_object: CourseKey | UsageKey) -> bool: - """ - Return True if this Taxonomy have some Tag set in the content_object - """ - _exausted = object() - - content_tags = api.get_content_tags(object_id=str(content_object), taxonomy_id=taxonomy.id) - return next(content_tags, _exausted) is not _exausted - - -def _set_initial_language_tag(content_object: CourseKey | UsageKey, lang: str) -> None: +def _set_initial_language_tag(content_key: ContentKey, lang_code: str) -> None: """ Create a tag for the language taxonomy in the content_object if it doesn't exist. + lang_code is the two-letter language code, optionally with country suffix. + If the language is not configured in the plataform or the language tag doesn't exist, use the default language of the platform. """ - lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID) + lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID).cast() - if lang and not _has_taxonomy(lang_taxonomy, content_object): - tags = api.get_tags(lang_taxonomy) - is_language_configured = any(lang_code == lang for lang_code, _ in settings.LANGUAGES) is not None - if not is_language_configured: + if lang_code and not api.get_content_tags(object_key=content_key, taxonomy_id=lang_taxonomy.id): + try: + lang_tag = lang_taxonomy.tag_for_external_id(lang_code) + except api.oel_tagging.TagDoesNotExist: + default_lang_code = settings.LANGUAGE_CODE logging.warning( "Language not configured in the plataform: %s. Using default language: %s", - lang, - settings.LANGUAGE_CODE, + lang_code, + default_lang_code, ) - lang = settings.LANGUAGE_CODE - - lang_tag = next((tag for tag in tags if tag.external_id == lang), None) - if lang_tag is None: - if not is_language_configured: - logging.error( - "Language tag not found for default language: %s. Skipping", lang - ) - return - - logging.warning( - "Language tag not found for language: %s. Using default language: %s", lang, settings.LANGUAGE_CODE - ) - lang_tag = next(tag for tag in tags if tag.external_id == settings.LANGUAGE_CODE) - - api.tag_content_object(lang_taxonomy, [lang_tag.id], content_object) + lang_tag = lang_taxonomy.tag_for_external_id(default_lang_code) + api.tag_content_object(content_key, lang_taxonomy, [lang_tag.value]) -def _delete_tags(content_object: CourseKey | UsageKey) -> None: +def _delete_tags(content_object: ContentKey) -> None: api.delete_object_tags(str(content_object)) @@ -84,14 +64,14 @@ def update_course_tags(course_key_str: str) -> bool: course_key_str (str): identifier of the Course """ try: - course_key = CourseKey.from_string(course_key_str) + course_key = LearningContextKey.from_string(course_key_str) log.info("Updating tags for Course with id: %s", course_key) course = modulestore().get_course(course_key) if course: - lang = course.language - _set_initial_language_tag(course_key, lang) + lang_code = course.language + _set_initial_language_tag(course_key, lang_code) return True except Exception as e: # pylint: disable=broad-except @@ -109,7 +89,7 @@ def delete_course_tags(course_key_str: str) -> bool: course_key_str (str): identifier of the Course """ try: - course_key = CourseKey.from_string(course_key_str) + course_key = LearningContextKey.from_string(course_key_str) log.info("Deleting tags for Course with id: %s", course_key) @@ -140,11 +120,11 @@ def update_xblock_tags(usage_key_str: str) -> bool: course = modulestore().get_course(usage_key.course_key) if course is None: return True - lang = course.language + lang_code = course.language else: return True - _set_initial_language_tag(usage_key, lang) + _set_initial_language_tag(usage_key, lang_code) return True except Exception as e: # pylint: disable=broad-except diff --git a/openedx/core/djangoapps/content_tagging/tests/test_api.py b/openedx/core/djangoapps/content_tagging/tests/test_api.py index 263ae761ef..985199322d 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_api.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_api.py @@ -2,7 +2,7 @@ import ddt from django.test.testcases import TestCase from opaque_keys.edx.keys import CourseKey, UsageKey -from openedx_tagging.core.tagging.models import ObjectTag, Tag +from openedx_tagging.core.tagging.models import Tag from organizations.models import Organization from .. import api @@ -22,7 +22,7 @@ class TestTaxonomyMixin: name="Learning Objectives", enabled=False, ) - api.set_taxonomy_orgs(self.taxonomy_disabled, all_orgs=True) + api.set_taxonomy_orgs(self.taxonomy_disabled, orgs=[self.org1, self.org2]) self.taxonomy_all_orgs = api.create_taxonomy( name="Content Types", enabled=True, @@ -65,59 +65,42 @@ class TestTaxonomyMixin: ) # ObjectTags self.all_orgs_course_tag = api.tag_content_object( + object_key=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"), taxonomy=self.taxonomy_all_orgs, - tags=[self.tag_all_orgs.id], - object_id=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"), + tags=[self.tag_all_orgs.value], )[0] self.all_orgs_block_tag = api.tag_content_object( - taxonomy=self.taxonomy_all_orgs, - tags=[self.tag_all_orgs.id], - object_id=UsageKey.from_string( + object_key=UsageKey.from_string( "block-v1:Ax+DemoX+Demo_Course+type@vertical+block@abcde" ), + taxonomy=self.taxonomy_all_orgs, + tags=[self.tag_all_orgs.value], )[0] self.both_orgs_course_tag = api.tag_content_object( + object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), taxonomy=self.taxonomy_both_orgs, - tags=[self.tag_both_orgs.id], - object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + tags=[self.tag_both_orgs.value], )[0] self.both_orgs_block_tag = api.tag_content_object( - taxonomy=self.taxonomy_both_orgs, - tags=[self.tag_both_orgs.id], - object_id=UsageKey.from_string( + object_key=UsageKey.from_string( "block-v1:OeX+DemoX+Demo_Course+type@video+block@abcde" ), + taxonomy=self.taxonomy_both_orgs, + tags=[self.tag_both_orgs.value], )[0] self.one_org_block_tag = api.tag_content_object( - taxonomy=self.taxonomy_one_org, - tags=[self.tag_one_org.id], - object_id=UsageKey.from_string( + object_key=UsageKey.from_string( "block-v1:OeX+DemoX+Demo_Course+type@html+block@abcde" ), + taxonomy=self.taxonomy_one_org, + tags=[self.tag_one_org.value], )[0] self.disabled_course_tag = api.tag_content_object( + object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), taxonomy=self.taxonomy_disabled, - tags=[self.tag_disabled.id], - object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + tags=[self.tag_disabled.value], )[0] - # Invalid object tags must be manually created - self.all_orgs_invalid_tag = ObjectTag.objects.create( - taxonomy=self.taxonomy_all_orgs, - tag=self.tag_all_orgs, - object_id="course-v1_OpenedX_DemoX_Demo_Course", - ) - self.one_org_invalid_org_tag = ObjectTag.objects.create( - taxonomy=self.taxonomy_one_org, - tag=self.tag_one_org, - object_id="block-v1_OeX_DemoX_Demo_Course_type_html_block@abcde", - ) - self.no_orgs_invalid_tag = ObjectTag.objects.create( - taxonomy=self.taxonomy_no_orgs, - tag=self.tag_no_orgs, - object_id=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), - ) - @ddt.ddt class TestAPITaxonomy(TestTaxonomyMixin, TestCase): @@ -138,8 +121,8 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase): @ddt.data( # All orgs (None, True, ["taxonomy_all_orgs"]), - (None, False, ["taxonomy_disabled"]), - (None, None, ["taxonomy_all_orgs", "taxonomy_disabled"]), + (None, False, []), + (None, None, ["taxonomy_all_orgs"]), # Org 1 ("org1", True, ["taxonomy_all_orgs", "taxonomy_one_org", "taxonomy_both_orgs"]), ("org1", False, ["taxonomy_disabled"]), @@ -190,11 +173,11 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase): ): taxonomy_id = getattr(self, taxonomy_attr).id object_tag = getattr(self, object_tag_attr) - with self.assertNumQueries(2): + with self.assertNumQueries(1): valid_tags = list( api.get_content_tags( + object_key=object_tag.object_key, taxonomy_id=taxonomy_id, - object_id=object_tag.object_id, ) ) assert len(valid_tags) == 1 @@ -204,38 +187,55 @@ class TestAPITaxonomy(TestTaxonomyMixin, TestCase): ("taxonomy_disabled", "disabled_course_tag"), ("taxonomy_all_orgs", "all_orgs_course_tag"), ("taxonomy_all_orgs", "all_orgs_block_tag"), - ("taxonomy_all_orgs", "all_orgs_invalid_tag"), ("taxonomy_both_orgs", "both_orgs_course_tag"), ("taxonomy_both_orgs", "both_orgs_block_tag"), ("taxonomy_one_org", "one_org_block_tag"), - ("taxonomy_one_org", "one_org_invalid_org_tag"), ) @ddt.unpack - def test_get_content_tags_include_invalid( + def test_get_content_tags( self, taxonomy_attr, object_tag_attr, ): taxonomy_id = getattr(self, taxonomy_attr).id object_tag = getattr(self, object_tag_attr) - with self.assertNumQueries(2): + with self.assertNumQueries(1): valid_tags = list( api.get_content_tags( + object_key=object_tag.object_key, taxonomy_id=taxonomy_id, - object_id=object_tag.object_id, ) ) assert len(valid_tags) == 1 assert valid_tags[0].id == object_tag.id - @ddt.data( - "all_orgs_invalid_tag", - "one_org_invalid_org_tag", - "no_orgs_invalid_tag", - ) - def test_object_tag_not_valid_check_object(self, tag_attr): - object_tag = getattr(self, tag_attr) - assert not object_tag.is_valid() - def test_get_tags(self): assert api.get_tags(self.taxonomy_all_orgs) == [self.tag_all_orgs] + + def test_cannot_tag_across_orgs(self): + """ + Ensure that I cannot apply tags from a taxonomy that's linked to another + org. + """ + # This taxonomy is only linked to the "OpenedX org", so it can't be used for "Axim" content. + taxonomy = self.taxonomy_one_org + tags = [self.tag_one_org.value] + with self.assertRaises(ValueError) as exc: + api.tag_content_object( + object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + taxonomy=taxonomy, + tags=tags, + ) + assert "The specified Taxonomy is not enabled for the content object's org (Ax)" in str(exc.exception) + # But this will work fine: + api.tag_content_object( + object_key=CourseKey.from_string("course-v1:OeX+DemoX+Demo_Course"), + taxonomy=taxonomy, + tags=tags, + ) + # As will this: + api.tag_content_object( + object_key=CourseKey.from_string("course-v1:Ax+DemoX+Demo_Course"), + taxonomy=self.taxonomy_both_orgs, + tags=[self.tag_both_orgs.value], + ) diff --git a/openedx/core/djangoapps/content_tagging/tests/test_models.py b/openedx/core/djangoapps/content_tagging/tests/test_models.py deleted file mode 100644 index 81c5da8641..0000000000 --- a/openedx/core/djangoapps/content_tagging/tests/test_models.py +++ /dev/null @@ -1,67 +0,0 @@ -""" -Test for Content models -""" -import ddt -from django.test.testcases import TestCase - -from openedx_tagging.core.tagging.models import ( - ObjectTag, - Tag, -) -from openedx_tagging.core.tagging.api import create_taxonomy -from ..models import ( - ContentLanguageTaxonomy, - ContentAuthorTaxonomy, -) - - -@ddt.ddt -class TestSystemDefinedModels(TestCase): - """ - Test for System defined models - """ - - @ddt.data( - (ContentLanguageTaxonomy, "taxonomy"), # Invalid object key - (ContentLanguageTaxonomy, "tag"), # Invalid external_id, invalid language - (ContentLanguageTaxonomy, "object"), # Invalid object key - (ContentAuthorTaxonomy, "taxonomy"), # Invalid object key - (ContentAuthorTaxonomy, "tag"), # Invalid external_id, User don't exits - (ContentAuthorTaxonomy, "object"), # Invalid object key - ) - @ddt.unpack - def test_validations( - self, - taxonomy_cls, - check, - ): - """ - Test that the respective validations are being called - """ - taxonomy = create_taxonomy( - name='Test taxonomy', - taxonomy_class=taxonomy_cls, - ) - - tag = Tag( - value="value", - external_id="external_id", - taxonomy=taxonomy, - ) - tag.save() - - object_tag = ObjectTag( - object_id='object_id', - taxonomy=taxonomy, - tag=tag, - ) - - check_taxonomy = check == 'taxonomy' - check_object = check == 'object' - check_tag = check == 'tag' - assert not taxonomy.validate_object_tag( - object_tag=object_tag, - check_taxonomy=check_taxonomy, - check_object=check_object, - check_tag=check_tag, - ) diff --git a/openedx/core/djangoapps/content_tagging/tests/test_rules.py b/openedx/core/djangoapps/content_tagging/tests/test_rules.py index 442fda5a71..9c1187ab91 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_rules.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_rules.py @@ -2,18 +2,16 @@ import ddt from django.contrib.auth import get_user_model -from django.test.testcases import TestCase, override_settings -from opaque_keys import InvalidKeyError +from django.test import TestCase from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from openedx_tagging.core.tagging.models import ( Tag, UserSystemDefinedTaxonomy, ) -from openedx_tagging.core.tagging.rules import ChangeObjectTagPermissionItem -from organizations.models import Organization +from openedx_tagging.core.tagging.rules import ObjectTagPermissionItem from common.djangoapps.student.auth import add_users, update_org_role -from common.djangoapps.student.roles import CourseCreatorRole, CourseStaffRole, OrgContentCreatorRole +from common.djangoapps.student.roles import CourseStaffRole, OrgStaffRole from .. import api from .test_api import TestTaxonomyMixin @@ -22,7 +20,6 @@ User = get_user_model() @ddt.ddt -@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": True}) class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): """ Tests that the expected rules have been applied to the Taxonomy models. @@ -42,12 +39,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): email="staff@example.com", is_staff=True, ) - # Normal user: grant course creator role (for all orgs) - self.user_all_orgs = User.objects.create( - username="user_all_orgs", - email="staff+all@example.com", - ) - add_users(self.staff, CourseCreatorRole(), self.user_all_orgs) # Normal user: grant course creator access to both org1 and org2 self.user_both_orgs = User.objects.create( @@ -56,7 +47,7 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): ) update_org_role( self.staff, - OrgContentCreatorRole, + OrgStaffRole, self.user_both_orgs, [self.org1.short_name, self.org2.short_name], ) @@ -67,7 +58,7 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): email="staff+org2@example.com", ) update_org_role( - self.staff, OrgContentCreatorRole, self.user_org2, [self.org2.short_name] + self.staff, OrgStaffRole, self.user_org2, [self.org2.short_name] ) # Normal user: no course creator access @@ -96,84 +87,69 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): block_id='block_id' ) - add_users(self.staff, CourseStaffRole(self.course1), self.user_all_orgs) add_users(self.staff, CourseStaffRole(self.course1), self.user_both_orgs) - add_users(self.staff, CourseStaffRole(self.course2), self.user_all_orgs) add_users(self.staff, CourseStaffRole(self.course2), self.user_both_orgs) add_users(self.staff, CourseStaffRole(self.course2), self.user_org2) add_users(self.staff, CourseStaffRole(self.course2), self.user_org2) - self.tax_all_course1 = ChangeObjectTagPermissionItem( + self.tax_all_course1 = ObjectTagPermissionItem( taxonomy=self.taxonomy_all_orgs, object_id=str(self.course1), ) - self.tax_all_course2 = ChangeObjectTagPermissionItem( + self.tax_all_course2 = ObjectTagPermissionItem( taxonomy=self.taxonomy_all_orgs, object_id=str(self.course2), ) - self.tax_all_xblock1 = ChangeObjectTagPermissionItem( + self.tax_all_xblock1 = ObjectTagPermissionItem( taxonomy=self.taxonomy_all_orgs, object_id=str(self.xblock1), ) - self.tax_all_xblock2 = ChangeObjectTagPermissionItem( + self.tax_all_xblock2 = ObjectTagPermissionItem( taxonomy=self.taxonomy_all_orgs, object_id=str(self.xblock2), ) - self.tax_both_course1 = ChangeObjectTagPermissionItem( + self.tax_both_course1 = ObjectTagPermissionItem( taxonomy=self.taxonomy_both_orgs, object_id=str(self.course1), ) - self.tax_both_course2 = ChangeObjectTagPermissionItem( + self.tax_both_course2 = ObjectTagPermissionItem( taxonomy=self.taxonomy_both_orgs, object_id=str(self.course2), ) - self.tax_both_xblock1 = ChangeObjectTagPermissionItem( + self.tax_both_xblock1 = ObjectTagPermissionItem( taxonomy=self.taxonomy_both_orgs, object_id=str(self.xblock1), ) - self.tax_both_xblock2 = ChangeObjectTagPermissionItem( + self.tax_both_xblock2 = ObjectTagPermissionItem( taxonomy=self.taxonomy_both_orgs, object_id=str(self.xblock2), ) - self.tax1_course1 = ChangeObjectTagPermissionItem( + self.tax1_course1 = ObjectTagPermissionItem( taxonomy=self.taxonomy_one_org, object_id=str(self.course1), ) - self.tax1_xblock1 = ChangeObjectTagPermissionItem( + self.tax1_xblock1 = ObjectTagPermissionItem( taxonomy=self.taxonomy_one_org, object_id=str(self.xblock1), ) - self.tax_no_org_course1 = ChangeObjectTagPermissionItem( + self.tax_no_org_course1 = ObjectTagPermissionItem( taxonomy=self.taxonomy_no_orgs, object_id=str(self.course1), ) - self.tax_no_org_xblock1 = ChangeObjectTagPermissionItem( + self.tax_no_org_xblock1 = ObjectTagPermissionItem( taxonomy=self.taxonomy_no_orgs, object_id=str(self.xblock1), ) - self.disabled_course_tag_perm = ChangeObjectTagPermissionItem( + self.disabled_course2_tag_perm = ObjectTagPermissionItem( taxonomy=self.taxonomy_disabled, object_id=str(self.course2), ) - self.all_orgs_invalid_tag_perm = ChangeObjectTagPermissionItem( - taxonomy=self.taxonomy_all_orgs, - object_id="course-v1_OpenedX_DemoX_Demo_Course", - ) - self.one_org_invalid_org_tag_perm = ChangeObjectTagPermissionItem( - taxonomy=self.taxonomy_one_org, - object_id="block-v1_OeX_DemoX_Demo_Course_type_html_block@abcde", - ) - self.no_orgs_invalid_tag_perm = ChangeObjectTagPermissionItem( - taxonomy=self.taxonomy_no_orgs, - object_id=str(self.course1), - ) - self.all_org_perms = ( self.tax_all_course1, self.tax_all_course2, @@ -198,8 +174,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): assert self.superuser.has_perm(perm, obj) assert self.staff.has_perm(perm) assert self.staff.has_perm(perm, obj) - assert self.user_all_orgs.has_perm(perm) - assert self.user_all_orgs.has_perm(perm, obj) # Org content creators are bound by a taxonomy's org restrictions assert self.user_both_orgs.has_perm(perm) == learner_perm @@ -213,58 +187,78 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): assert self.learner.has_perm(perm, obj) == learner_obj # Taxonomy + def test_taxonomy_base_add_permissions(self): + """ + Test that staff, superuser and org admins can call POST on taxonomies. + """ + perm = "oel_tagging.add_taxonomy" + assert self.superuser.has_perm(perm) + assert self.staff.has_perm(perm) + assert self.user_both_orgs.has_perm(perm) + assert self.user_org2.has_perm(perm) + assert not self.learner.has_perm(perm) @ddt.data( - "oel_tagging.add_taxonomy", "oel_tagging.change_taxonomy", "oel_tagging.delete_taxonomy", ) def test_taxonomy_base_edit_permissions(self, perm): """ - Test that only Staff & Superuser can call add/edit/delete taxonomies. + Test that everyone can call PUT, PATCH and DELETE on taxonomies. """ assert self.superuser.has_perm(perm) assert self.staff.has_perm(perm) - assert not self.user_all_orgs.has_perm(perm) - assert not self.user_both_orgs.has_perm(perm) - assert not self.user_org2.has_perm(perm) - assert not self.learner.has_perm(perm) + assert self.user_both_orgs.has_perm(perm) + assert self.user_org2.has_perm(perm) + assert self.learner.has_perm(perm) @ddt.data( "oel_tagging.view_taxonomy", ) def test_taxonomy_base_view_permissions(self, perm): """ - Test that everyone can call view taxonomy. + Test that everyone can call GET on taxonomies. """ assert self.superuser.has_perm(perm) assert self.staff.has_perm(perm) - assert self.user_all_orgs.has_perm(perm) assert self.user_both_orgs.has_perm(perm) assert self.user_org2.has_perm(perm) assert self.learner.has_perm(perm) @ddt.data( - ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"), ("oel_tagging.change_taxonomy", "taxonomy_disabled"), ("oel_tagging.change_taxonomy", "taxonomy_both_orgs"), ("oel_tagging.change_taxonomy", "taxonomy_one_org"), - ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"), - ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"), ("oel_tagging.delete_taxonomy", "taxonomy_disabled"), ("oel_tagging.delete_taxonomy", "taxonomy_both_orgs"), ("oel_tagging.delete_taxonomy", "taxonomy_one_org"), - ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"), ) @ddt.unpack def test_change_taxonomy(self, perm, taxonomy_attr): """ - Test that only Staff & Superuser can edit/delete taxonomies. + Test that only instance level and org level admins can edit/delete taxonomies from their orgs. + """ + taxonomy = getattr(self, taxonomy_attr) + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert self.user_both_orgs.has_perm(perm, taxonomy) + assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org") + assert not self.learner.has_perm(perm, taxonomy) + + @ddt.data( + ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"), + ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"), + ) + @ddt.unpack + def test_change_taxonomy_all_no_org(self, perm, taxonomy_attr): + """ + Test that only Staff & Superuser can edit/delete taxonomies from all or no org. """ taxonomy = getattr(self, taxonomy_attr) assert self.superuser.has_perm(perm, taxonomy) assert self.staff.has_perm(perm, taxonomy) - assert not self.user_all_orgs.has_perm(perm, taxonomy) assert not self.user_both_orgs.has_perm(perm, taxonomy) assert not self.user_org2.has_perm(perm, taxonomy) assert not self.learner.has_perm(perm, taxonomy) @@ -284,20 +278,31 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): system_taxonomy = system_taxonomy.cast() assert self.superuser.has_perm(perm, system_taxonomy) assert not self.staff.has_perm(perm, system_taxonomy) - assert not self.user_all_orgs.has_perm(perm, system_taxonomy) assert not self.user_both_orgs.has_perm(perm, system_taxonomy) assert not self.user_org2.has_perm(perm, system_taxonomy) assert not self.learner.has_perm(perm, system_taxonomy) + def test_view_taxonomy_no_orgs(self): + """ + Test that only Staff & Superuser can view taxonomies with no orgs. + """ + taxonomy = self.taxonomy_no_orgs + taxonomy.enabled = True + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert not self.user_both_orgs.has_perm(perm, taxonomy) + assert not self.user_org2.has_perm(perm, taxonomy) + assert not self.learner.has_perm(perm, taxonomy) + @ddt.data( - "taxonomy_all_orgs", "taxonomy_both_orgs", "taxonomy_one_org", - "taxonomy_no_orgs", ) def test_view_taxonomy_enabled(self, taxonomy_attr): """ - Test that anyone can view enabled taxonomies. + Test that anyone can view enabled taxonomies from their org. """ taxonomy = getattr(self, taxonomy_attr) taxonomy.enabled = True @@ -305,20 +310,31 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): assert self.superuser.has_perm(perm, taxonomy) assert self.staff.has_perm(perm, taxonomy) - assert self.user_all_orgs.has_perm(perm, taxonomy) + assert self.user_both_orgs.has_perm(perm, taxonomy) + assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org") + assert not self.learner.has_perm(perm, taxonomy) + + def test_view_taxonomy_enabled_all_orgs(self): + """ + Test that anyone can view enabled global taxonomies. + """ + taxonomy = self.taxonomy_all_orgs + taxonomy.enabled = True + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) assert self.user_both_orgs.has_perm(perm, taxonomy) assert self.user_org2.has_perm(perm, taxonomy) assert self.learner.has_perm(perm, taxonomy) @ddt.data( - "taxonomy_all_orgs", "taxonomy_both_orgs", "taxonomy_one_org", - "taxonomy_no_orgs", ) def test_view_taxonomy_disabled(self, taxonomy_attr): """ - Test that only Staff & Superuser can view disabled taxonomies. + Test that only instance level and org level admins can view disabled taxonomies. """ taxonomy = getattr(self, taxonomy_attr) taxonomy.enabled = False @@ -326,7 +342,34 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): assert self.superuser.has_perm(perm, taxonomy) assert self.staff.has_perm(perm, taxonomy) - assert not self.user_all_orgs.has_perm(perm, taxonomy) + assert self.user_both_orgs.has_perm(perm, taxonomy) + assert self.user_org2.has_perm(perm, taxonomy) == (taxonomy_attr != "taxonomy_one_org") + assert not self.learner.has_perm(perm, taxonomy) + + def test_view_taxonomy_all_orgs_disabled(self): + """ + Test that only instance level admins can view disabled all org taxonomies. + """ + taxonomy = self.taxonomy_all_orgs + taxonomy.enabled = False + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) + assert not self.user_both_orgs.has_perm(perm, taxonomy) + assert not self.user_org2.has_perm(perm, taxonomy) + assert not self.learner.has_perm(perm, taxonomy) + + def test_view_taxonomy_disabled_no_org(self): + """ + Test that only Staff & Superuser can view disabled taxonomies with no orgs. + """ + taxonomy = self.taxonomy_no_orgs + taxonomy.enabled = False + perm = "oel_tagging.view_taxonomy" + + assert self.superuser.has_perm(perm, taxonomy) + assert self.staff.has_perm(perm, taxonomy) assert not self.user_both_orgs.has_perm(perm, taxonomy) assert not self.user_org2.has_perm(perm, taxonomy) assert not self.learner.has_perm(perm, taxonomy) @@ -344,21 +387,17 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): """ assert self.superuser.has_perm(perm) assert self.staff.has_perm(perm) - assert not self.user_all_orgs.has_perm(perm) assert not self.user_both_orgs.has_perm(perm) assert not self.user_org2.has_perm(perm) assert not self.learner.has_perm(perm) - @ddt.data( - "oel_tagging.view_tag", - ) - def test_tag_base_view_permissions(self, perm): + def test_tag_base_view_permissions(self): """ Test that everyone can call view tag. """ + perm = "oel_tagging.view_tag" assert self.superuser.has_perm(perm) assert self.staff.has_perm(perm) - assert self.user_all_orgs.has_perm(perm) assert self.user_both_orgs.has_perm(perm) assert self.user_org2.has_perm(perm) assert self.learner.has_perm(perm) @@ -383,7 +422,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): tag = getattr(self, tag_attr) assert self.superuser.has_perm(perm, tag) assert self.staff.has_perm(perm, tag) - assert not self.user_all_orgs.has_perm(perm, tag) assert not self.user_both_orgs.has_perm(perm, tag) assert not self.user_org2.has_perm(perm, tag) assert not self.learner.has_perm(perm, tag) @@ -408,7 +446,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): assert self.superuser.has_perm(perm, tag_system_taxonomy) assert not self.staff.has_perm(perm, tag_system_taxonomy) - assert not self.user_all_orgs.has_perm(perm, tag_system_taxonomy) assert not self.user_both_orgs.has_perm(perm, tag_system_taxonomy) assert not self.user_org2.has_perm(perm, tag_system_taxonomy) assert not self.learner.has_perm(perm, tag_system_taxonomy) @@ -433,7 +470,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): assert self.superuser.has_perm(perm, tag_free_text_taxonomy) assert not self.staff.has_perm(perm, tag_free_text_taxonomy) - assert not self.user_all_orgs.has_perm(perm, tag_free_text_taxonomy) assert not self.user_both_orgs.has_perm(perm, tag_free_text_taxonomy) assert not self.user_org2.has_perm(perm, tag_free_text_taxonomy) assert not self.learner.has_perm(perm, tag_free_text_taxonomy) @@ -451,7 +487,6 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): assert self.staff.has_perm(perm, tag) # Everyone else can't do anything - assert not self.user_all_orgs.has_perm(perm, tag) assert not self.user_both_orgs.has_perm(perm, tag) assert not self.user_org2.has_perm(perm, tag) assert not self.learner.has_perm(perm, tag) @@ -473,28 +508,29 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): # ObjectTag @ddt.data( - ("oel_tagging.add_object_tag", "disabled_course_tag_perm"), - ("oel_tagging.change_object_tag", "disabled_course_tag_perm"), - ("oel_tagging.delete_object_tag", "disabled_course_tag_perm"), + ("oel_tagging.add_objecttag", "disabled_course2_tag_perm"), + ("oel_tagging.change_objecttag", "disabled_course2_tag_perm"), + ("oel_tagging.delete_objecttag", "disabled_course2_tag_perm"), ) @ddt.unpack def test_object_tag_disabled_taxonomy(self, perm, tag_attr): - """Only taxonomy administrators can create/edit an ObjectTag using a disabled Taxonomy""" + """ + Only superuser create/edit an ObjectTag using a disabled Taxonomy + """ object_tag_perm = getattr(self, tag_attr) assert self.superuser.has_perm(perm, object_tag_perm) - assert self.staff.has_perm(perm, object_tag_perm) - assert not self.user_all_orgs.has_perm(perm, object_tag_perm) + assert not self.staff.has_perm(perm, object_tag_perm) assert not self.user_both_orgs.has_perm(perm, object_tag_perm) assert not self.user_org2.has_perm(perm, object_tag_perm) assert not self.learner.has_perm(perm, object_tag_perm) @ddt.data( - ("oel_tagging.add_object_tag", "tax_no_org_course1"), - ("oel_tagging.add_object_tag", "tax_no_org_xblock1"), - ("oel_tagging.change_object_tag", "tax_no_org_course1"), - ("oel_tagging.change_object_tag", "tax_no_org_xblock1"), - ("oel_tagging.delete_object_tag", "tax_no_org_xblock1"), - ("oel_tagging.delete_object_tag", "tax_no_org_course1"), + ("oel_tagging.add_objecttag", "tax_no_org_course1"), + ("oel_tagging.add_objecttag", "tax_no_org_xblock1"), + ("oel_tagging.change_objecttag", "tax_no_org_course1"), + ("oel_tagging.change_objecttag", "tax_no_org_xblock1"), + ("oel_tagging.delete_objecttag", "tax_no_org_xblock1"), + ("oel_tagging.delete_objecttag", "tax_no_org_course1"), ) @ddt.unpack def test_object_tag_no_orgs(self, perm, tag_attr): @@ -502,15 +538,14 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): object_tag = getattr(self, tag_attr) assert self.superuser.has_perm(perm, object_tag) assert self.staff.has_perm(perm, object_tag) - assert not self.user_all_orgs.has_perm(perm, object_tag) assert not self.user_both_orgs.has_perm(perm, object_tag) assert not self.user_org2.has_perm(perm, object_tag) assert not self.learner.has_perm(perm, object_tag) @ddt.data( - "oel_tagging.add_object_tag", - "oel_tagging.change_object_tag", - "oel_tagging.delete_object_tag", + "oel_tagging.add_objecttag", + "oel_tagging.change_objecttag", + "oel_tagging.delete_objecttag", ) def test_change_object_tag_all_orgs(self, perm): """ @@ -520,18 +555,17 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): for perm_item in self.all_org_perms: assert self.superuser.has_perm(perm, perm_item) assert self.staff.has_perm(perm, perm_item) - assert self.user_all_orgs.has_perm(perm, perm_item) assert self.user_both_orgs.has_perm(perm, perm_item) assert self.user_org2.has_perm(perm, perm_item) == (self.org2.short_name in perm_item.object_id) assert not self.learner.has_perm(perm, perm_item) @ddt.data( - ("oel_tagging.add_object_tag", "tax1_course1"), - ("oel_tagging.add_object_tag", "tax1_xblock1"), - ("oel_tagging.change_object_tag", "tax1_course1"), - ("oel_tagging.change_object_tag", "tax1_xblock1"), - ("oel_tagging.delete_object_tag", "tax1_course1"), - ("oel_tagging.delete_object_tag", "tax1_xblock1"), + ("oel_tagging.add_objecttag", "tax1_course1"), + ("oel_tagging.add_objecttag", "tax1_xblock1"), + ("oel_tagging.change_objecttag", "tax1_course1"), + ("oel_tagging.change_objecttag", "tax1_xblock1"), + ("oel_tagging.delete_objecttag", "tax1_course1"), + ("oel_tagging.delete_objecttag", "tax1_xblock1"), ) @ddt.unpack def test_change_object_tag_org1(self, perm, tag_attr): @@ -539,101 +573,37 @@ class TestRulesTaxonomy(TestTaxonomyMixin, TestCase): perm_item = getattr(self, tag_attr) assert self.superuser.has_perm(perm, perm_item) assert self.staff.has_perm(perm, perm_item) - assert self.user_all_orgs.has_perm(perm, perm_item) assert self.user_both_orgs.has_perm(perm, perm_item) assert not self.user_org2.has_perm(perm, perm_item) assert not self.learner.has_perm(perm, perm_item) @ddt.data( - - ("oel_tagging.add_object_tag", "one_org_invalid_org_tag_perm"), - ("oel_tagging.add_object_tag", "all_orgs_invalid_tag_perm"), - ("oel_tagging.change_object_tag", "one_org_invalid_org_tag_perm"), - ("oel_tagging.change_object_tag", "all_orgs_invalid_tag_perm"), - ("oel_tagging.delete_object_tag", "one_org_invalid_org_tag_perm"), - ("oel_tagging.delete_object_tag", "all_orgs_invalid_tag_perm"), - ) - @ddt.unpack - def test_change_object_tag_invalid_key(self, perm, tag_attr): - perm_item = getattr(self, tag_attr) - with self.assertRaises(InvalidKeyError): - assert self.staff.has_perm(perm, perm_item) - - @ddt.data( - "all_orgs_course_tag", - "all_orgs_block_tag", - "both_orgs_course_tag", - "both_orgs_block_tag", - "one_org_block_tag", - "all_orgs_invalid_tag", - "one_org_invalid_org_tag", - "no_orgs_invalid_tag", - "disabled_course_tag", + "tax_all_course1", + "tax_all_course2", + "tax_all_xblock1", + "tax_all_xblock2", + "tax_both_course1", + "tax_both_course2", + "tax_both_xblock1", + "tax_both_xblock2", ) def test_view_object_tag(self, tag_attr): """Anyone can view any ObjectTag""" - object_tag = getattr(self, tag_attr) - self._expected_users_have_perm( - "oel_tagging.view_object_tag", - object_tag, - learner_perm=True, - learner_obj=True, - ) + perm = "oel_tagging.view_objecttag" + perm_item = getattr(self, tag_attr) + assert self.superuser.has_perm(perm, perm_item) + assert self.staff.has_perm(perm, perm_item) + assert self.user_both_orgs.has_perm(perm, perm_item) + assert self.user_org2.has_perm(perm, perm_item) == tag_attr.endswith("2") + assert not self.learner.has_perm(perm, perm_item) - -@ddt.ddt -@override_settings(FEATURES={"ENABLE_CREATOR_GROUP": False}) -class TestRulesTaxonomyNoCreatorGroup( - TestRulesTaxonomy -): # pylint: disable=test-inherits-tests - """ - Run the above tests with ENABLE_CREATOR_GROUP unset, to demonstrate that all users have course creator access for - all orgs, and therefore everyone is a Taxonomy Administrator. - - However, if there are no Organizations in the database, then nobody has access to the Tagging models. - """ - - def _expected_users_have_perm( - self, perm, obj, learner_perm=False, learner_obj=False, user_org2=True - ): + def test_view_object_tag_diabled(self): """ - When ENABLE_CREATOR_GROUP is disabled, all users have all permissions. + Noboty can view a ObjectTag from a disable taxonomy """ - super()._expected_users_have_perm( - perm=perm, - obj=obj, - learner_perm=learner_perm, - learner_obj=learner_obj, - user_org2=user_org2, - ) - - # Taxonomy - - @ddt.data( - ("oel_tagging.change_taxonomy", "taxonomy_all_orgs"), - ("oel_tagging.change_taxonomy", "taxonomy_both_orgs"), - ("oel_tagging.change_taxonomy", "taxonomy_disabled"), - ("oel_tagging.change_taxonomy", "taxonomy_one_org"), - ("oel_tagging.change_taxonomy", "taxonomy_no_orgs"), - ("oel_tagging.delete_taxonomy", "taxonomy_all_orgs"), - ("oel_tagging.delete_taxonomy", "taxonomy_both_orgs"), - ("oel_tagging.delete_taxonomy", "taxonomy_disabled"), - ("oel_tagging.delete_taxonomy", "taxonomy_one_org"), - ("oel_tagging.delete_taxonomy", "taxonomy_no_orgs"), - ) - @ddt.unpack - def test_no_orgs_no_perms(self, perm, taxonomy_attr): - """ - Org-level permissions are revoked when there are no orgs. - """ - Organization.objects.all().delete() - taxonomy = getattr(self, taxonomy_attr) - # Superusers & Staff always have access - assert self.superuser.has_perm(perm, taxonomy) - assert self.staff.has_perm(perm, taxonomy) - - # But everyone else's object-level access is removed - assert not self.user_all_orgs.has_perm(perm, taxonomy) - assert not self.user_both_orgs.has_perm(perm, taxonomy) - assert not self.user_org2.has_perm(perm, taxonomy) - assert not self.learner.has_perm(perm, taxonomy) + perm = "oel_tagging.view_objecttag" + assert self.superuser.has_perm(perm, self.disabled_course_tag) + assert not self.staff.has_perm(perm, self.disabled_course_tag) + assert not self.user_both_orgs.has_perm(perm, self.disabled_course_tag) + assert not self.user_org2.has_perm(perm, self.disabled_course_tag) + assert not self.learner.has_perm(perm, self.disabled_course_tag) diff --git a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py index c1c45e9449..fccac3e307 100644 --- a/openedx/core/djangoapps/content_tagging/tests/test_tasks.py +++ b/openedx/core/djangoapps/content_tagging/tests/test_tasks.py @@ -5,10 +5,9 @@ from __future__ import annotations from unittest.mock import patch -from django.core.management import call_command from django.test import override_settings from edx_toggles.toggles.testutils import override_waffle_flag -from openedx_tagging.core.tagging.models import ObjectTag, Tag, Taxonomy +from openedx_tagging.core.tagging.models import Tag, Taxonomy from organizations.models import Organization from common.djangoapps.student.tests.factories import UserFactory @@ -16,28 +15,59 @@ from openedx.core.djangolib.testing.utils import skip_unless_cms from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase from .. import api -from ..models import ContentLanguageTaxonomy, TaxonomyOrg +from ..models.base import TaxonomyOrg from ..toggles import CONTENT_TAGGING_AUTO +from ..types import ContentKey LANGUAGE_TAXONOMY_ID = -1 +class LanguageTaxonomyTestMixin: + """ + Mixin for test cases that expect the Language System Taxonomy to exist. + """ + + def setUp(self): + """ + When pytest runs, it creates the database by inspecting models, not by + running migrations. So data created by our migrations is not present. + In particular, the Language Taxonomy is not present. So this mixin will + create the taxonomy, simulating the effect of the following migrations: + 1. openedx_tagging.core.tagging.migrations.0012_language_taxonomy + 2. content_tagging.migrations.0007_system_defined_org_2 + """ + super().setUp() + Taxonomy.objects.get_or_create(id=-1, defaults={ + "name": "Languages", + "description": "Languages that are enabled on this system.", + "enabled": True, + "allow_multiple": False, + "allow_free_text": False, + "visible_to_authors": True, + "_taxonomy_class": "openedx_tagging.core.tagging.models.system_defined.LanguageTaxonomy", + }) + TaxonomyOrg.objects.get_or_create(taxonomy_id=-1, defaults={"org": None}) + + @skip_unless_cms # Auto-tagging is only available in the CMS @override_waffle_flag(CONTENT_TAGGING_AUTO, active=True) -class TestAutoTagging(ModuleStoreTestCase): +class TestAutoTagging(LanguageTaxonomyTestMixin, ModuleStoreTestCase): # type: ignore[misc] """ Test if the Course and XBlock tags are automatically created """ MODULESTORE = TEST_DATA_SPLIT_MODULESTORE - def _check_tag(self, object_id: str, taxonomy_id: int, value: str | None): + def _check_tag(self, object_key: ContentKey, taxonomy_id: int, value: str | None): """ Check if the ObjectTag exists for the given object_id and taxonomy_id If value is None, check if the ObjectTag does not exists """ - object_tag = ObjectTag.objects.filter(object_id=object_id, taxonomy_id=taxonomy_id).first() + object_tags = list(api.get_content_tags(object_key, taxonomy_id=taxonomy_id)) + object_tag = object_tags[0] if len(object_tags) == 1 else None + if len(object_tags) > 1: + raise ValueError("Found too many object tags") if value is None: assert not object_tag, f"Expected no tag for taxonomy_id={taxonomy_id}, " \ f"but one found with value={object_tag.value}" @@ -47,21 +77,6 @@ class TestAutoTagging(ModuleStoreTestCase): return True - @classmethod - def setUpClass(cls): - # Run fixtures to create the system defined tags - call_command("loaddata", "--app=oel_tagging", "language_taxonomy.yaml") - - # Configure language taxonomy - language_taxonomy = Taxonomy.objects.get(id=-1) - language_taxonomy.taxonomy_class = ContentLanguageTaxonomy - language_taxonomy.save() - - # Enable Language taxonomy for all orgs - TaxonomyOrg.objects.create(id=-1, taxonomy=language_taxonomy, org=None) - - super().setUpClass() - def setUp(self): super().setUp() # Create user @@ -80,13 +95,13 @@ class TestAutoTagging(ModuleStoreTestCase): "test_course", "test_run", self.user_id, - fields={"language": "pt"}, + fields={"language": "pl"}, ) # Check if the tags are created in the Course - assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Polski") - @override_settings(LANGUAGE_CODE='pt') + @override_settings(LANGUAGE_CODE='pt-br') def test_create_course_invalid_language(self): # Create course course = self.store.create_course( @@ -98,9 +113,9 @@ class TestAutoTagging(ModuleStoreTestCase): ) # Check if the tags are created in the Course is the system default - assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Português (Brasil)") - @override_settings(LANGUAGES=[('pt', 'Portuguese')], LANGUAGE_CODE='pt') + @override_settings(LANGUAGES=[('pt', 'Portuguese')], LANGUAGE_DICT={'pt': 'Portuguese'}, LANGUAGE_CODE='pt') def test_create_course_unsuported_language(self): # Create course course = self.store.create_course( @@ -114,22 +129,6 @@ class TestAutoTagging(ModuleStoreTestCase): # Check if the tags are created in the Course is the system default assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") - @override_settings(LANGUAGE_CODE='pt') - def test_create_course_no_tag_language(self): - # Remove English tag - Tag.objects.filter(taxonomy_id=LANGUAGE_TAXONOMY_ID, value="English").delete() - # Create course - course = self.store.create_course( - self.orgA.short_name, - "test_course", - "test_run", - self.user_id, - fields={"language": "en"}, - ) - - # Check if the tags are created in the Course is the system default - assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Portuguese") - @override_settings(LANGUAGE_CODE='pt') def test_create_course_no_tag_default_language(self): # Remove Portuguese tag @@ -153,19 +152,19 @@ class TestAutoTagging(ModuleStoreTestCase): "test_course", "test_run", self.user_id, - fields={"language": "pt"}, + fields={"language": "pt-br"}, ) # Simulates user manually changing a tag lang_taxonomy = Taxonomy.objects.get(pk=LANGUAGE_TAXONOMY_ID) - api.tag_content_object(lang_taxonomy, ["Spanish"], course.id) + api.tag_content_object(course.id, lang_taxonomy, ["Español (España)"]) # Update course language course.language = "en" self.store.update_item(course, self.user_id) # Does not automatically update the tag - assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Spanish") + assert self._check_tag(course.id, LANGUAGE_TAXONOMY_ID, "Español (España)") def test_create_delete_xblock(self): # Create course @@ -174,7 +173,7 @@ class TestAutoTagging(ModuleStoreTestCase): "test_course", "test_run", self.user_id, - fields={"language": "pt"}, + fields={"language": "pt-br"}, ) # Create XBlocks @@ -184,7 +183,7 @@ class TestAutoTagging(ModuleStoreTestCase): usage_key_str = str(vertical.location) # Check if the tags are created in the XBlock - assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Portuguese") + assert self._check_tag(usage_key_str, LANGUAGE_TAXONOMY_ID, "Português (Brasil)") # Delete the XBlock self.store.delete_item(vertical.location, self.user_id) diff --git a/openedx/core/djangoapps/content_tagging/types.py b/openedx/core/djangoapps/content_tagging/types.py new file mode 100644 index 0000000000..3a9f6ec549 --- /dev/null +++ b/openedx/core/djangoapps/content_tagging/types.py @@ -0,0 +1,8 @@ +""" +Types used by content tagging API and implementation +""" +from typing import Union + +from opaque_keys.edx.keys import LearningContextKey, UsageKey + +ContentKey = Union[LearningContextKey, UsageKey] diff --git a/openedx/core/djangoapps/contentserver/test/test_contentserver.py b/openedx/core/djangoapps/contentserver/test/test_contentserver.py index 700eff6427..e9ee1f7ee5 100644 --- a/openedx/core/djangoapps/contentserver/test/test_contentserver.py +++ b/openedx/core/djangoapps/contentserver/test/test_contentserver.py @@ -156,7 +156,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): CourseEnrollment.enroll(self.non_staff_usr, self.course_key) assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key) - self.client.login(username=self.non_staff_usr, password='test') + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) resp = self.client.get(self.url_locked_versioned) assert resp.status_code == 200 @@ -167,7 +167,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): CourseEnrollment.enroll(self.non_staff_usr, self.course_key) assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key) - self.client.login(username=self.non_staff_usr, password='test') + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) resp = self.client.get(self.url_locked_versioned_old_style, follow=True) assert resp.status_code == 200 @@ -185,7 +185,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): Test that locked assets behave appropriately in case user is logged in in but not registered for the course. """ - self.client.login(username=self.non_staff_usr, password='test') + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) resp = self.client.get(self.url_locked) assert resp.status_code == 403 @@ -197,7 +197,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): CourseEnrollment.enroll(self.non_staff_usr, self.course_key) assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key) - self.client.login(username=self.non_staff_usr, password='test') + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) resp = self.client.get(self.url_locked) assert resp.status_code == 200 @@ -205,7 +205,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): """ Test that locked assets behave appropriately in case user is staff. """ - self.client.login(username=self.staff_usr, password='test') + self.client.login(username=self.staff_usr, password=self.TEST_PASSWORD) resp = self.client.get(self.url_locked) assert resp.status_code == 200 @@ -320,7 +320,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): CourseEnrollment.enroll(self.non_staff_usr, self.course_key) assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key) - self.client.login(username=self.non_staff_usr, password='test') + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) resp = self.client.get(self.url_locked) assert resp.status_code == 200 assert 'Expires' not in resp @@ -350,7 +350,7 @@ class ContentStoreToyCourseTest(SharedModuleStoreTestCase): CourseEnrollment.enroll(self.non_staff_usr, self.course_key) assert CourseEnrollment.is_enrolled(self.non_staff_usr, self.course_key) - self.client.login(username=self.non_staff_usr, password='test') + self.client.login(username=self.non_staff_usr, password=self.TEST_PASSWORD) resp = self.client.get(self.url_locked) assert resp.status_code == 200 assert 'Expires' not in resp diff --git a/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py b/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py index 2c1e615229..768643a7a2 100644 --- a/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py +++ b/openedx/core/djangoapps/course_apps/rest_api/tests/test_views.py @@ -32,7 +32,7 @@ class CourseAppsRestApiTest(SharedModuleStoreTestCase): self.instructor = UserFactory() self.user = UserFactory() self.client = Client() - self.client.login(username=self.instructor.username, password="test") + self.client.login(username=self.instructor.username, password=self.TEST_PASSWORD) self.url = reverse("course_apps_api:v1:course_apps", kwargs=dict(course_id=self.course.id)) CourseStaffRole(self.course.id).add_users(self.instructor) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py new file mode 100644 index 0000000000..545948a092 --- /dev/null +++ b/openedx/core/djangoapps/django_comment_common/comment_client/subscriptions.py @@ -0,0 +1,50 @@ +""" +Subscription model is used to get users who are subscribed to the main thread/post i.e. +""" +import logging + +from . import models, settings, utils + +log = logging.getLogger(__name__) + + +class Subscription(models.Model): + """ + Subscription model is used to get users who are subscribed to the main thread/post i.e. + """ + # accessible_fields can be set and retrieved on the model + accessible_fields = [ + '_id', 'subscriber_id', "source_id", "source_type" + ] + + type = 'subscriber' + base_url = f"{settings.PREFIX}/threads" + + @classmethod + def fetch(cls, thread_id, query_params): + """ + Fetches the subscriptions for a given thread_id + """ + params = { + 'page': query_params.get('page', 1), + 'per_page': query_params.get('per_page', 20), + 'id': thread_id + } + params.update( + utils.strip_blank(utils.strip_none(query_params)) + ) + response = utils.perform_request( + 'get', + cls.url(action='get', params=params) + "/subscriptions", + params, + metric_tags=[], + metric_action='subscription.get', + paged_results=True + ) + return utils.SubscriptionsPaginatedResult( + collection=response.get('collection', []), + page=response.get('page', 1), + num_pages=response.get('num_pages', 1), + subscriptions_count=response.get('subscriptions_count', 0), + corrected_text=response.get('corrected_text', None) + ) diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py index 74e3e0eba2..6f92313b6e 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/thread.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/thread.py @@ -11,7 +11,6 @@ log = logging.getLogger(__name__) class Thread(models.Model): - # accessible_fields can be set and retrieved on the model accessible_fields = [ 'id', 'title', 'body', 'anonymous', 'anonymous_to_peers', 'course_id', diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py index 2432ab36be..a67cdbdbc4 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/utils.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/utils.py @@ -131,6 +131,17 @@ class CommentClientPaginatedResult: self.corrected_text = corrected_text +class SubscriptionsPaginatedResult: + """ class for paginated results returned from comment services""" + + def __init__(self, collection, page, num_pages, subscriptions_count=0, corrected_text=None): + self.collection = collection + self.page = page + self.num_pages = num_pages + self.subscriptions_count = subscriptions_count + self.corrected_text = corrected_text + + def check_forum_heartbeat(): """ Check the forum connection via its built-in heartbeat service and create an answer which can be used in the LMS diff --git a/openedx/core/djangoapps/external_user_ids/apps.py b/openedx/core/djangoapps/external_user_ids/apps.py index abf154abc3..4d3c6ca09a 100644 --- a/openedx/core/djangoapps/external_user_ids/apps.py +++ b/openedx/core/djangoapps/external_user_ids/apps.py @@ -11,6 +11,3 @@ class ExternalUserIDConfig(AppConfig): Default configuration for the "openedx.core.djangoapps.credit" Django application. """ name = 'openedx.core.djangoapps.external_user_ids' - - def ready(self): - from . import signals # pylint: disable=unused-import diff --git a/openedx/core/djangoapps/external_user_ids/models.py b/openedx/core/djangoapps/external_user_ids/models.py index 1c4b3943e2..09fe47c4f3 100644 --- a/openedx/core/djangoapps/external_user_ids/models.py +++ b/openedx/core/djangoapps/external_user_ids/models.py @@ -20,7 +20,7 @@ class ExternalIdType(TimeStampedModel): .. no_pii: """ - MICROBACHELORS_COACHING = 'mb_coaching' + CALIPER = 'caliper' XAPI = 'xapi' LTI = 'lti' diff --git a/openedx/core/djangoapps/external_user_ids/signals.py b/openedx/core/djangoapps/external_user_ids/signals.py deleted file mode 100644 index 166303d12b..0000000000 --- a/openedx/core/djangoapps/external_user_ids/signals.py +++ /dev/null @@ -1,64 +0,0 @@ -""" -Signal Handlers for External User Ids to be created and maintainer -""" - -from logging import getLogger - -from django.db.models.signals import post_save -from django.dispatch import receiver - -from openedx.core.djangoapps.catalog.utils import get_programs - -from .models import ExternalId, ExternalIdType - -LOGGER = getLogger(__name__) - - -def _user_needs_external_id(instance, created): - return ( - created and - instance.user and - not ExternalId.user_has_external_id( - user=instance.user, - type_name=ExternalIdType.MICROBACHELORS_COACHING) - ) - - -@receiver(post_save, sender='student.CourseEnrollment') -def create_external_id_for_microbachelors_program( - sender, instance, created, **kwargs # pylint: disable=unused-argument -): - """ - Watches for post_save signal for creates on the CourseEnrollment table. - Generate an External ID if the Enrollment is in a MicroBachelors Program - """ - if _user_needs_external_id(instance, created): - mb_programs = [ - program for program in get_programs(course=instance.course_id) - if program.get('type_attrs', {}).get('coaching_supported') - ] - if mb_programs: - ExternalId.add_new_user_id( - user=instance.user, - type_name=ExternalIdType.MICROBACHELORS_COACHING - ) - - -@receiver(post_save, sender='entitlements.CourseEntitlement') -def create_external_id_for_microbachelors_program_entitlement( - sender, instance, created, **kwargs # pylint: disable=unused-argument -): - """ - Watches for post_save signal for creates on the CourseEntitlement table. - Generate an External ID if the Entitlement is in a MicroBachelors Program - """ - if _user_needs_external_id(instance, created): - mb_programs = [ - program for program in get_programs(catalog_course_uuid=instance.course_uuid) - if program.get('type_attrs', {}).get('coaching_supported') - ] - if mb_programs: - ExternalId.add_new_user_id( - user=instance.user, - type_name=ExternalIdType.MICROBACHELORS_COACHING - ) diff --git a/openedx/core/djangoapps/external_user_ids/tests/factories.py b/openedx/core/djangoapps/external_user_ids/tests/factories.py index 86aa747bfe..aaafe27959 100644 --- a/openedx/core/djangoapps/external_user_ids/tests/factories.py +++ b/openedx/core/djangoapps/external_user_ids/tests/factories.py @@ -14,7 +14,7 @@ class ExternalIDTypeFactory(factory.django.DjangoModelFactory): # lint-amnesty, class Meta: model = ExternalIdType - name = FuzzyChoice([ExternalIdType.MICROBACHELORS_COACHING]) + name = FuzzyChoice([ExternalIdType.CALIPER]) description = FuzzyText() diff --git a/openedx/core/djangoapps/external_user_ids/tests/test_signals.py b/openedx/core/djangoapps/external_user_ids/tests/test_signals.py deleted file mode 100644 index 290c3a27ac..0000000000 --- a/openedx/core/djangoapps/external_user_ids/tests/test_signals.py +++ /dev/null @@ -1,192 +0,0 @@ -""" -Signal Tests for External User Ids that are sent out of Open edX -""" - -from opaque_keys.edx.keys import CourseKey -from django.core.cache import cache -from edx_django_utils.cache import RequestCache - -from common.djangoapps.entitlements.models import CourseEntitlement -from openedx.core.djangoapps.catalog.tests.factories import ( - CourseFactory, - ProgramFactory, -) -from common.djangoapps.student.tests.factories import TEST_PASSWORD, UserFactory, CourseEnrollmentFactory -from openedx.core.djangoapps.catalog.cache import ( - CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL, - COURSE_PROGRAMS_CACHE_KEY_TPL, - PROGRAM_CACHE_KEY_TPL, -) -from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType -from common.djangoapps.student.models import CourseEnrollment -from common.djangoapps.course_modes.models import CourseMode - -from openedx.core.djangolib.testing.utils import CacheIsolationTestCase -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order - - -class MicrobachelorsExternalIDTest(ModuleStoreTestCase, CacheIsolationTestCase): - """ - Test cases for Signals for External User Ids - """ - ENABLED_CACHES = ['default'] - - @classmethod - def setUpClass(cls): - super().setUpClass() - - cls.course_list = [] - cls.user = UserFactory.create() - cls.course_keys = [ - CourseKey.from_string('course-v1:edX+DemoX+Test_Course'), - CourseKey.from_string('course-v1:edX+DemoX+Another_Test_Course'), - ] - ExternalIdType.objects.create( - name=ExternalIdType.MICROBACHELORS_COACHING, - description='test' - ) - - def setUp(self): - super().setUp() - RequestCache.clear_all_namespaces() - self.program = self._create_cached_program() - self.client.login(username=self.user.username, password=TEST_PASSWORD) - - def _create_cached_program(self): - """ helper method to create a cached program """ - program = ProgramFactory.create() - - for course_key in self.course_keys: - program['courses'].append(CourseFactory(id=course_key)) - - program['type'] = 'MicroBachelors' - program['type_attrs']['coaching_supported'] = True - - for course in program['courses']: - cache.set( - CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_uuid=course['uuid']), - [program['uuid']], - None - ) - - course_run = course['course_runs'][0]['key'] - cache.set( - COURSE_PROGRAMS_CACHE_KEY_TPL.format(course_run_id=course_run), - [program['uuid']], - None - ) - cache.set( - PROGRAM_CACHE_KEY_TPL.format(uuid=program['uuid']), - program, - None - ) - - return program - - def test_enroll_mb_create_external_id(self): - course_run_key = self.program['courses'][0]['course_runs'][0]['key'] - - # Enroll user - enrollment = CourseEnrollmentFactory.create( - course_id=course_run_key, - user=self.user, - mode=CourseMode.VERIFIED, - ) - enrollment.save() - external_id = ExternalId.objects.get( - user=self.user - ) - assert external_id is not None - assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING - - def test_second_enroll_mb_no_new_external_id(self): - course_run_key1 = self.program['courses'][0]['course_runs'][0]['key'] - course_run_key2 = self.program['courses'][1]['course_runs'][0]['key'] - - # Enroll user - CourseEnrollmentFactory.create( - course_id=course_run_key1, - user=self.user, - mode=CourseMode.VERIFIED, - ) - external_id = ExternalId.objects.get( - user=self.user - ) - assert external_id is not None - assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING - original_external_user_uuid = external_id.external_user_id - - CourseEnrollmentFactory.create( - course_id=course_run_key2, - user=self.user, - mode=CourseMode.VERIFIED, - ) - enrollments = CourseEnrollment.objects.filter(user=self.user) - - assert len(enrollments) == 2 - - external_ids = ExternalId.objects.filter( - user=self.user - ) - - assert len(external_ids) == 1 - assert external_ids[0].external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING - assert original_external_user_uuid == external_ids[0].external_user_id - - def test_entitlement_mb_create_external_id(self): - catalog_course = self.program['courses'][0] - - assert ExternalId.objects.filter( - user=self.user - ).count() == 0 - - entitlement = CourseEntitlement.objects.create( - course_uuid=catalog_course['uuid'], - mode=CourseMode.VERIFIED, - user=self.user, - order_number='TEST-12345' - ) - entitlement.save() - - external_id = ExternalId.objects.get( - user=self.user - ) - assert external_id is not None - assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING - - def test_second_entitlement_mb_no_new_external_id(self): - catalog_course1 = self.program['courses'][0] - catalog_course2 = self.program['courses'][1] - - # Enroll user - entitlement = CourseEntitlement.objects.create( - course_uuid=catalog_course1['uuid'], - mode=CourseMode.VERIFIED, - user=self.user, - order_number='TEST-12345' - ) - entitlement.save() - external_id = ExternalId.objects.get( - user=self.user - ) - assert external_id is not None - assert external_id.external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING - original_external_user_uuid = external_id.external_user_id - - CourseEntitlement.objects.create( - course_uuid=catalog_course2['uuid'], - mode=CourseMode.VERIFIED, - user=self.user, - order_number='TEST-12345' - ) - entitlements = CourseEntitlement.objects.filter(user=self.user) - - assert len(entitlements) == 2 - - external_ids = ExternalId.objects.filter( - user=self.user - ) - - assert len(external_ids) == 1 - assert external_ids[0].external_id_type.name == ExternalIdType.MICROBACHELORS_COACHING - assert original_external_user_uuid == external_ids[0].external_user_id diff --git a/openedx/core/djangoapps/notifications/base_notification.py b/openedx/core/djangoapps/notifications/base_notification.py index ec3018c007..5541b542f8 100644 --- a/openedx/core/djangoapps/notifications/base_notification.py +++ b/openedx/core/djangoapps/notifications/base_notification.py @@ -3,10 +3,9 @@ Base setup for Notification Apps and Types. """ from django.utils.translation import gettext_lazy as _ -from .utils import ( - find_app_in_normalized_apps, - find_pref_in_normalized_prefs, -) +from .utils import find_app_in_normalized_apps, find_pref_in_normalized_prefs + +FILTER_AUDIT_EXPIRED = 'filter_audit_expired' COURSE_NOTIFICATION_TYPES = { 'new_comment_on_response': { @@ -20,6 +19,7 @@ COURSE_NOTIFICATION_TYPES = { 'replier_name': 'replier name', }, 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED] }, 'new_comment': { 'notification_app': 'discussion', @@ -33,6 +33,7 @@ COURSE_NOTIFICATION_TYPES = { 'replier_name': 'replier name', }, 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED] }, 'new_response': { 'notification_app': 'discussion', @@ -45,6 +46,7 @@ COURSE_NOTIFICATION_TYPES = { 'replier_name': 'replier name', }, 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED] }, 'new_discussion_post': { 'notification_app': 'discussion', @@ -61,6 +63,7 @@ COURSE_NOTIFICATION_TYPES = { 'username': 'Post author name', }, 'email_template': '', + 'filters': [FILTER_AUDIT_EXPIRED] }, 'new_question_post': { 'notification_app': 'discussion', @@ -77,7 +80,39 @@ COURSE_NOTIFICATION_TYPES = { 'username': 'Post author name', }, 'email_template': '', - } + 'filters': [FILTER_AUDIT_EXPIRED] + }, + 'response_on_followed_post': { + 'notification_app': 'discussion', + 'name': 'response_on_followed_post', + 'is_core': True, + 'info': '', + 'non_editable': [], + 'content_template': _('<{p}><{strong}>{replier_name}</{strong}> responded to a post you’re following: ' + '<{strong}>{post_title}</{strong}></{p}>'), + 'content_context': { + 'post_title': 'Post title', + 'replier_name': 'replier name', + }, + 'email_template': '', + 'filter': [FILTER_AUDIT_EXPIRED] + }, + 'comment_on_followed_post': { + 'notification_app': 'discussion', + 'name': 'comment_on_followed_post', + 'is_core': True, + 'info': '', + 'non_editable': [], + 'content_template': _('<{p}><{strong}>{replier_name}</{strong}> commented on {author_name}\'s response in ' + 'a post you’re following <{strong}>{post_title}</{strong}></{p}>'), + 'content_context': { + 'post_title': 'Post title', + 'author_name': 'author name', + 'replier_name': 'replier name', + }, + 'email_template': '', + 'filter': [FILTER_AUDIT_EXPIRED] + }, } COURSE_NOTIFICATION_APPS = { @@ -322,3 +357,16 @@ def get_notification_content(notification_type, context): if notification_type_content_template: return notification_type_content_template.format(**context, **html_tags_context) return '' + + +def get_default_values_of_preference(notification_app, notification_type): + """ + Returns default preference for notification_type + """ + default_prefs = NotificationAppManager().get_notification_app_preferences() + app_prefs = default_prefs.get(notification_app, {}) + core_notification_types = app_prefs.get('core_notification_types', []) + notification_types = app_prefs.get('notification_types', {}) + if notification_type in core_notification_types: + return notification_types.get('core', {}) + return notification_types.get(notification_type, {}) diff --git a/openedx/core/djangoapps/notifications/config/waffle.py b/openedx/core/djangoapps/notifications/config/waffle.py index fdfd74571c..e05487b11b 100644 --- a/openedx/core/djangoapps/notifications/config/waffle.py +++ b/openedx/core/djangoapps/notifications/config/waffle.py @@ -27,3 +27,13 @@ ENABLE_NOTIFICATIONS = CourseWaffleFlag(f'{WAFFLE_NAMESPACE}.enable_notification # .. toggle_target_removal_date: 2023-12-07 # .. toggle_tickets: INF-902 SHOW_NOTIFICATIONS_TRAY = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.show_notifications_tray", __name__) + +# .. toggle_name: notifications.enable_notifications_filters +# .. toggle_implementation: CourseWaffleFlag +# .. toggle_default: False +# .. toggle_description: Waffle flag to enable filters in notifications task +# .. toggle_use_cases: temporary, open_edx +# .. toggle_creation_date: 2023-06-07 +# .. toggle_target_removal_date: 2024-06-01 +# .. toggle_tickets: INF-902 +ENABLE_NOTIFICATIONS_FILTERS = CourseWaffleFlag(f"{WAFFLE_NAMESPACE}.enable_notifications_filters", __name__) diff --git a/openedx/core/djangoapps/notifications/filters.py b/openedx/core/djangoapps/notifications/filters.py new file mode 100644 index 0000000000..9176faa6ee --- /dev/null +++ b/openedx/core/djangoapps/notifications/filters.py @@ -0,0 +1,67 @@ +""" +Notification filters +""" +import logging + +from django.utils import timezone + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment +from openedx.core.djangoapps.course_date_signals.utils import get_expected_duration +from openedx.core.djangoapps.notifications.base_notification import COURSE_NOTIFICATION_TYPES +from openedx.features.course_duration_limits.models import CourseDurationLimitConfig +from xmodule.modulestore.django import modulestore + +logger = logging.getLogger(__name__) + + +class NotificationFilter: + """ + Filter notifications based on their type + """ + + @staticmethod + def filter_audit_expired(user_ids, course) -> list: + """ + Check if the user has access to the course + """ + verified_mode = CourseMode.verified_mode_for_course(course=course, include_expired=True) + access_duration = get_expected_duration(course.id) + course_time_limit = CourseDurationLimitConfig.current(course_key=course.id) + if not verified_mode: + logger.info( + "Course %s does not have a verified mode, so no users will be filtered out", + course.id, + ) + return user_ids + enrollments = CourseEnrollment.objects.filter( + user_id__in=user_ids, + course_id=course.id, + mode=CourseMode.AUDIT, + ) + if course_time_limit.enabled_for_course(course.id): + enrollments = enrollments.filter(created__gte=course_time_limit.enabled_as_of) + + for enrollment in enrollments: + content_availability_date = max(enrollment.created, course.start) + expiration_date = content_availability_date + access_duration + if expiration_date and timezone.now() > expiration_date: + logger.info("User %s has expired audit access to course %s", enrollment.user_id, course.id) + user_ids.remove(enrollment.user_id) + return user_ids + + def apply_filters(self, user_ids, course_key, notification_type) -> list: + """ + Apply all the filters + """ + notification_config = COURSE_NOTIFICATION_TYPES.get(notification_type, {}) + applicable_filters = notification_config.get('filters', []) + course = modulestore().get_course(course_key) + for filter_name in applicable_filters: + logger.info( + "Applying filter %s for notification type %s", + filter_name, + notification_type, + ) + user_ids = getattr(self, filter_name)(user_ids, course) + return user_ids diff --git a/openedx/core/djangoapps/notifications/models.py b/openedx/core/djangoapps/notifications/models.py index d0f8daf0e5..55f6c11a7c 100644 --- a/openedx/core/djangoapps/notifications/models.py +++ b/openedx/core/djangoapps/notifications/models.py @@ -21,7 +21,7 @@ log = logging.getLogger(__name__) NOTIFICATION_CHANNELS = ['web', 'push', 'email'] # Update this version when there is a change to any course specific notification type or app. -COURSE_NOTIFICATION_CONFIG_VERSION = 3 +COURSE_NOTIFICATION_CONFIG_VERSION = 4 def get_course_notification_preference_config(): @@ -130,12 +130,12 @@ class CourseNotificationPreference(TimeStampedModel): return f'{self.user.username} - {self.course_id}' @staticmethod - def get_updated_user_course_preferences(user, course_id): + def get_user_course_preference(user_id, course_id): """ Returns updated courses preferences for a user """ preferences, _ = CourseNotificationPreference.objects.get_or_create( - user=user, + user_id=user_id, course_id=course_id, is_active=True, ) @@ -149,9 +149,13 @@ class CourseNotificationPreference(TimeStampedModel): preferences.save() # pylint: disable-next=broad-except except Exception as e: - log.error(f'Unable to update notification preference for {user.username} to new config. {e}') + log.error(f'Unable to update notification preference to new config. {e}') return preferences + @staticmethod + def get_updated_user_course_preferences(user, course_id): + return CourseNotificationPreference.get_user_course_preference(user.id, course_id) + def get_app_config(self, app_name) -> Dict: """ Returns the app config for the given app name. diff --git a/openedx/core/djangoapps/notifications/tasks.py b/openedx/core/djangoapps/notifications/tasks.py index e59ff77eb1..4d9a240095 100644 --- a/openedx/core/djangoapps/notifications/tasks.py +++ b/openedx/core/djangoapps/notifications/tasks.py @@ -13,13 +13,16 @@ from opaque_keys.edx.keys import CourseKey from pytz import UTC from common.djangoapps.student.models import CourseEnrollment -from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS +from openedx.core.djangoapps.notifications.base_notification import get_default_values_of_preference +from openedx.core.djangoapps.notifications.config.waffle import ENABLE_NOTIFICATIONS, ENABLE_NOTIFICATIONS_FILTERS from openedx.core.djangoapps.notifications.events import notification_generated_event +from openedx.core.djangoapps.notifications.filters import NotificationFilter from openedx.core.djangoapps.notifications.models import ( CourseNotificationPreference, Notification, get_course_notification_preference_config_version ) +from openedx.core.djangoapps.notifications.utils import get_list_in_batches logger = get_task_logger(__name__) @@ -90,25 +93,43 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c if not ENABLE_NOTIFICATIONS.is_enabled(course_key): return user_ids = list(set(user_ids)) + batch_size = settings.NOTIFICATION_CREATION_BATCH_SIZE - # check if what is preferences of user and make decision to send notification or not - preferences = CourseNotificationPreference.objects.filter( - user_id__in=user_ids, - course_id=course_key, - ) - preferences = create_notification_pref_if_not_exists(user_ids, list(preferences), course_key) - notifications = [] - audience = [] - for preference in preferences: - preference = update_user_preference(preference, preference.user, course_key) - if ( - preference and - preference.get_web_config(app_name, notification_type) and - preference.get_app_config(app_name).get('enabled', False) - ): + notifications_generated = False + notification_content = '' + default_web_config = get_default_values_of_preference(app_name, notification_type).get('web', True) + for batch_user_ids in get_list_in_batches(user_ids, batch_size): + # check if what is preferences of user and make decision to send notification or not + preferences = CourseNotificationPreference.objects.filter( + user_id__in=batch_user_ids, + course_id=course_key, + ) + preferences = list(preferences) + + if default_web_config: + preferences = create_notification_pref_if_not_exists(batch_user_ids, preferences, course_key) + + if not preferences: + return + + for preference in preferences: + preference = update_user_preference(preference, preference.user_id, course_key) + if not ( + preference and + preference.get_web_config(app_name, notification_type) and + preference.get_app_config(app_name).get('enabled', False) + ): + batch_user_ids.remove(preference.user_id) + if ENABLE_NOTIFICATIONS_FILTERS.is_enabled(course_key): + logger.info(f'Sending notifications to {len(batch_user_ids)} users.') + batch_user_ids = NotificationFilter().apply_filters(batch_user_ids, course_key, notification_type) + logger.info(f'After applying filters, sending notifications to {len(batch_user_ids)} users.') + + notifications = [] + for user_id in batch_user_ids: notifications.append( Notification( - user_id=preference.user_id, + user_id=user_id, app_name=app_name, notification_type=notification_type, content_context=context, @@ -116,23 +137,25 @@ def send_notifications(user_ids, course_key: str, app_name, notification_type, c course_id=course_key, ) ) - audience.append(preference.user_id) - # send notification to users but use bulk_create - notifications_generated = Notification.objects.bulk_create(notifications) - if notifications_generated: - notification_content = notifications_generated[0].content - notification_generated_event( - audience, app_name, notification_type, course_key, content_url, notification_content, - ) + # send notification to users but use bulk_create + notification_objects = Notification.objects.bulk_create(notifications) + if notification_objects and not notifications_generated: + notifications_generated = True + notification_content = notification_objects[0].content + + if notifications_generated: + notification_generated_event( + batch_user_ids, app_name, notification_type, course_key, content_url, notification_content, + ) -def update_user_preference(preference: CourseNotificationPreference, user, course_id): +def update_user_preference(preference: CourseNotificationPreference, user_id, course_id): """ Update user preference if config version is changed. """ current_version = get_course_notification_preference_config_version() if preference.config_version != current_version: - return preference.get_updated_user_course_preferences(user, course_id) + return preference.get_user_course_preference(user_id, course_id) return preference diff --git a/openedx/core/djangoapps/notifications/tests/test_filters.py b/openedx/core/djangoapps/notifications/tests/test_filters.py new file mode 100644 index 0000000000..d1aff28de7 --- /dev/null +++ b/openedx/core/djangoapps/notifications/tests/test_filters.py @@ -0,0 +1,96 @@ +""" +Test for the NotificationFilter class. +""" +from datetime import timedelta +from unittest import mock + +import ddt +from django.utils.timezone import now + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.notifications.filters import NotificationFilter +from openedx.features.course_duration_limits.models import CourseDurationLimitConfig +from openedx.features.course_experience.tests.views.helpers import add_course_mode +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + + +@ddt.ddt +class CourseExpirationTestCase(ModuleStoreTestCase): + """Tests to verify the get_user_course_expiration_date function is working correctly""" + + def setUp(self): + super().setUp() # lint-amnesty, pylint: disable=super-with-arguments + self.course = CourseFactory( + start=now() - timedelta(weeks=10), + ) + + self.user = UserFactory() + self.user_1 = UserFactory() + + # Make this a verified course, so we can test expiration date + add_course_mode(self.course, mode_slug=CourseMode.AUDIT) + add_course_mode(self.course) + CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + expired_audit = CourseEnrollment.enroll(self.user, self.course.id, CourseMode.AUDIT) + expired_audit.created = now() - timedelta(weeks=6) + expired_audit.save() + + @mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details") + def test_audit_expired_filter( + self, + mock_get_course_run_details, + ): + """ + Test if filter_audit_expired function is working correctly + """ + + mock_get_course_run_details.return_value = {'weeks_to_complete': 4} + result = NotificationFilter.filter_audit_expired( + [self.user.id, self.user_1.id], + self.course, + ) + self.assertEqual([self.user_1.id], result) + + mock_get_course_run_details.return_value = {'weeks_to_complete': 7} + result = NotificationFilter.filter_audit_expired( + [self.user.id, self.user_1.id], + self.course, + ) + self.assertEqual([self.user.id, self.user_1.id], result) + + CourseDurationLimitConfig.objects.create( + enabled=True, + course=CourseOverview.get_from_id(self.course.id), + enabled_as_of=now(), + ) + # weeks_to_complete is set to 4 because we want to test if CourseDurationLimitConfig is working correctly. + mock_get_course_run_details.return_value = {'weeks_to_complete': 4} + result = NotificationFilter.filter_audit_expired( + [self.user.id, self.user_1.id], + self.course, + ) + self.assertEqual([self.user.id, self.user_1.id], result) + + @mock.patch("openedx.core.djangoapps.course_date_signals.utils.get_course_run_details") + @mock.patch("openedx.core.djangoapps.notifications.filters.NotificationFilter.filter_audit_expired") + def test_apply_filter( + self, + mock_filter_audit_expired, + mock_get_course_run_details, + ): + """ + Test if apply_filter function is working correctly + """ + mock_get_course_run_details.return_value = {'weeks_to_complete': 4} + mock_filter_audit_expired.return_value = [self.user.id, self.user_1.id] + result = NotificationFilter().apply_filters( + [self.user.id, self.user_1.id], + self.course.id, + 'new_comment_on_response' + ) + self.assertEqual([self.user.id, self.user_1.id], result) + mock_filter_audit_expired.assert_called_once() diff --git a/openedx/core/djangoapps/notifications/tests/test_tasks.py b/openedx/core/djangoapps/notifications/tests/test_tasks.py index 51cdc741d6..339ce0e03f 100644 --- a/openedx/core/djangoapps/notifications/tests/test_tasks.py +++ b/openedx/core/djangoapps/notifications/tests/test_tasks.py @@ -5,8 +5,10 @@ Tests for notifications tasks. from unittest.mock import patch import ddt +from django.conf import settings from edx_toggles.toggles.testutils import override_waffle_flag +from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -159,3 +161,110 @@ class SendNotificationsTest(ModuleStoreTestCase): # Assert that `Notification` objects are not created for the users. notification = Notification.objects.filter(user_id=self.user.id).first() self.assertIsNone(notification) + + +@ddt.ddt +@patch('openedx.core.djangoapps.notifications.tasks.ENABLE_NOTIFICATIONS_FILTERS.is_enabled', lambda x: False) +class SendBatchNotificationsTest(ModuleStoreTestCase): + """ + Test that notification and notification preferences are created in batches + """ + def setUp(self): + """ + Setups test case + """ + super().setUp() + self.course = CourseFactory.create( + org='test_org', + number='test_course', + run='test_run' + ) + + def _create_users(self, num_of_users): + """ + Create users and enroll them in course + """ + users = [ + UserFactory.create(username=f'user{i}', email=f'user{i}@example.com') + for i in range(num_of_users) + ] + for user in users: + CourseEnrollment.enroll(user=user, course_key=self.course.id) + return users + + @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) + @ddt.data( + (settings.NOTIFICATION_CREATION_BATCH_SIZE, 1, 2), + (settings.NOTIFICATION_CREATION_BATCH_SIZE + 10, 2, 4), + (settings.NOTIFICATION_CREATION_BATCH_SIZE - 10, 1, 2), + ) + @ddt.unpack + def test_notification_is_send_in_batch(self, creation_size, prefs_query_count, notifications_query_count): + """ + Tests notifications and notification preferences are created in batches + """ + notification_app = "discussion" + notification_type = "new_discussion_post" + users = self._create_users(creation_size) + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "username": "Test Author" + } + + # Creating preferences and asserting query count + with self.assertNumQueries(prefs_query_count): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + # Updating preferences for notification creation + preferences = CourseNotificationPreference.objects.filter( + user_id__in=user_ids, + course_id=self.course.id + ) + for preference in preferences: + discussion_config = preference.notification_preference_config['discussion'] + discussion_config['notification_types'][notification_type]['web'] = True + preference.save() + + # Creating notifications and asserting query count + with self.assertNumQueries(notifications_query_count): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + def test_preference_not_created_for_default_off_preference(self): + """ + Tests if new preferences are NOT created when default preference for + notification type is False + """ + notification_app = "discussion" + notification_type = "new_discussion_post" + users = self._create_users(20) + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "username": "Test Author" + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): + with self.assertNumQueries(1): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") + + def test_preference_created_for_default_on_preference(self): + """ + Tests if new preferences are created when default preference for + notification type is True + """ + notification_app = "discussion" + notification_type = "new_comment" + users = self._create_users(20) + user_ids = [user.id for user in users] + context = { + "post_title": "Test Post", + "author_name": "Test Author", + "replier_name": "Replier Name" + } + with override_waffle_flag(ENABLE_NOTIFICATIONS, active=True): + with self.assertNumQueries(3): + send_notifications(user_ids, str(self.course.id), notification_app, notification_type, + context, "http://test.url") diff --git a/openedx/core/djangoapps/notifications/tests/test_views.py b/openedx/core/djangoapps/notifications/tests/test_views.py index 88fd9619ef..76da8756c6 100644 --- a/openedx/core/djangoapps/notifications/tests/test_views.py +++ b/openedx/core/djangoapps/notifications/tests/test_views.py @@ -73,7 +73,7 @@ class CourseEnrollmentListViewTest(ModuleStoreTestCase): """ Test the CourseEnrollmentListView. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Enable or disable the waffle flag based on the test case data with override_waffle_flag(SHOW_NOTIFICATIONS_TRAY, active=show_notifications_tray): url = reverse('enrollment-list') @@ -217,7 +217,13 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 'notification_preference_config': { 'discussion': { 'enabled': True, - 'core_notification_types': ['new_comment_on_response', 'new_comment', 'new_response'], + 'core_notification_types': [ + 'new_comment_on_response', + 'new_comment', + 'new_response', + 'response_on_followed_post', + 'comment_on_followed_post' + ], 'notification_types': { 'core': { 'web': True, @@ -227,7 +233,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): 'following, including endorsements to your responses and on your posts.' }, 'new_discussion_post': {'web': False, 'email': False, 'push': False, 'info': ''}, - 'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''} + 'new_question_post': {'web': False, 'email': False, 'push': False, 'info': ''}, }, 'non_editable': { 'core': ['web'] @@ -248,7 +254,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): """ Test get user notification preference. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get(self.path) self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data, self._expected_api_response()) @@ -274,7 +280,7 @@ class UserNotificationPreferenceAPITest(ModuleStoreTestCase): """ Test update of user notification preference. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) payload = { 'notification_app': notification_app, 'value': value, @@ -319,7 +325,8 @@ class NotificationListAPIViewTest(APITestCase): """ def setUp(self): - self.user = UserFactory() + self.TEST_PASSWORD = 'Password1234' + self.user = UserFactory(password=self.TEST_PASSWORD) self.url = reverse('notifications-list') def test_list_notifications(self): @@ -336,7 +343,7 @@ class NotificationListAPIViewTest(APITestCase): 'post_title': 'This is a test post.', } ) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Make a request to the view. response = self.client.get(self.url) @@ -373,7 +380,7 @@ class NotificationListAPIViewTest(APITestCase): app_name='app2', notification_type='info', ) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Make a request to the view with the app_name query parameter set to 'app1'. response = self.client.get(self.url + "?app_name=discussion") @@ -396,7 +403,7 @@ class NotificationListAPIViewTest(APITestCase): """ Test event emission with tray_opened param is provided. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Make a request to the view with the tray_opened query parameter set to True. response = self.client.get(self.url + "?tray_opened=True") @@ -436,7 +443,7 @@ class NotificationListAPIViewTest(APITestCase): notification_type='info', created=today - timedelta(days=settings.NOTIFICATIONS_EXPIRY) ) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Make a request to the view response = self.client.get(self.url) @@ -463,7 +470,7 @@ class NotificationListAPIViewTest(APITestCase): user=self.user, notification_type='info', ) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Make a request to the view response = self.client.get(self.url) @@ -517,7 +524,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase): """ Test that the endpoint returns the correct count of unseen notifications and show_notifications_tray value. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Enable or disable the waffle flag based on the test case data with override_waffle_flag(SHOW_NOTIFICATIONS_TRAY, active=show_notifications_tray_enabled): @@ -544,7 +551,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase): """ # Create a user with no notifications. user = UserFactory() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) @@ -556,7 +563,7 @@ class NotificationCountViewSetTestCase(ModuleStoreTestCase): Tests if "notification_expiry_days" exists in API response """ user = UserFactory() - self.client.login(username=user.username, password='test') + self.client.login(username=user.username, password=self.TEST_PASSWORD) response = self.client.get(self.url) self.assertEqual(response.status_code, 200) self.assertEqual(response.data['notification_expiry_days'], 60) @@ -568,7 +575,8 @@ class MarkNotificationsSeenAPIViewTestCase(APITestCase): """ def setUp(self): - self.user = UserFactory() + self.TEST_PASSWORD = 'Password1234' + self.user = UserFactory(password=self.TEST_PASSWORD) # Create some sample notifications for the user Notification.objects.create(user=self.user, app_name='App Name 1', notification_type='Type A') @@ -580,7 +588,7 @@ class MarkNotificationsSeenAPIViewTestCase(APITestCase): # Create a POST request to mark notifications as seen for 'App Name 1' app_name = 'App Name 1' url = reverse('mark-notifications-seen', kwargs={'app_name': app_name}) - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.put(url) # Assert the response status code is 200 (OK) self.assertEqual(response.status_code, status.HTTP_200_OK) @@ -600,9 +608,10 @@ class NotificationReadAPIViewTestCase(APITestCase): """ def setUp(self): - self.user = UserFactory() + self.TEST_PASSWORD = 'Password1234' + self.user = UserFactory(password=self.TEST_PASSWORD) self.url = reverse('notifications-read') - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) # Create some sample notifications for the user with already existing apps and with invalid app name Notification.objects.create(user=self.user, app_name='app_name_2', notification_type='Type A') @@ -659,7 +668,7 @@ class NotificationReadAPIViewTestCase(APITestCase): # Create a PATCH request to mark notification as read for notification_id: 2 through a different user self.client.logout() self.user = UserFactory() - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) notification_id = 2 data = {'notification_id': notification_id} diff --git a/openedx/core/djangoapps/notifications/utils.py b/openedx/core/djangoapps/notifications/utils.py index 4ee3a614a7..39bf8755da 100644 --- a/openedx/core/djangoapps/notifications/utils.py +++ b/openedx/core/djangoapps/notifications/utils.py @@ -42,3 +42,12 @@ def get_show_notifications_tray(user): break return show_notifications_tray + + +def get_list_in_batches(input_list, batch_size): + """ + Divides the list of objects into list of list of objects each of length batch_size. + """ + list_length = len(input_list) + for index in range(0, list_length, batch_size): + yield input_list[index: index + batch_size] diff --git a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst index d65aaaf4a9..d859040516 100644 --- a/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst +++ b/openedx/core/djangoapps/oauth_dispatch/docs/decisions/0008-use-asymmetric-jwts.rst @@ -63,7 +63,7 @@ The code examples below show this in action. Remove JWT_ISSUERS ~~~~~~~~~~~~~~~~~~ -edx_rest_framework_extensions.settings_ supports having a list of **JWT_ISSUERS** instead of just a single +`edx_rest_framework_extensions.settings`_ supports having a list of **JWT_ISSUERS** instead of just a single one. This support for configuring multiple issuers is present across many services. However, this does not conform to the `JWT standard`_, where the `issuer`_ is intended to identify the entity that generates and signs the JWT. In our case, that should be the single Auth service only. @@ -81,70 +81,56 @@ issuer, but with (the potential of) multiple signing keys stored in a JWT Set. .. _JSON Web Key Set (JWK Set): https://tools.ietf.org/html/draft-ietf-jose-json-web-key-36#section-5 .. _site configuration: https://github.com/openedx/edx-platform/blob/af841336c7e39d634c238cd8a11c5a3a661aa9e2/openedx/core/djangoapps/site_configuration/__init__.py -Example Code ------------- +Features +-------- KeyPair Generation ~~~~~~~~~~~~~~~~~~ -Here is code for generating a keypair:: +Please have a look at ``openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py`` +to get better understanding how to generate keypair using ``PyJWT``. - from Cryptodome.PublicKey import RSA - from jwkest import jwk +The public and private keypair would be similar to the following:: - rsa_key = RSA.generate(2048) - rsa_jwk = jwk.RSAKey(kid="your_key_id", key=rsa_key) + ## Public keyset + """ + { + "keys": [ + { + "kty": "RSA", + "key_ops": ["verify"], + "n": "...", + "e": "...", + "kid": "your_key_id" + } + ] + } + """ -To serialize the **public key** in a `JSON Web Key Set (JWK Set)`_:: - public_keys = jwk.KEYS() - public_keys.append(rsa_jwk) - serialized_public_keys_json = public_keys.dump_jwks() - -and its sample output:: - - { - "keys": [ - { - "kid": "your_key_id", - "e": "strawberry", - "kty": "RSA", - "n": "something" - } - ] - } - -To serialize the **keypair** as a JWK:: - - serialized_keypair = rsa_jwk.serialize(private=True) - serialized_keypair_json = json.dumps(serialized_keypair) - -and its sample output:: - - { - "e": "strawberry", - "d": "apple", - "n": "banana", - "q": "pear", - "p": "plum", - "kid": "your_key_id", - "kty": "RSA" - } + ## Private key + """ + { + "kty": "RSA", + "key_ops": ["sign"], + "n": "...", + "e": "...", + "d": "...", + "p": "...", + "q": "...", + "dp": "...", + "dq": "...", + "qi": "...", + "kid": "your_key_id" + } + """ Signing ~~~~~~~ -To deserialize the keypair from above:: +To create a signature you simply need a **payload**, **private key** and your hashing algorithm:: - private_keys = jwk.KEYS() - serialized_keypair = json.loads(serialized_keypair_json) - private_keys.add(serialized_keypair) - -To create a signature:: - - from jwkest.jws import JWS - jws = JWS("JWT payload", alg="RS512") - signed_message = jws.sign_compact(keys=private_keys) + signed_message = jwt.encode("JWT payload in dict format", key=private_key, algorithm="RS512") Note: we specify **RS512** above to identify *RSASSA-PKCS1-v1_5 using SHA-512* as the signature algorithm value as described in the `JSON Web Algorithms (JWA)`_ spec. @@ -154,24 +140,20 @@ the signature algorithm value as described in the `JSON Web Algorithms (JWA)`_ s Verify Signature ~~~~~~~~~~~~~~~~ -To verify the signature from above:: +To verify the signature we'll be looping through the public keys and try to verify the signature with each of them. +For more details you can have a look at `verify_jwk_signature_using_keyset`_. To generate ``keyset`` required for verification you +can use `get_verification_jwk_key_set`_ method. - public_keys = jwk.KEYS() - public_keys.load_jwks(serialized_public_keys_json) - jws.verify_compact(signed_message, public_keys) +.. _verify_jwk_signature_using_keyset: https://github.com/openedx/edx-drf-extensions/blob/master/edx_rest_framework_extensions/auth/jwt/decoder.py#L270 +.. _get_verification_jwk_key_set : https://github.com/openedx/edx-drf-extensions/blob/master/edx_rest_framework_extensions/auth/jwt/decoder.py#L395 Key Rotation ~~~~~~~~~~~~ -When a new public key is added in the future, it should have a unique "kid" -value and added to the public keys JWK set:: - - new_rsa_key = RSA.generate(2048) - new_rsa_jwk = jwk.RSAKey(kid="new_id", key=new_rsa_key) - public_keys.append(new_rsa_jwk) - -When a JWS is created, it is signed with a certain "kid"-identified keypair. When it -is later verified, the public key with the matching "kid" in the JWK set is used. +In future if we plan to rotate the keys, we can simply add new key public key to the public keyset and remove the old private one. +Means, at any time there might be more than one public key but there will be only one private key. Considering that we are doing verification +by looping through all the available public keys, the ``kid`` parameter is not +as important as it was before. But it's still recommended to use it. It will help us to differentiate between the old and new public keys. Consequences ------------ diff --git a/openedx/core/djangoapps/oauth_dispatch/jwt.py b/openedx/core/djangoapps/oauth_dispatch/jwt.py index 329d28822f..1d88e835bc 100644 --- a/openedx/core/djangoapps/oauth_dispatch/jwt.py +++ b/openedx/core/djangoapps/oauth_dispatch/jwt.py @@ -5,12 +5,13 @@ import json import logging from time import time +import jwt from django.conf import settings from edx_django_utils.monitoring import increment, set_custom_attribute from edx_rbac.utils import create_role_auth_claim_for_user from edx_toggles.toggles import SettingToggle -from jwkest import jwk -from jwkest.jws import JWS +from jwt import PyJWK +from jwt.utils import base64url_encode from common.djangoapps.student.models import UserProfile, anonymous_id_for_user @@ -273,17 +274,14 @@ def _attach_profile_claim(payload, user): def _encode_and_sign(payload, use_asymmetric_key, secret): """Encode and sign the provided payload.""" - keys = jwk.KEYS() if use_asymmetric_key: - serialized_keypair = json.loads(settings.JWT_AUTH['JWT_PRIVATE_SIGNING_JWK']) - keys.add(serialized_keypair) + key = json.loads(settings.JWT_AUTH['JWT_PRIVATE_SIGNING_JWK']) algorithm = settings.JWT_AUTH['JWT_SIGNING_ALGORITHM'] else: - key = secret if secret else settings.JWT_AUTH['JWT_SECRET_KEY'] - keys.add({'key': key, 'kty': 'oct'}) + secret = secret if secret else settings.JWT_AUTH['JWT_SECRET_KEY'] + key = {'k': base64url_encode(secret.encode('utf-8')), 'kty': 'oct'} algorithm = settings.JWT_AUTH['JWT_ALGORITHM'] - data = json.dumps(payload) - jws = JWS(data, alg=algorithm) - return jws.sign_compact(keys=keys) + jwk = PyJWK(key, algorithm) + return jwt.encode(payload, jwk.key, algorithm=algorithm) diff --git a/openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py b/openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py index cca094bf82..a139cebca1 100644 --- a/openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py +++ b/openedx/core/djangoapps/oauth_dispatch/management/commands/generate_jwt_signing_key.py @@ -14,7 +14,7 @@ import yaml from Cryptodome.PublicKey import RSA from django.conf import settings from django.core.management.base import BaseCommand -from jwkest import jwk +from jwt.algorithms import get_default_algorithms log = logging.getLogger(__name__) @@ -123,15 +123,23 @@ class Command(BaseCommand): def _generate_key_pair(self, key_size, key_id): log.info('Generating new JWT signing keypair for key id %s.', key_id) rsa_key = RSA.generate(key_size) - rsa_jwk = jwk.RSAKey(kid=key_id, key=rsa_key) - return rsa_jwk + algo = get_default_algorithms()['RS512'] + key_data = algo.prepare_key(rsa_key.export_key('PEM').decode()) + rsa_jwk = json.loads(algo.to_jwk(key_data)) + public_rsa_jwk = json.loads(algo.to_jwk(key_data.public_key())) + + rsa_jwk['kid'] = key_id + public_rsa_jwk['kid'] = key_id + return {'private': rsa_jwk, 'public': public_rsa_jwk} def _output_public_keys(self, jwk_key, add_previous, strip_prefix): - public_keys = jwk.KEYS() + public_keys = {'keys': []} + if add_previous: self._add_previous_public_keys(public_keys) - public_keys.append(jwk_key) - serialized_public_keys = public_keys.dump_jwks() + + public_keys['keys'].append(jwk_key['public']) + serialized_public_keys = json.dumps(public_keys) prefix = '' if strip_prefix else 'COMMON_' public_signing_key = f'{prefix}JWT_PUBLIC_SIGNING_JWK_SET' @@ -155,11 +163,10 @@ class Command(BaseCommand): previous_signing_keys = settings.JWT_AUTH.get('JWT_PUBLIC_SIGNING_JWK_SET') if previous_signing_keys: log.info('Old JWT_PUBLIC_SIGNING_JWK_SET: %s.', previous_signing_keys) - public_keys.load_jwks(previous_signing_keys) + public_keys['keys'].extend(json.loads(previous_signing_keys)['keys']) def _output_private_keys(self, jwk_key, strip_prefix): - serialized_keypair = jwk_key.serialize(private=True) - serialized_keypair_json = json.dumps(serialized_keypair) + serialized_keypair_json = json.dumps(jwk_key['private']) prefix = '' if strip_prefix else 'EDXAPP_' private_signing_key = f'{prefix}JWT_PRIVATE_SIGNING_JWK' diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py index 6818c171c6..d99ac4883b 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/mixins.py @@ -3,10 +3,11 @@ OAuth Dispatch test mixins """ import pytest -import jwt from django.conf import settings -from jwkest.jwk import KEYS -from jwkest.jws import JWS +from edx_rest_framework_extensions.auth.jwt.decoder import ( + get_verification_jwk_key_set, + verify_jwk_signature_using_keyset +) from jwt.exceptions import ExpiredSignatureError from common.djangoapps.student.models import UserProfile, anonymous_id_for_user @@ -33,25 +34,15 @@ class AccessTokenMixin: Helper method to decode a JWT with the ability to verify the expiration of said token """ - keys = KEYS() - if should_be_asymmetric_key: - keys.load_jwks(settings.JWT_AUTH['JWT_PUBLIC_SIGNING_JWK_SET']) - else: - keys.add({'key': secret_key, 'kty': 'oct'}) + asymmetric_keys = settings.JWT_AUTH.get('JWT_PUBLIC_SIGNING_JWK_SET') if should_be_asymmetric_key else None + key_set = get_verification_jwk_key_set(asymmetric_keys=asymmetric_keys, secret_key=secret_key) + data = verify_jwk_signature_using_keyset(access_token, + key_set, + iss=issuer, + aud=aud, + verify_exp=verify_expiration) - _ = JWS().verify_compact(access_token.encode('utf-8'), keys) - - return jwt.decode( - access_token, - secret_key, - algorithms=[settings.JWT_AUTH['JWT_ALGORITHM']], - audience=audience, - issuer=issuer, - options={ - 'verify_signature': False, - "verify_exp": verify_expiration - }, - ) + return data # Note that if we expect the claims to have expired # then we ask the JWT library not to verify expiration diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_overrides.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_overrides.py index 37bc12ceaa..dc38b18c6e 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_overrides.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_dot_overrides.py @@ -31,19 +31,20 @@ class AuthenticateTestCase(TestCase): def setUp(self): super().setUp() + self.TEST_PASSWORD = 'Password1234' self.user = UserFactory.create( username='darkhelmet', - password='12345', + password=self.TEST_PASSWORD, email='darkhelmet@spaceball_one.org', ) self.validator = EdxOAuth2Validator() def test_authenticate_with_username(self): - user = self.validator._authenticate(username='darkhelmet', password='12345') + user = self.validator._authenticate(username='darkhelmet', password=self.TEST_PASSWORD) assert self.user == user def test_authenticate_with_email(self): - user = self.validator._authenticate(username='darkhelmet@spaceball_one.org', password='12345') + user = self.validator._authenticate(username='darkhelmet@spaceball_one.org', password=self.TEST_PASSWORD) assert self.user == user @@ -56,9 +57,10 @@ class CustomValidationTestCase(TestCase): """ def setUp(self): super().setUp() + self.TEST_PASSWORD = 'Password1234' self.user = UserFactory.create( username='darkhelmet', - password='12345', + password=self.TEST_PASSWORD, email='darkhelmet@spaceball_one.org', ) self.validator = EdxOAuth2Validator() @@ -67,13 +69,13 @@ class CustomValidationTestCase(TestCase): def test_active_user_validates(self): assert self.user.is_active request = self.request_factory.get('/') - assert self.validator.validate_user('darkhelmet', '12345', client=None, request=request) + assert self.validator.validate_user('darkhelmet', self.TEST_PASSWORD, client=None, request=request) def test_inactive_user_validates(self): self.user.is_active = False self.user.save() request = self.request_factory.get('/') - assert self.validator.validate_user('darkhelmet', '12345', client=None, request=request) + assert self.validator.validate_user('darkhelmet', self.TEST_PASSWORD, client=None, request=request) @skip_unless_lms @@ -87,9 +89,10 @@ class CustomAuthorizationViewTestCase(TestCase): """ def setUp(self): super().setUp() + self.TEST_PASSWORD = 'Password1234' self.dot_adapter = adapters.DOTAdapter() - self.user = UserFactory() - self.client.login(username=self.user.username, password='test') + self.user = UserFactory(password=self.TEST_PASSWORD) + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) self.restricted_dot_app = self._create_restricted_app() self._create_expired_token(self.restricted_dot_app) diff --git a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py index d6ed9ca30c..7bd991ba60 100644 --- a/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py +++ b/openedx/core/djangoapps/oauth_dispatch/tests/test_views.py @@ -9,12 +9,10 @@ from unittest.mock import call, patch import ddt import httpretty -from Cryptodome.PublicKey import RSA from django.conf import settings from django.test import RequestFactory, TestCase from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_switch -from jwkest import jwk from oauth2_provider import models as dot_models from common.djangoapps.student.tests.factories import UserFactory @@ -86,8 +84,9 @@ class _DispatchingViewTestCase(TestCase): """ def setUp(self): super().setUp() + self.TEST_PASSWORD = 'Password1234' self.dot_adapter = adapters.DOTAdapter() - self.user = UserFactory() + self.user = UserFactory(password=self.TEST_PASSWORD) self.dot_app = self.dot_adapter.create_public_client( name='test dot application', user=self.user, @@ -148,7 +147,7 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa if grant_type == dot_models.Application.GRANT_PASSWORD: body['username'] = user.username - body['password'] = 'test' + body['password'] = self.TEST_PASSWORD elif grant_type == dot_models.Application.GRANT_CLIENT_CREDENTIALS: body['client_secret'] = client.client_secret @@ -163,20 +162,6 @@ class TestAccessTokenView(AccessTokenLoginMixin, mixins.AccessTokenMixin, _Dispa return body - def _generate_key_pair(self): - """ Generates an asymmetric key pair and returns the JWK of its public keys and keypair. """ - rsa_key = RSA.generate(2048) - rsa_jwk = jwk.RSAKey(kid="key_id", key=rsa_key) - - public_keys = jwk.KEYS() - public_keys.append(rsa_jwk) - serialized_public_keys_json = public_keys.dump_jwks() - - serialized_keypair = rsa_jwk.serialize(private=True) - serialized_keypair_json = json.dumps(serialized_keypair) - - return serialized_public_keys_json, serialized_keypair_json - def _test_jwt_access_token(self, client_attr, token_type=None, headers=None, grant_type=None, asymmetric_jwt=False): """ Test response for JWT token. @@ -598,7 +583,7 @@ class TestAuthorizationView(_DispatchingViewTestCase): @ddt.unpack def test_post_authorization_view(self, client_type, allow_field): oauth_application = getattr(self, f'{client_type}_app') - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.post( '/oauth2/authorize/', { @@ -620,7 +605,7 @@ class TestAuthorizationView(_DispatchingViewTestCase): Make sure we get the overridden Authorization page - not the default django-oauth-toolkit when we perform a page load """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) response = self.client.get( '/oauth2/authorize/', { @@ -787,7 +772,7 @@ class TestRevokeTokenView(AccessTokenLoginMixin, _DispatchingViewTestCase): # p 'client_id': self.dot_app.client_id, 'grant_type': 'password', 'username': self.user.username, - 'password': 'test', + 'password': self.TEST_PASSWORD, } def access_token_post_body_with_refresh_token(self, refresh_token): diff --git a/openedx/core/djangoapps/safe_sessions/middleware.py b/openedx/core/djangoapps/safe_sessions/middleware.py index a9ade2ac3b..5f4449d93c 100644 --- a/openedx/core/djangoapps/safe_sessions/middleware.py +++ b/openedx/core/djangoapps/safe_sessions/middleware.py @@ -267,10 +267,7 @@ class SafeCookieData: SHA256(version '|' session_id '|' user_id '|'). """ data_to_sign = self._compute_digest(user_id) - - self.signature = signing.TimestampSigner( - salt=self.key_salt, algorithm=settings.DEFAULT_HASHING_ALGORITHM - ).sign_object(data_to_sign, serializer=signing.JSONSerializer, compress=False) + self.signature = signing.dumps(data_to_sign, salt=self.key_salt) def verify(self, user_id): """ @@ -279,10 +276,7 @@ class SafeCookieData: (not expired) and bound to the given user. """ try: - unsigned_data = signing.TimestampSigner( - salt=self.key_salt, algorithm=settings.DEFAULT_HASHING_ALGORITHM - ).unsign_object(self.signature, serializer=signing.JSONSerializer, max_age=settings.SESSION_COOKIE_AGE) - + unsigned_data = signing.loads(self.signature, salt=self.key_salt, max_age=settings.SESSION_COOKIE_AGE) if unsigned_data == self._compute_digest(user_id): return True log.error("SafeCookieData '%r' is not bound to user '%s'.", str(self), user_id) diff --git a/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py b/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py index 145233c9c0..079cdcdd0c 100644 --- a/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py +++ b/openedx/core/djangoapps/safe_sessions/tests/test_middleware.py @@ -36,6 +36,7 @@ class TestSafeSessionProcessRequest(TestSafeSessionsLogMixin, TestCase): self.user = UserFactory.create() self.addCleanup(set_current_request, None) self.request = get_mock_request() + self.TEST_PASSWORD = 'Password1234' def assert_response(self, safe_cookie_data=None, success=True): """ @@ -79,7 +80,7 @@ class TestSafeSessionProcessRequest(TestSafeSessionsLogMixin, TestCase): @patch("openedx.core.djangoapps.safe_sessions.middleware.LOG_REQUEST_USER_CHANGES", False) @patch("openedx.core.djangoapps.safe_sessions.middleware.track_request_user_changes") def test_success(self, mock_log_request_user_changes): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) session_id = self.client.session.session_key safe_cookie_data = SafeCookieData.create(session_id, self.user.id) @@ -106,7 +107,7 @@ class TestSafeSessionProcessRequest(TestSafeSessionsLogMixin, TestCase): @patch("openedx.core.djangoapps.safe_sessions.middleware.LOG_REQUEST_USER_CHANGES", True) @patch("openedx.core.djangoapps.safe_sessions.middleware.track_request_user_changes") def test_log_request_user_on(self, mock_log_request_user_changes): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) session_id = self.client.session.session_key safe_cookie_data = SafeCookieData.create(session_id, self.user.id) @@ -134,7 +135,7 @@ class TestSafeSessionProcessRequest(TestSafeSessionsLogMixin, TestCase): self.assert_no_session() def test_invalid_user_at_step_4(self): - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) safe_cookie_data = SafeCookieData.create(self.client.session.session_key, 'no_such_user') self.request.META['HTTP_ACCEPT'] = 'text/html' with self.assert_incorrect_user_logged(): @@ -225,6 +226,16 @@ class TestSafeSessionProcessResponse(TestSafeSessionsLogMixin, TestCase): assert safe_cookie_data.session_id == 'some_session_id' assert safe_cookie_data.verify(self.user.id) + def test_update_cookie_data_at_step_3_with_sha256(self): + """ first encode cookie with default algo sha1 and then check with sha256""" + self.assert_response(set_request_user=True, set_session_cookie=True) + serialized_cookie_data = self.client.response.cookies[settings.SESSION_COOKIE_NAME].value + safe_cookie_data = SafeCookieData.parse(serialized_cookie_data) + assert safe_cookie_data.version == SafeCookieData.CURRENT_VERSION + assert safe_cookie_data.session_id == 'some_session_id' + with self.settings(DEFAULT_HASHING_ALGORITHM='sha256'): + assert safe_cookie_data.verify(self.user.id) + def test_cant_update_cookie_at_step_3_error(self): self.client.response.cookies[settings.SESSION_COOKIE_NAME] = None with self.assert_invalid_session_id(): @@ -250,7 +261,8 @@ class TestSafeSessionMiddleware(TestSafeSessionsLogMixin, CacheIsolationTestCase def setUp(self): super().setUp() - self.user = UserFactory.create() + self.TEST_PASSWORD = 'Password1234' + self.user = UserFactory.create(password=self.TEST_PASSWORD) self.addCleanup(set_current_request, None) self.request = get_mock_request() self.client.response = HttpResponse() @@ -270,7 +282,7 @@ class TestSafeSessionMiddleware(TestSafeSessionsLogMixin, CacheIsolationTestCase """ Set up request for success path -- everything up until process_response(). """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) session_id = self.client.session.session_key safe_cookie_data = SafeCookieData.create(session_id, self.user.id) diff --git a/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py b/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py index b8a23c567d..47efa626ec 100644 --- a/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py +++ b/openedx/core/djangoapps/safe_sessions/tests/test_safe_cookie_data.py @@ -10,6 +10,7 @@ from unittest.mock import patch import pytest import ddt +import django from django.test import TestCase from ..middleware import SafeCookieData, SafeCookieError @@ -204,6 +205,7 @@ class TestSafeCookieData(TestSafeSessionsLogMixin, TestCase): #---- Test roundtrip with pinned values ----# + @pytest.mark.skipif(django.VERSION[0] >= 4, reason="For django32 default algorithm is sha1. No need for django42.") def test_pinned_values(self): """ Compute a cookie with all inputs held constant and assert that the @@ -236,3 +238,37 @@ class TestSafeCookieData(TestSafeSessionsLogMixin, TestCase): ":1m6Hve" ":OMhY2FL2pudJjSSXChtI-zR8QVA" ) + + @pytest.mark.skipif(django.VERSION[0] < 4, reason="For django42 default algorithm is sha256. No need for django32.") + def test_pinned_values_django42(self): + """ + Compute a cookie with all inputs held constant and assert that the + exact output never changes. This protects against unintentional + changes to the algorithm. + """ + user_id = '8523' + session_id = 'SSdtIGEgc2Vzc2lvbiE' + a_random_string = 'HvGnjXf1b3jU' + timestamp = 1626895850 + + module = 'openedx.core.djangoapps.safe_sessions.middleware' + with patch(f"{module}.signing.time.time", return_value=timestamp): + with patch(f"{module}.get_random_string", return_value=a_random_string): + safe_cookie_data = SafeCookieData.create(session_id, user_id) + serialized_value = str(safe_cookie_data) + + # **IMPORTANT**: If a change to the algorithm causes this test + # to start failing, you will either need to allow both the old + # and new format or all users will become logged out upon + # deploy of the changes. + # + # Also assumes SECRET_KEY is '85920908f28904ed733fe576320db18cabd7b6cd' + # (set in lms or cms.envs.test) + assert serialized_value == ( + "1" + "|SSdtIGEgc2Vzc2lvbiE" + "|HvGnjXf1b3jU" + "|ImExZWZiNzVlZGFmM2FkZWZmYjM4YjI0ZmZkOWU4MzExODU0MTk4NmVlNGRiYzBlODdhYWUzOGM5MzVlNzk4NjUi" + ":1m6Hve" + ":Pra4iochviPvKUoIV33gdVZFDgG-cMDlIYfl8iFIMaY" + ) diff --git a/openedx/core/djangoapps/signals/signals.py b/openedx/core/djangoapps/signals/signals.py index d11caaa24d..ca693b4d10 100644 --- a/openedx/core/djangoapps/signals/signals.py +++ b/openedx/core/djangoapps/signals/signals.py @@ -11,7 +11,7 @@ COURSE_GRADE_CHANGED = Signal() # Signal that fires when a user is awarded a certificate in a course (in the certificates django app) # TODO: runtime coupling between apps will be reduced if this event is changed to carry a username -# rather than a User object; however, this will require changes to the milestones and badges APIs +# rather than a User object; however, this will require changes to the milestones # Same providing_args=["user", "course_key", "mode", "status"] for next 3 signals. COURSE_CERT_CHANGED = Signal() COURSE_CERT_AWARDED = Signal() diff --git a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py index 21b9ba952a..d7578f25eb 100644 --- a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py +++ b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py @@ -24,14 +24,15 @@ class TestComprehensiveThemeLMS(TestCase): Clear static file finders cache and register cleanup methods. """ super().setUp() - self.user = UserFactory() + self.TEST_PASSWORD = 'Password1234' + self.user = UserFactory(password=self.TEST_PASSWORD) # Clear the internal staticfiles caches, to get test isolation. staticfiles.finders.get_finder.cache_clear() def _login(self): """ Log into LMS. """ - self.client.login(username=self.user.username, password='test') + self.client.login(username=self.user.username, password=self.TEST_PASSWORD) @with_comprehensive_theme("test-theme") def test_footer(self): diff --git a/openedx/core/djangoapps/theming/tests/test_views.py b/openedx/core/djangoapps/theming/tests/test_views.py index 3eefd7bed8..5b5503702c 100644 --- a/openedx/core/djangoapps/theming/tests/test_views.py +++ b/openedx/core/djangoapps/theming/tests/test_views.py @@ -13,7 +13,7 @@ from openedx.core.djangoapps.theming.models import SiteTheme THEMING_ADMIN_URL = '/theming/admin' TEST_THEME_NAME = 'test-theme' -TEST_PASSWORD = 'test' +TEST_PASSWORD = 'Password1234' class TestThemingViews(TestCase): diff --git a/openedx/core/djangoapps/user_api/accounts/api.py b/openedx/core/djangoapps/user_api/accounts/api.py index 4e1c6b426d..bc4fd1d1a7 100644 --- a/openedx/core/djangoapps/user_api/accounts/api.py +++ b/openedx/core/djangoapps/user_api/accounts/api.py @@ -404,9 +404,23 @@ def get_name_validation_error(name): :return: Validation error message. """ + + def contains_html(value): + """ + Validator method to check whether name contains html tags + """ + regex = re.compile('(<|>)', re.UNICODE) + return bool(regex.search(value)) + + def contains_url(value): + """ + Validator method to check whether full name contains url + """ + regex = re.findall(r'https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))*', value) + return bool(regex) + if name: - regex = re.findall(r'https|http?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+', name) - return _('Enter a valid name') if bool(regex) else '' + return _('Enter a valid name') if (contains_html(name) or contains_url(name)) else '' else: return accounts.REQUIRED_FIELD_NAME_MSG diff --git a/openedx/core/djangoapps/user_api/accounts/serializers.py b/openedx/core/djangoapps/user_api/accounts/serializers.py index 75ecfa35bf..b17f479b45 100644 --- a/openedx/core/djangoapps/user_api/accounts/serializers.py +++ b/openedx/core/djangoapps/user_api/accounts/serializers.py @@ -21,7 +21,6 @@ from common.djangoapps.student.models import ( UserPasswordToggleHistory, UserProfile ) -from lms.djangoapps.badges.utils import badges_enabled from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.user_api import errors from openedx.core.djangoapps.user_api.accounts.utils import is_secondary_email_feature_enabled @@ -136,7 +135,6 @@ class UserReadOnlySerializer(serializers.Serializer): # lint-amnesty, pylint: d except ObjectDoesNotExist: activation_key = None - accomplishments_shared = badges_enabled() data = { "username": user.username, "url": self.context.get('request').build_absolute_uri( @@ -164,7 +162,6 @@ class UserReadOnlySerializer(serializers.Serializer): # lint-amnesty, pylint: d "level_of_education": None, "mailing_address": None, "requires_parental_consent": None, - "accomplishments_shared": accomplishments_shared, "account_privacy": self.configuration.get('default_visibility'), "social_links": None, "extended_profile_fields": None, diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py index fb4686d139..569ec98466 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_api.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_api.py @@ -88,7 +88,7 @@ class TestAccountApi(UserSettingsEventTestMixin, EmailTemplateTagMixin, CreateAc This includes the specific types of error raised, and default behavior when optional arguments are not specified. """ - password = "test" + password = 'Password1234' def setUp(self): super().setUp() @@ -625,7 +625,6 @@ class AccountSettingsOnCreationTest(CreateAccountMixin, TestCase): 'requires_parental_consent': True, 'language_proficiencies': [], 'account_privacy': PRIVATE_VISIBILITY, - 'accomplishments_shared': False, 'extended_profile': [], 'secondary_email': None, 'secondary_email_enabled': None, diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py index 6ec6e3694f..0cc2754e47 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_retirement_views.py @@ -39,7 +39,7 @@ from openedx.core.djangoapps.credit.models import ( CreditRequirement, CreditRequirementStatus ) -from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType +from openedx.core.djangoapps.external_user_ids.models import ExternalIdType from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.user_api.accounts.views import AccountRetirementPartnerReportView @@ -496,7 +496,6 @@ class TestPartnerReportingList(ModuleStoreTestCase): """ EXPECTED_MB_ORGS_CONFIG = [ { - AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching', AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [ AccountRetirementPartnerReportView.STUDENT_ID_KEY, AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY, @@ -516,7 +515,7 @@ class TestPartnerReportingList(ModuleStoreTestCase): self.url = reverse('accounts_retirement_partner_report') self.maxDiff = None self.test_created_datetime = datetime.datetime(2018, 1, 1, tzinfo=pytz.UTC) - ExternalIdType.objects.get_or_create(name=ExternalIdType.MICROBACHELORS_COACHING) + ExternalIdType.objects.get_or_create(name=ExternalIdType.CALIPER) def get_user_dict(self, user, enrollments): """ @@ -624,56 +623,6 @@ class TestPartnerReportingList(ModuleStoreTestCase): self.assert_status_and_user_list(user_dicts) - def test_success_mb_coaching(self): - """ - Check that MicroBachelors users who have consented to coaching have the proper info - included for the partner report. - """ - path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' - with mock.patch(path, return_value=True) as mock_has_ever_consented: - user_dicts, users = self.create_partner_reporting_statuses(num=1) - external_id, created = ExternalId.add_new_user_id( # lint-amnesty, pylint: disable=unused-variable - user=users[0], - type_name=ExternalIdType.MICROBACHELORS_COACHING - ) - - expected_user = user_dicts[0] - expected_users = [expected_user] - expected_user[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = str(external_id.external_user_id) - expected_user[ - AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = TestPartnerReportingList.EXPECTED_MB_ORGS_CONFIG - - self.assert_status_and_user_list(expected_users) - mock_has_ever_consented.assert_called_once() - - def test_success_mb_coaching_no_external_id(self): - """ - Check that MicroBachelors users who have consented to coaching, but who do not have an external id, have the - proper info included for the partner report. - """ - path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' - with mock.patch(path, return_value=True) as mock_has_ever_consented: - user_dicts, users = self.create_partner_reporting_statuses(num=1) # lint-amnesty, pylint: disable=unused-variable - - self.assert_status_and_user_list(user_dicts) - mock_has_ever_consented.assert_called_once() - - def test_success_mb_coaching_no_consent(self): - """ - Check that MicroBachelors users who have not consented to coaching have the proper info - included for the partner report. - """ - path = 'openedx.core.djangoapps.user_api.accounts.views.has_ever_consented_to_coaching' - with mock.patch(path, return_value=False) as mock_has_ever_consented: - user_dicts, users = self.create_partner_reporting_statuses(num=1) - ExternalId.add_new_user_id( - user=users[0], - type_name=ExternalIdType.MICROBACHELORS_COACHING - ) - - self.assert_status_and_user_list(user_dicts) - mock_has_ever_consented.assert_called_once() - def test_no_users(self): """ Checks that the call returns a success code and empty list if no users are found. diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py index 63cb59cace..546b8afacc 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/test_views.py @@ -359,7 +359,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe ENABLED_CACHES = ['default'] TOTAL_QUERY_COUNT = 24 - FULL_RESPONSE_FIELD_COUNT = 30 + FULL_RESPONSE_FIELD_COUNT = 29 def setUp(self): super().setUp() @@ -379,12 +379,12 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe legacy_profile.save() return year_of_birth - def _verify_full_shareable_account_response(self, response, account_privacy=None, badges_enabled=False): + def _verify_full_shareable_account_response(self, response, account_privacy=None): """ Verify that the shareable fields from the account are returned """ data = response.data - assert 12 == len(data) + assert 11 == len(data) # public fields (3) assert account_privacy == data['account_privacy'] @@ -399,7 +399,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert 'm' == data['level_of_education'] assert data['social_links'] is not None assert data['time_zone'] is None - assert badges_enabled == data['accomplishments_shared'] def _verify_private_account_response(self, response, requires_parental_consent=False): """ @@ -436,7 +435,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert 'm' == data['level_of_education'] assert data['social_links'] is not None assert UserPreference.get_value(self.user, 'time_zone') == data['time_zone'] - assert data['accomplishments_shared'] is not None assert ((self.user.first_name + ' ') + self.user.last_name) == data['name'] # additional admin fields (13) @@ -669,7 +667,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe response = self.send_get(self.different_client) self._verify_private_account_response(response) - @mock.patch.dict(settings.FEATURES, {'ENABLE_OPENBADGES': True}) @ddt.data( ("client", "user", PRIVATE_VISIBILITY), ("different_client", "different_user", PRIVATE_VISIBILITY), @@ -691,7 +688,7 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe if preference_visibility == PRIVATE_VISIBILITY: self._verify_private_account_response(response) else: - self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY, badges_enabled=True) + self._verify_full_shareable_account_response(response, ALL_USERS_VISIBILITY) client = self.login_client(api_client, requesting_username) @@ -812,8 +809,6 @@ class TestAccountsAPI(FilteredQueryCountMixin, CacheIsolationTestCase, UserAPITe assert [] == data['language_proficiencies'] assert PRIVATE_VISIBILITY == data['account_privacy'] assert data['time_zone'] is None - # Badges aren't on by default, so should not be present. - assert data['accomplishments_shared'] is False self.client.login(username=self.user.username, password=TEST_PASSWORD) verify_get_own_information(self._get_num_queries(22)) diff --git a/openedx/core/djangoapps/user_api/accounts/tests/testutils.py b/openedx/core/djangoapps/user_api/accounts/tests/testutils.py index 8befbbdc90..24f7c16f24 100644 --- a/openedx/core/djangoapps/user_api/accounts/tests/testutils.py +++ b/openedx/core/djangoapps/user_api/accounts/tests/testutils.py @@ -9,7 +9,12 @@ from common.djangoapps.util.password_policy_validators import DEFAULT_MAX_PASSWO INVALID_NAMES = [ None, '', - '' + 'http://', + 'https://', + '<html_name>', + 'https://www.example.com', + 'Valid name http://www.example.com', + 'Valid name <tag>', ] INVALID_USERNAMES_ASCII = [ diff --git a/openedx/core/djangoapps/user_api/accounts/views.py b/openedx/core/djangoapps/user_api/accounts/views.py index 83bdde4f44..6eb2e8341e 100644 --- a/openedx/core/djangoapps/user_api/accounts/views.py +++ b/openedx/core/djangoapps/user_api/accounts/views.py @@ -57,7 +57,6 @@ from openedx.core.djangoapps.ace_common.template_context import get_base_templat from openedx.core.djangoapps.api_admin.models import ApiAccessRequest from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequirementStatus -from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.profile_images.images import remove_profile_images from openedx.core.djangoapps.user_api import accounts @@ -93,11 +92,6 @@ from .serializers import ( from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator -try: - from coaching.api import has_ever_consented_to_coaching -except ImportError: - has_ever_consented_to_coaching = None - log = logging.getLogger(__name__) USER_PROFILE_PII = { @@ -250,9 +244,6 @@ class AccountViewSet(ViewSet): If "custom", the user has selectively chosen a subset of shareable fields to make visible to others via the User Preferences API. - * accomplishments_shared: Signals whether badges are enabled on the - platform and should be fetched. - * phone_number: The phone number for the user. String of numbers with an optional `+` sign at the start. @@ -746,49 +737,8 @@ class AccountRetirementPartnerReportView(ViewSet): 'created': retirement_status.created, } - # Some orgs have a custom list of headings and content for the partner report. Add this, if applicable. - self._add_orgs_config_for_user(retirement, retirement_status.user) - return retirement - def _add_orgs_config_for_user(self, retirement, user): - """ - Check to see if the user's info was sent to any partners (orgs) that have a a custom list of headings and - content for the partner report. If so, add this. - """ - # See if the MicroBachelors coaching provider needs to be notified of this user's retirement - if has_ever_consented_to_coaching is not None and has_ever_consented_to_coaching(user): - # See if the user has a MicroBachelors external id. If not, they were never sent to the - # coaching provider. - external_ids = ExternalId.objects.filter( - user=user, - external_id_type__name=ExternalIdType.MICROBACHELORS_COACHING - ) - if external_ids.exists(): - # User has an external id. Add the additional info. - external_id = str(external_ids[0].external_user_id) - self._add_coaching_orgs_config(retirement, external_id) - - def _add_coaching_orgs_config(self, retirement, external_id): - """ - Add the orgs configuration for MicroBachelors coaching - """ - # Add the custom field headings - retirement[AccountRetirementPartnerReportView.ORGS_CONFIG_KEY] = [ - { - AccountRetirementPartnerReportView.ORGS_CONFIG_ORG_KEY: 'mb_coaching', - AccountRetirementPartnerReportView.ORGS_CONFIG_FIELD_HEADINGS_KEY: [ - AccountRetirementPartnerReportView.STUDENT_ID_KEY, - AccountRetirementPartnerReportView.ORIGINAL_EMAIL_KEY, - AccountRetirementPartnerReportView.ORIGINAL_NAME_KEY, - AccountRetirementPartnerReportView.DELETION_COMPLETED_KEY - ] - } - ] - - # Add the custom field value - retirement[AccountRetirementPartnerReportView.STUDENT_ID_KEY] = external_id - @request_requires_username def retirement_partner_status_create(self, request): """ diff --git a/openedx/core/djangoapps/user_api/tests/test_views.py b/openedx/core/djangoapps/user_api/tests/test_views.py index aaf2daf96f..28b4c57505 100644 --- a/openedx/core/djangoapps/user_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/tests/test_views.py @@ -33,6 +33,7 @@ class UserAPITestCase(ApiTestCase): Parent test case for User API workflow coverage """ LIST_URI = USER_LIST_URI + TEST_PASSWORD = 'Password1234' def get_uri_for_user(self, target_user): """Given a user object, get the URI for the corresponding resource""" diff --git a/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py b/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py index 1a5d4fe46b..c585936e21 100644 --- a/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py +++ b/openedx/core/djangoapps/user_api/verification_api/tests/test_views.py @@ -23,7 +23,7 @@ class VerificationViewTestsMixinBase: """ Base class for the tests on verification views """ VIEW_NAME = None CREATED_AT = datetime.datetime.strptime(FROZEN_TIME, '%Y-%m-%d') - PASSWORD = 'test' + PASSWORD = 'Password1234' def setUp(self): freezer = freezegun.freeze_time(FROZEN_TIME) diff --git a/openedx/core/djangoapps/user_api/views.py b/openedx/core/djangoapps/user_api/views.py index 421a23adf6..b4fcc68db6 100644 --- a/openedx/core/djangoapps/user_api/views.py +++ b/openedx/core/djangoapps/user_api/views.py @@ -10,7 +10,7 @@ from edx_rest_framework_extensions.auth.session.authentication import SessionAut from opaque_keys import InvalidKeyError from opaque_keys.edx import locator from opaque_keys.edx.keys import CourseKey -from rest_framework import authentication, generics, status, viewsets +from rest_framework import generics, status, viewsets from rest_framework.exceptions import ParseError from rest_framework.permissions import IsAuthenticated from rest_framework.views import APIView @@ -31,7 +31,6 @@ class UserViewSet(viewsets.ReadOnlyModelViewSet): """ DRF class for interacting with the User ORM object """ - authentication_classes = (authentication.SessionAuthentication,) permission_classes = (ApiKeyHeaderPermission,) queryset = User.objects.all().prefetch_related("preferences").select_related("profile") serializer_class = UserSerializer @@ -43,7 +42,6 @@ class ForumRoleUsersListView(generics.ListAPIView): """ Forum roles are represented by a list of user dicts """ - authentication_classes = (authentication.SessionAuthentication,) permission_classes = (ApiKeyHeaderPermission,) serializer_class = UserSerializer paginate_by = 10 @@ -67,7 +65,6 @@ class UserPreferenceViewSet(viewsets.ReadOnlyModelViewSet): """ DRF class for interacting with the UserPreference ORM """ - authentication_classes = (authentication.SessionAuthentication,) permission_classes = (ApiKeyHeaderPermission,) queryset = UserPreference.objects.all() filter_backends = (DjangoFilterBackend,) @@ -81,7 +78,6 @@ class PreferenceUsersListView(generics.ListAPIView): """ DRF class for listing a user's preferences """ - authentication_classes = (authentication.SessionAuthentication,) permission_classes = (ApiKeyHeaderPermission,) serializer_class = UserSerializer paginate_by = 10 diff --git a/openedx/core/djangoapps/user_authn/api/tests/test_views.py b/openedx/core/djangoapps/user_authn/api/tests/test_views.py index 034350a4c9..d6720c46fc 100644 --- a/openedx/core/djangoapps/user_authn/api/tests/test_views.py +++ b/openedx/core/djangoapps/user_authn/api/tests/test_views.py @@ -421,9 +421,9 @@ class SendAccountActivationEmail(UserAPITestCase): Create a user, then log in. """ super().setUp() - self.user = UserFactory() + self.user = UserFactory(password=self.TEST_PASSWORD) Registration().register(self.user) - result = self.client.login(username=self.user.username, password="test") + result = self.client.login(username=self.user.username, password=self.TEST_PASSWORD) assert result, 'Could not log in' self.path = reverse('send_account_activation_email') diff --git a/openedx/core/djangoapps/user_authn/tests/utils.py b/openedx/core/djangoapps/user_authn/tests/utils.py index 4b002eef31..09ca85145f 100644 --- a/openedx/core/djangoapps/user_authn/tests/utils.py +++ b/openedx/core/djangoapps/user_authn/tests/utils.py @@ -58,7 +58,7 @@ class AuthAndScopesTestMixin: self.student. """ default_scopes = None - user_password = 'test' + user_password = 'Password1234' def setUp(self): super().setUp() diff --git a/openedx/core/djangoapps/user_authn/views/registration_form.py b/openedx/core/djangoapps/user_authn/views/registration_form.py index e503265d1e..d49e4c439e 100644 --- a/openedx/core/djangoapps/user_authn/views/registration_form.py +++ b/openedx/core/djangoapps/user_authn/views/registration_form.py @@ -93,7 +93,7 @@ def contains_url(value): """ Validator method to check whether full name contains url """ - regex = re.findall(r'https|http?://(?:[-\w.]|(?:%[\da-fA-F]{2}))+', value) + regex = re.findall(r'https?://(?:[-\w.]|(?:%[\da-fA-F]{2}))*', value) return bool(regex) diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_password.py b/openedx/core/djangoapps/user_authn/views/tests/test_password.py index 764a2178af..a403298a6e 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_password.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_password.py @@ -99,8 +99,8 @@ class TestPasswordChange(CreateAccountMixin, CacheIsolationTestCase): USERNAME = "heisenberg" ALTERNATE_USERNAME = "walt" - OLD_PASSWORD = "ḅḷüëṡḳÿ" - NEW_PASSWORD = "B🄸🄶B🄻🅄🄴" + OLD_PASSWORD = "ḅḷüëṡḳÿ1" + NEW_PASSWORD = "B🄸🄶B🄻🅄🄴1" OLD_EMAIL = "walter@graymattertech.com" NEW_EMAIL = "walt@savewalterwhite.com" @@ -192,11 +192,11 @@ class TestPasswordChange(CreateAccountMixin, CacheIsolationTestCase): UserFactory.create( username='edx', email='edx@example.com', - password='edx', + password='Password1234', is_superuser=is_superuser, is_staff=is_staff, ) - self.client.login(username='edx', password='edx') + self.client.login(username='edx', password='Password1234') response = self._change_password_from_support(email_from_support_tools=self.OLD_EMAIL) assert response.status_code == 200 diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_register.py b/openedx/core/djangoapps/user_authn/views/tests/test_register.py index 0bc05545f7..20261ab9a9 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_register.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_register.py @@ -294,6 +294,45 @@ class RegistrationViewValidationErrorTest( } ) + # testing for http/https + response = self.client.post(self.url, { + "email": "bob@example.com", + "name": "http://", + "username": "bob", + "password": "password", + "honor_code": "true", + }) + assert response.status_code == 400 + response_json = json.loads(response.content.decode('utf-8')) + self.assertDictEqual( + response_json, + { + "name": [{"user_message": 'Enter a valid name'}], + "error_code": "validation-error" + } + ) + + def test_register_fullname_html_validation_error(self): + """ + Test for catching invalid full name errors + """ + response = self.client.post(self.url, { + "email": "bob@example.com", + "name": "<Bob Smith>", + "username": "bob", + "password": "password", + "honor_code": "true", + }) + assert response.status_code == 400 + response_json = json.loads(response.content.decode('utf-8')) + self.assertDictEqual( + response_json, + { + 'name': [{'user_message': 'Full Name cannot contain the following characters: < >'}], + "error_code": "validation-error" + } + ) + def test_register_duplicate_username_account_validation_error(self): # Register the first user response = self.client.post(self.url, { @@ -2689,20 +2728,30 @@ class RegistrationValidationViewTests(test_utils.ApiTestCase, OpenEdxEventsTestM {"username": str(USERNAME_INVALID_CHARS_ASCII)} ) + @override_settings(AUTH_PASSWORD_VALIDATORS=[ + create_validator_config( + 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4} + ) + ]) def test_password_empty_validation_decision(self): # 2 is the default setting for minimum length found in lms/envs/common.py # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator - msg = 'This password is too short. It must contain at least 2 characters.' + msg = 'This password is too short. It must contain at least 4 characters.' self.assertValidationDecision( {'password': ''}, {"password": msg} ) + @override_settings(AUTH_PASSWORD_VALIDATORS=[ + create_validator_config( + 'common.djangoapps.util.password_policy_validators.MinimumLengthValidator', {'min_length': 4} + ) + ]) def test_password_bad_min_length_validation_decision(self): password = 'p' # 2 is the default setting for minimum length found in lms/envs/common.py # under AUTH_PASSWORD_VALIDATORS.MinimumLengthValidator - msg = 'This password is too short. It must contain at least 2 characters.' + msg = 'This password is too short. It must contain at least 4 characters.' self.assertValidationDecision( {'password': password}, {"password": msg} diff --git a/openedx/core/djangoapps/xblock/runtime/shims.py b/openedx/core/djangoapps/xblock/runtime/shims.py index abef606bcf..18aa41eb91 100644 --- a/openedx/core/djangoapps/xblock/runtime/shims.py +++ b/openedx/core/djangoapps/xblock/runtime/shims.py @@ -141,7 +141,7 @@ class RuntimeShim: warnings.warn( "Use of runtime.render_template is deprecated. " "For template files included with your XBlock (which is preferable), use " - "xblockutils.resources.ResourceLoader.render_mako_template to render them, or use a JavaScript-based " + "xblock.utils.resources.ResourceLoader.render_mako_template to render them, or use a JavaScript-based " "template instead. For template files that are part of the LMS/Studio, use the 'mako' XBlock service.", DeprecationWarning, stacklevel=2, ) diff --git a/openedx/core/djangoapps/xblock/tests/test_utils.py b/openedx/core/djangoapps/xblock/tests/test_utils.py index c8181165c7..229406e1bc 100644 --- a/openedx/core/djangoapps/xblock/tests/test_utils.py +++ b/openedx/core/djangoapps/xblock/tests/test_utils.py @@ -136,6 +136,7 @@ def test_secure_token(param_delta: dict, expected_validation: bool): SECRET_KEY=params["generation_secret_key"], XBLOCK_HANDLER_TOKEN_KEYS=params["generation_xblock_handler_token_keys"], ): + assert isinstance(reference_time, datetime.datetime) with freeze_time(reference_time): token = get_secure_token_for_xblock_handler( params["generation_user_id"], params["generation_block_key_str"] @@ -145,7 +146,9 @@ def test_secure_token(param_delta: dict, expected_validation: bool): SECRET_KEY=params["validation_secret_key"], XBLOCK_HANDLER_TOKEN_KEYS=params["validation_xblock_handler_token_keys"], ): - with freeze_time(reference_time + datetime.timedelta(seconds=params["validation_time_delta_s"])): + assert isinstance(params["validation_time_delta_s"], int) + new_reference_time = reference_time + datetime.timedelta(seconds=float(params["validation_time_delta_s"])) + with freeze_time(new_reference_time): assert ( validate_secure_token_for_xblock_handler( params["validation_user_id"], params["validation_block_key_str"], token diff --git a/openedx/core/lib/events.py b/openedx/core/lib/events.py new file mode 100644 index 0000000000..bc90824d51 --- /dev/null +++ b/openedx/core/lib/events.py @@ -0,0 +1,28 @@ +"""Temporary method for use in rolling out a new event producer configuration.""" + +from django.conf import settings +from edx_django_utils.monitoring import set_custom_attribute + + +def determine_producer_config_for_signal_and_topic(signal, topic): + """ + Utility method to determine the setting for the given signal and topic in EVENT_BUS_PRODUCER_CONFIG + + Records to New Relic for later analysis. + + Parameters + signal (OpenEdxPublicSignal): The signal being sent to the event bus + topic (string): The topic to which the signal is being sent (without environment prefix) + + Returns + True if the signal is enabled for that topic in EVENT_BUS_PRODUCER_CONFIG + False if the signal is explicitly disabled for that topic in EVENT_BUS_PRODUCER_CONFIG + None if the signal/topic pair is not present in EVENT_BUS_PRODUCER_CONFIG + """ + event_type_producer_configs = getattr(settings, "EVENT_BUS_PRODUCER_CONFIG", + {}).get(signal.event_type, {}) + topic_config = event_type_producer_configs.get(topic, {}) + topic_setting = topic_config.get('enabled', None) + set_custom_attribute(f'producer_config_setting_{topic}_{signal.event_type}', + topic_setting if topic_setting is not None else 'Unset') + return topic_setting diff --git a/openedx/features/course_duration_limits/tests/test_course_expiration.py b/openedx/features/course_duration_limits/tests/test_course_expiration.py index 32a352d66e..b794ef3076 100644 --- a/openedx/features/course_duration_limits/tests/test_course_expiration.py +++ b/openedx/features/course_duration_limits/tests/test_course_expiration.py @@ -249,7 +249,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin): mode='audit' ) CourseInstructorRole(self.course.id).add_users(instructor) - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) self.update_masquerade(**masquerade_config) @@ -285,7 +285,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin): mode='audit' ) CourseInstructorRole(self.course.id).add_users(instructor) - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) self.update_masquerade(username='audit') @@ -320,7 +320,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin): mode='audit' ) CourseInstructorRole(self.course.id).add_users(instructor) - self.client.login(username=instructor.username, password='test') + self.client.login(username=instructor.username, password=self.TEST_PASSWORD) self.update_masquerade(username='audit') @@ -370,7 +370,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin): mode='audit' ) - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) self.update_masquerade(username=expired_staff.username) @@ -418,7 +418,7 @@ class CourseExpirationTestCase(ModuleStoreTestCase, MasqueradeMixin): mode='audit' ) - self.client.login(username=staff_user.username, password='test') + self.client.login(username=staff_user.username, password=self.TEST_PASSWORD) self.update_masquerade(username=expired_staff.username) diff --git a/openedx/features/course_experience/tests/views/test_course_sock.py b/openedx/features/course_experience/tests/views/test_course_sock.py index cdc80168ab..5c612e3ca8 100644 --- a/openedx/features/course_experience/tests/views/test_course_sock.py +++ b/openedx/features/course_experience/tests/views/test_course_sock.py @@ -20,7 +20,7 @@ from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, p from .helpers import add_course_mode -TEST_PASSWORD = 'test' +TEST_PASSWORD = 'Password1234' TEST_VERIFICATION_SOCK_LOCATOR = '<div class="verification-sock"' diff --git a/openedx/features/course_experience/tests/views/test_masquerade.py b/openedx/features/course_experience/tests/views/test_masquerade.py index 0e9660c015..0f555e27f7 100644 --- a/openedx/features/course_experience/tests/views/test_masquerade.py +++ b/openedx/features/course_experience/tests/views/test_masquerade.py @@ -17,7 +17,7 @@ from xmodule.partitions.partitions_service import PartitionService # lint-amnes from .helpers import add_course_mode from .test_course_sock import TEST_VERIFICATION_SOCK_LOCATOR -TEST_PASSWORD = 'test' +TEST_PASSWORD = 'Password1234' class MasqueradeTestBase(SharedModuleStoreTestCase, MasqueradeMixin): diff --git a/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js b/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js index 6419dd1770..d4f81ae7f4 100644 --- a/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js +++ b/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js @@ -14,14 +14,12 @@ 'js/views/fields', 'learner_profile/js/views/learner_profile_fields', 'learner_profile/js/views/learner_profile_view', - 'learner_profile/js/models/badges_model', - 'learner_profile/js/views/badge_list_container', 'js/student_account/views/account_settings_fields', 'js/views/message_banner', 'string_utils' ], function(gettext, $, _, Backbone, Logger, StringUtils, PagingCollection, AccountSettingsModel, - AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView, BadgeModel, - BadgeListContainer, AccountSettingsFieldViews, MessageBannerView) { + AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView, + AccountSettingsFieldViews, MessageBannerView) { return function(options) { var $learnerProfileElement = $('.wrapper-profile'); @@ -55,9 +53,6 @@ nameFieldView, sectionOneFieldViews, sectionTwoFieldViews, - BadgeCollection, - badgeCollection, - badgeListContainer, learnerProfileView, getProfileVisibility, showLearnerProfileView; @@ -172,26 +167,6 @@ }) ]; - BadgeCollection = PagingCollection.extend({ - queryParams: { - currentPage: 'current_page' - } - }); - badgeCollection = new BadgeCollection(); - badgeCollection.url = options.badges_api_url; - - badgeListContainer = new BadgeListContainer({ - attributes: {class: 'badge-set-display'}, - collection: badgeCollection, - find_courses_url: options.find_courses_url, - ownProfile: options.own_profile, - badgeMeta: { - badges_logo: options.badges_logo, - backpack_ui_img: options.backpack_ui_img, - badges_icon: options.badges_icon - } - }); - learnerProfileView = new LearnerProfileView({ el: $learnerProfileElement, ownProfile: options.own_profile, @@ -204,7 +179,6 @@ nameFieldView: nameFieldView, sectionOneFieldViews: sectionOneFieldViews, sectionTwoFieldViews: sectionTwoFieldViews, - badgeListContainer: badgeListContainer, platformName: options.platform_name }); @@ -239,7 +213,6 @@ accountSettingsModel: accountSettingsModel, accountPreferencesModel: accountPreferencesModel, learnerProfileView: learnerProfileView, - badgeListContainer: badgeListContainer }; }; }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js b/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js deleted file mode 100644 index 42da19ef76..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/models/badges_model.js +++ /dev/null @@ -1,8 +0,0 @@ -(function(define) { - 'use strict'; - - define(['backbone'], function(Backbone) { - var BadgesModel = Backbone.Model.extend({}); - return BadgesModel; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js index f5b8f4bec6..0019c2fc13 100644 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js @@ -31,7 +31,6 @@ define( return new LearnerProfilePage({ accounts_api_url: Helpers.USER_ACCOUNTS_API_URL, preferences_api_url: Helpers.USER_PREFERENCES_API_URL, - badges_api_url: Helpers.BADGES_API_URL, own_profile: ownProfile, account_settings_page_url: Helpers.USER_ACCOUNTS_API_URL, country_options: Helpers.FIELD_OPTIONS, @@ -68,148 +67,6 @@ define( LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); }); - it("doesn't show the mode toggle if badges are disabled", function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: false}), - tabbedView = context.learnerProfileView.tabbedView, - learnerProfileView = context.learnerProfileView; - - LearnerProfileHelpers.expectTabbedViewToBeUndefined(requests, tabbedView); - LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); - }); - - it("doesn't show the mode toggle if badges fail to fetch", function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: false}), - tabbedView = context.learnerProfileView.tabbedView, - learnerProfileView = context.learnerProfileView; - - LearnerProfileHelpers.expectTabbedViewToBeUndefined(requests, tabbedView); - LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); - }); - - it('renders the mode toggle if there are badges', function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: true}), - tabbedView = context.learnerProfileView.tabbedView; - - AjaxHelpers.expectRequest(requests, 'POST', '/event'); - AjaxHelpers.respondWithError(requests, 404); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); - - LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView); - }); - - it('renders the mode toggle if badges enabled but none exist', function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: true}), - tabbedView = context.learnerProfileView.tabbedView; - - AjaxHelpers.expectRequest(requests, 'POST', '/event'); - AjaxHelpers.respondWithError(requests, 404); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges); - - LearnerProfileHelpers.expectTabbedViewToBeShown(tabbedView); - }); - - it('displays the badges when the accomplishments toggle is selected', function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: true}), - learnerProfileView = context.learnerProfileView, - tabbedView = learnerProfileView.tabbedView; - - AjaxHelpers.expectRequest(requests, 'POST', '/event'); - AjaxHelpers.respondWithError(requests, 404); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); - - LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); - tabbedView.$el.find('[data-url="accomplishments"]').click(); - LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); - tabbedView.$el.find('[data-url="about_me"]').click(); - LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); - }); - - it('displays a placeholder on the last page of badges', function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: true}), - learnerProfileView = context.learnerProfileView, - tabbedView = learnerProfileView.tabbedView; - - AjaxHelpers.expectRequest(requests, 'POST', '/event'); - AjaxHelpers.respondWithError(requests, 404); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges); - - LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); - tabbedView.$el.find('[data-url="accomplishments"]').click(); - LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true); - tabbedView.$el.find('[data-url="about_me"]').click(); - LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); - }); - - it('displays a placeholder when the accomplishments toggle is selected and no badges exist', function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: true}), - learnerProfileView = context.learnerProfileView, - tabbedView = learnerProfileView.tabbedView; - - AjaxHelpers.expectRequest(requests, 'POST', '/event'); - AjaxHelpers.respondWithError(requests, 404); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.emptyBadges); - - LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); - tabbedView.$el.find('[data-url="accomplishments"]').click(); - LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 0, true); - tabbedView.$el.find('[data-url="about_me"]').click(); - LearnerProfileHelpers.expectBadgesHidden(learnerProfileView); - }); - - it('shows a paginated list of badges', function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: true}), - learnerProfileView = context.learnerProfileView, - tabbedView = learnerProfileView.tabbedView; - - AjaxHelpers.expectRequest(requests, 'POST', '/event'); - AjaxHelpers.respondWithError(requests, 404); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); - - tabbedView.$el.find('[data-url="accomplishments"]').click(); - LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); - LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges); - }); - - it('allows forward and backward navigation of badges', function() { - var requests = AjaxHelpers.requests(this), - context = createProfilePage(true, {accomplishments_shared: true}), - learnerProfileView = context.learnerProfileView, - tabbedView = learnerProfileView.tabbedView, - badgeListContainer = context.badgeListContainer; - - AjaxHelpers.expectRequest(requests, 'POST', '/event'); - AjaxHelpers.respondWithError(requests, 404); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); - - tabbedView.$el.find('[data-url="accomplishments"]').click(); - - badgeListContainer.$el.find('.next-page-link').click(); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); - LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges); - - badgeListContainer.$el.find('.next-page-link').click(); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.thirdPageBadges); - LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, true); - LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.thirdPageBadges); - - badgeListContainer.$el.find('.previous-page-link').click(); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.secondPageBadges); - LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.secondPageBadges); - LearnerProfileHelpers.expectBadgesDisplayed(learnerProfileView, 10, false); - - badgeListContainer.$el.find('.previous-page-link').click(); - AjaxHelpers.respondWithJson(requests, LearnerProfileHelpers.firstPageBadges); - LearnerProfileHelpers.expectPage(learnerProfileView, LearnerProfileHelpers.firstPageBadges); - }); - it('renders the limited profile for under 13 users', function() { var context = createProfilePage( true, diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js deleted file mode 100644 index 20342c4a67..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_container_spec.js +++ /dev/null @@ -1,99 +0,0 @@ -define([ - 'backbone', - 'jquery', - 'underscore', - 'URI', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'edx-ui-toolkit/js/pagination/paging-collection', - 'learner_profile/js/spec_helpers/helpers', - 'learner_profile/js/views/badge_list_container' -], -function(Backbone, $, _, URI, AjaxHelpers, PagingCollection, LearnerProfileHelpers, BadgeListContainer) { - 'use strict'; - - describe('edx.user.BadgeListContainer', function() { - var view; - - var createView = function(requests, pageNum, badgeListObject) { - var BadgeCollection = PagingCollection.extend({ - queryParams: { - currentPage: 'current_page' - } - }); - var badgeCollection = new BadgeCollection(); - var models = []; - var badgeListContainer; - var request; - var path; - badgeCollection.url = '/api/badges/v1/assertions/user/staff/'; - _.each(_.range(badgeListObject.count), function(idx) { - models.push(LearnerProfileHelpers.makeBadge(idx)); - }); - badgeListObject.results = models; // eslint-disable-line no-param-reassign - badgeCollection.setPage(pageNum); - request = AjaxHelpers.currentRequest(requests); - path = new URI(request.url).path(); - expect(path).toBe('/api/badges/v1/assertions/user/staff/'); - AjaxHelpers.respondWithJson(requests, badgeListObject); - badgeListContainer = new BadgeListContainer({ - collection: badgeCollection - - }); - badgeListContainer.render(); - return badgeListContainer; - }; - - afterEach(function() { - view.$el.remove(); - }); - - it('displays all badges', function() { - var requests = AjaxHelpers.requests(this), - badges; - view = createView(requests, 1, { - count: 30, - previous: '/arbitrary/url', - num_pages: 3, - next: null, - start: 20, - current_page: 1, - results: [] - }); - badges = view.$el.find('div.badge-display'); - expect(badges.length).toBe(30); - }); - - it('displays placeholder on last page', function() { - var requests = AjaxHelpers.requests(this), - placeholder; - view = createView(requests, 3, { - count: 30, - previous: '/arbitrary/url', - num_pages: 3, - next: null, - start: 20, - current_page: 3, - results: [] - }); - placeholder = view.$el.find('span.accomplishment-placeholder'); - expect(placeholder.length).toBe(1); - }); - - it('does not display placeholder on first page', function() { - var requests = AjaxHelpers.requests(this), - placeholder; - view = createView(requests, 1, { - count: 30, - previous: '/arbitrary/url', - num_pages: 3, - next: null, - start: 0, - current_page: 1, - results: [] - }); - placeholder = view.$el.find('span.accomplishment-placeholder'); - expect(placeholder.length).toBe(0); - }); - }); -} -); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js deleted file mode 100644 index e8cfd32d4c..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_list_view_spec.js +++ /dev/null @@ -1,81 +0,0 @@ -define([ - 'backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/pagination/paging-collection', - 'learner_profile/js/spec_helpers/helpers', - 'learner_profile/js/views/badge_list_view' -], -function(Backbone, $, _, PagingCollection, LearnerProfileHelpers, BadgeListView) { - 'use strict'; - - describe('edx.user.BadgeListView', function() { - var view; - - var createView = function(badges, pages, page, hasNextPage) { - var badgeCollection = new PagingCollection(); - var models = []; - var badgeList; - badgeCollection.url = '/api/badges/v1/assertions/user/staff/'; - _.each(badges, function(element) { - models.push(new Backbone.Model(element)); - }); - badgeCollection.models = models; - badgeCollection.length = badges.length; - badgeCollection.currentPage = page; - badgeCollection.totalPages = pages; - badgeCollection.hasNextPage = function() { - return hasNextPage; - }; - badgeList = new BadgeListView({ - collection: badgeCollection - - }); - return badgeList; - }; - - afterEach(function() { - view.$el.remove(); - }); - - it('there is a single row if there is only one badge', function() { - var rows; - view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 1, false); - view.render(); - rows = view.$el.find('div.row'); - expect(rows.length).toBe(1); - }); - - it('accomplishments placeholder is visible on a last page', function() { - var placeholder; - view = createView([LearnerProfileHelpers.makeBadge(1)], 2, 2, false); - view.render(); - placeholder = view.$el.find('span.accomplishment-placeholder'); - expect(placeholder.length).toBe(1); - }); - - it('accomplishments placeholder to be not visible on a first page', function() { - var placeholder; - view = createView([LearnerProfileHelpers.makeBadge(1)], 1, 2, true); - view.render(); - placeholder = view.$el.find('span.accomplishment-placeholder'); - expect(placeholder.length).toBe(0); - }); - - it('badges are in two columns (checked by counting rows for a known number of badges)', function() { - var badges = []; - var placeholder; - var rows; - _.each(_.range(4), function(item) { - badges.push(LearnerProfileHelpers.makeBadge(item)); - }); - view = createView(badges, 1, 2, true); - view.render(); - placeholder = view.$el.find('span.accomplishment-placeholder'); - expect(placeholder.length).toBe(0); - rows = view.$el.find('div.row'); - expect(rows.length).toBe(2); - }); - }); -} -); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js deleted file mode 100644 index 8ac88ae0b1..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/badge_view_spec.js +++ /dev/null @@ -1,114 +0,0 @@ -define([ - 'backbone', 'jquery', 'underscore', - 'learner_profile/js/spec_helpers/helpers', - 'learner_profile/js/views/badge_view' -], -function(Backbone, $, _, LearnerProfileHelpers, BadgeView) { - 'use strict'; - - describe('edx.user.BadgeView', function() { - var view, - badge, - testBadgeNameIsDisplayed, - testBadgeIconIsDisplayed; - - var createView = function(ownProfile) { - var options, - testView; - badge = LearnerProfileHelpers.makeBadge(1); - options = { - model: new Backbone.Model(badge), - ownProfile: ownProfile, - badgeMeta: {} - }; - testView = new BadgeView(options); - testView.render(); - $('body').append(testView.$el); - testView.$el.show(); - expect(testView.$el.is(':visible')).toBe(true); - return testView; - }; - - afterEach(function() { - view.$el.remove(); - $('.badges-modal').remove(); - }); - - it('profile of other has no share button', function() { - view = createView(false); - expect(view.context.ownProfile).toBeFalsy(); - expect(view.$el.find('button.share-button').length).toBe(0); - }); - - it('own profile has share button', function() { - view = createView(true); - expect(view.context.ownProfile).toBeTruthy(); - expect(view.$el.find('button.share-button').length).toBe(1); - }); - - it('click on share button calls createModal function', function() { - var shareButton; - view = createView(true); - spyOn(view, 'createModal'); - view.delegateEvents(); - expect(view.context.ownProfile).toBeTruthy(); - shareButton = view.$el.find('button.share-button'); - expect(shareButton.length).toBe(1); - expect(view.createModal).not.toHaveBeenCalled(); - shareButton.click(); - expect(view.createModal).toHaveBeenCalled(); - }); - - it('click on share button calls shows the dialog', function(done) { - var shareButton, - $modalElement; - view = createView(true); - expect(view.context.ownProfile).toBeTruthy(); - shareButton = view.$el.find('button.share-button'); - expect(shareButton.length).toBe(1); - $modalElement = $('.badges-modal'); - expect($modalElement.length).toBe(0); - expect($modalElement.is(':visible')).toBeFalsy(); - shareButton.click(); - // Note: this element should have appeared in the dom during: shareButton.click(); - $modalElement = $('.badges-modal'); - jasmine.waitUntil(function() { - return $modalElement.is(':visible'); - }).always(done); - }); - - testBadgeNameIsDisplayed = function(ownProfile) { - var badgeDiv; - view = createView(ownProfile); - badgeDiv = view.$el.find('.badge-name'); - expect(badgeDiv.length).toBeTruthy(); - expect(badgeDiv.is(':visible')).toBe(true); - expect(_.count(badgeDiv.html(), badge.badge_class.display_name)).toBeTruthy(); - }; - - it('test badge name is displayed for own profile', function() { - testBadgeNameIsDisplayed(true); - }); - - it('test badge name is displayed for other profile', function() { - testBadgeNameIsDisplayed(false); - }); - - testBadgeIconIsDisplayed = function(ownProfile) { - var badgeImg; - view = createView(ownProfile); - badgeImg = view.$el.find('img.badge'); - expect(badgeImg.length).toBe(1); - expect(badgeImg.attr('src')).toEqual(badge.image_url); - }; - - it('test badge icon is displayed for own profile', function() { - testBadgeIconIsDisplayed(true); - }); - - it('test badge icon is displayed for other profile', function() { - testBadgeIconIsDisplayed(false); - }); - }); -} -); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js index 1797b0de05..21d2dd0f5d 100644 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js +++ b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js @@ -15,13 +15,12 @@ define( 'js/student_account/models/user_preferences_model', 'learner_profile/js/views/learner_profile_fields', 'learner_profile/js/views/learner_profile_view', - 'learner_profile/js/views/badge_list_container', 'js/student_account/views/account_settings_fields', 'js/views/message_banner' ], function(gettext, Backbone, $, _, PagingCollection, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, - BadgeListContainer, AccountSettingsFieldViews, MessageBannerView) { + AccountSettingsFieldViews, MessageBannerView) { 'use strict'; describe('edx.user.LearnerProfileView', function() { @@ -132,15 +131,6 @@ define( }) ]; - var badgeCollection = new PagingCollection(); - badgeCollection.url = Helpers.BADGES_API_URL; - - var badgeListContainer = new BadgeListContainer({ - attributes: {class: 'badge-set-display'}, - collection: badgeCollection, - find_courses_url: Helpers.FIND_COURSES_URL - }); - return new LearnerProfileView( { el: $('.wrapper-profile'), @@ -154,7 +144,6 @@ define( profileImageFieldView: profileImageFieldView, sectionOneFieldViews: sectionOneFieldViews, sectionTwoFieldViews: sectionTwoFieldViews, - badgeListContainer: badgeListContainer }); }; @@ -225,16 +214,5 @@ define( Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); }); - - it("renders an error if the badges can't be fetched", function() { - var learnerProfileView = createLearnerProfileView(false, 'all_users', true); - learnerProfileView.options.accountSettingsModel.set({accomplishments_shared: true}); - var requests = AjaxHelpers.requests(this); - - learnerProfileView.render(); - - LearnerProfileHelpers.breakBadgeLoading(learnerProfileView, requests); - LearnerProfileHelpers.expectBadgeLoadingErrorIsRendered(learnerProfileView); - }); }); }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js deleted file mode 100644 index e3d15659ff..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/share_modal_view_spec.js +++ /dev/null @@ -1,63 +0,0 @@ -define( - [ - 'backbone', 'jquery', 'underscore', 'moment', - 'js/spec/student_account/helpers', - 'learner_profile/js/spec_helpers/helpers', - 'learner_profile/js/views/share_modal_view', - 'jquery.simulate' - ], - function(Backbone, $, _, Moment, Helpers, LearnerProfileHelpers, ShareModalView) { - 'use strict'; - - describe('edx.user.ShareModalView', function() { - var keys = $.simulate.keyCode; - - var view; - - var createModalView = function() { - var badge = LearnerProfileHelpers.makeBadge(1); - var context = _.extend(badge, { - created: new Moment(badge.created), - ownProfile: true, - badgeMeta: {} - }); - return new ShareModalView({ - model: new Backbone.Model(context), - shareButton: $('<button/>') - }); - }; - - beforeEach(function() { - view = createModalView(); - // Attach view to document, otherwise click won't work - view.render(); - $('body').append(view.$el); - view.$el.show(); - expect(view.$el.is(':visible')).toBe(true); - }); - - afterEach(function() { - view.$el.remove(); - }); - - it('modal view closes on escape', function() { - spyOn(view, 'close'); - view.delegateEvents(); - expect(view.close).not.toHaveBeenCalled(); - $(view.$el).simulate('keydown', {keyCode: keys.ESCAPE}); - expect(view.close).toHaveBeenCalled(); - }); - - it('modal view closes click on close', function() { - var $closeButton; - spyOn(view, 'close'); - view.delegateEvents(); - $closeButton = view.$el.find('button.close'); - expect($closeButton.length).toBe(1); - expect(view.close).not.toHaveBeenCalled(); - $closeButton.trigger('click'); - expect(view.close).toHaveBeenCalled(); - }); - }); - } -); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js b/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js index 5d4a278907..e1369284a4 100644 --- a/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js +++ b/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js @@ -123,137 +123,11 @@ define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers' expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true); }; - var expectBadgesDisplayed = function(learnerProfileView, length, lastPage) { - var $badgeListingView = $('#tabpanel-accomplishments'), - updatedLength = length, - placeholder; - expect($('#tabpanel-about_me').hasClass('is-hidden')).toBe(true); - expect($badgeListingView.hasClass('is-hidden')).toBe(false); - if (lastPage) { - updatedLength += 1; - placeholder = $badgeListingView.find('.find-course'); - expect(placeholder.length).toBe(1); - expect(placeholder.attr('href')).toBe('/courses/'); - } - expect($badgeListingView.find('.badge-display').length).toBe(updatedLength); - }; - - var expectBadgesHidden = function() { - var $accomplishmentsTab = $('#tabpanel-accomplishments'); - if ($accomplishmentsTab.length) { - // Nonexistence counts as hidden. - expect($('#tabpanel-accomplishments').hasClass('is-hidden')).toBe(true); - } - expect($('#tabpanel-about_me').hasClass('is-hidden')).toBe(false); - }; - - var expectPage = function(learnerProfileView, pageData) { - var $badgeListContainer = $('#tabpanel-accomplishments'); - var index = $badgeListContainer.find('span.search-count').text().trim(); - expect(index).toBe('Showing ' + (pageData.start + 1) + '-' + (pageData.start + pageData.results.length) - + ' out of ' + pageData.count + ' total'); - expect($badgeListContainer.find('.current-page').text()).toBe('' + pageData.current_page); - _.each(pageData.results, function(badge) { - expect($('.badge-display:contains(' + badge.badge_class.display_name + ')').length).toBe(1); - }); - }; - - var expectBadgeLoadingErrorIsRendered = function() { - var errorMessage = $('.badge-set-display').text(); - expect(errorMessage).toBe( - 'Your request could not be completed. Reload the page and try again. If the issue persists, click the ' - + 'Help tab to report the problem.' - ); - }; - - var breakBadgeLoading = function(learnerProfileView, requests) { - var request = AjaxHelpers.currentRequest(requests); - var path = new URI(request.url).path(); - expect(path).toBe('/api/badges/v1/assertions/user/student/'); - AjaxHelpers.respondWithError(requests, 500); - }; - - var firstPageBadges = { - count: 30, - previous: null, - next: '/arbitrary/url', - num_pages: 3, - start: 0, - current_page: 1, - results: [] - }; - - var secondPageBadges = { - count: 30, - previous: '/arbitrary/url', - next: '/arbitrary/url', - num_pages: 3, - start: 10, - current_page: 2, - results: [] - }; - - var thirdPageBadges = { - count: 30, - previous: '/arbitrary/url', - num_pages: 3, - next: null, - start: 20, - current_page: 3, - results: [] - }; - - var emptyBadges = { - count: 0, - previous: null, - num_pages: 1, - results: [] - }; - - function makeBadge(num) { - return { - badge_class: { - slug: 'test_slug_' + num, - issuing_component: 'test_component', - display_name: 'Test Badge ' + num, - course_id: null, - description: "Yay! It's a test badge.", - criteria: 'https://example.com/syllabus', - image_url: 'http://localhost:8000/media/badge_classes/test_lMB9bRw.png' - }, - image_url: 'http://example.com/image.png', - assertion_url: 'http://example.com/example.json', - created_at: '2015-12-03T16:25:57.676113Z' - }; - } - - _.each(_.range(0, 10), function(i) { - firstPageBadges.results.push(makeBadge(i)); - }); - - _.each(_.range(10, 20), function(i) { - secondPageBadges.results.push(makeBadge(i)); - }); - - _.each(_.range(20, 30), function(i) { - thirdPageBadges.results.push(makeBadge(i)); - }); - return { expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered, expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered, expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered, expectTabbedViewToBeUndefined: expectTabbedViewToBeUndefined, - expectTabbedViewToBeShown: expectTabbedViewToBeShown, - expectBadgesDisplayed: expectBadgesDisplayed, - expectBadgesHidden: expectBadgesHidden, - expectBadgeLoadingErrorIsRendered: expectBadgeLoadingErrorIsRendered, - breakBadgeLoading: breakBadgeLoading, - firstPageBadges: firstPageBadges, - secondPageBadges: secondPageBadges, - thirdPageBadges: thirdPageBadges, - emptyBadges: emptyBadges, - expectPage: expectPage, - makeBadge: makeBadge + expectTabbedViewToBeShown: expectTabbedViewToBeShown }; }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js b/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js deleted file mode 100644 index ca68ca8fa9..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js +++ /dev/null @@ -1,35 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -(function(define) { - 'use strict'; - - define( - [ - 'gettext', 'jquery', 'underscore', 'common/js/components/views/paginated_view', - 'learner_profile/js/views/badge_view', 'learner_profile/js/views/badge_list_view', - 'text!learner_profile/templates/badge_list.underscore' - ], - function(gettext, $, _, PaginatedView, BadgeView, BadgeListView, BadgeListTemplate) { - var BadgeListContainer = PaginatedView.extend({ - type: 'badge', - - itemViewClass: BadgeView, - - listViewClass: BadgeListView, - - viewTemplate: BadgeListTemplate, - - isZeroIndexed: true, - - paginationLabel: gettext('Accomplishments Pagination'), - - initialize: function(options) { - BadgeListContainer.__super__.initialize.call(this, options); - this.listView.find_courses_url = options.find_courses_url; - this.listView.badgeMeta = options.badgeMeta; - this.listView.ownProfile = options.ownProfile; - } - }); - - return BadgeListContainer; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js b/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js deleted file mode 100644 index c30ab9c94a..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js +++ /dev/null @@ -1,65 +0,0 @@ -(function(define) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/html-utils', - 'common/js/components/views/list', - 'learner_profile/js/views/badge_view', - 'text!learner_profile/templates/badge_placeholder.underscore' - ], - function(gettext, $, _, HtmlUtils, ListView, BadgeView, badgePlaceholder) { - var BadgeListView = ListView.extend({ - tagName: 'div', - - template: HtmlUtils.template(badgePlaceholder), - - renderCollection: function() { - var self = this, - $row; - - this.$el.empty(); - - // Split into two columns. - this.collection.each(function(badge, index) { - var $item; - if (index % 2 === 0) { - $row = $('<div class="row">'); - this.$el.append($row); - } - $item = new BadgeView({ - model: badge, - badgeMeta: this.badgeMeta, - ownProfile: this.ownProfile - }).render().el; - - if ($row) { - $row.append($item); - } - - this.itemViews.push($item); - }, this); - // Placeholder must always be at the end, and may need a new row. - if (!this.collection.hasNextPage()) { - // find_courses_url set by BadgeListContainer during initialization. - if (this.collection.length % 2 === 0) { - $row = $('<div class="row">'); - this.$el.append($row); - } - - if ($row) { - HtmlUtils.append( - $row, - this.template({find_courses_url: self.find_courses_url}) - ); - } - } - return this; - } - }); - - return BadgeListView; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/badge_view.js b/openedx/features/learner_profile/static/learner_profile/js/views/badge_view.js deleted file mode 100644 index 8543249169..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/badge_view.js +++ /dev/null @@ -1,47 +0,0 @@ -(function(define) { - 'use strict'; - - define( - [ - 'gettext', 'jquery', 'underscore', 'backbone', 'moment', - 'text!learner_profile/templates/badge.underscore', - 'learner_profile/js/views/share_modal_view', - 'edx-ui-toolkit/js/utils/html-utils' - ], - function(gettext, $, _, Backbone, Moment, badgeTemplate, ShareModalView, HtmlUtils) { - var BadgeView = Backbone.View.extend({ - initialize: function(options) { - this.options = _.extend({}, options); - this.context = _.extend(this.options.model.toJSON(), { - created: new Moment(this.options.model.toJSON().created), - ownProfile: options.ownProfile, - badgeMeta: options.badgeMeta - }); - }, - attributes: { - class: 'badge-display' - }, - template: _.template(badgeTemplate), - events: { - 'click .share-button': 'createModal' - }, - createModal: function() { - var modal = new ShareModalView({ - model: new Backbone.Model(this.context), - shareButton: this.shareButton - }); - modal.$el.hide(); - modal.render(); - $('body').append(modal.$el); - modal.$el.fadeIn('short', 'swing', _.bind(modal.ready, modal)); - }, - render: function() { - this.$el.html(HtmlUtils.HTML(this.template(this.context)).toString()); - this.shareButton = this.$el.find('.share-button'); - return this; - } - }); - - return BadgeView; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js b/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js index c75a22db14..74c0e6b819 100644 --- a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js +++ b/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js @@ -59,30 +59,12 @@ $('.wrapper-profile-section-container-one').removeClass('is-hidden'); $('.wrapper-profile-section-container-two').removeClass('is-hidden'); - // Only show accomplishments if this is a full profile + if (this.showFullProfile()) { - $('.learner-achievements').removeClass('is-hidden'); - } else { - $('.learner-achievements').addClass('is-hidden'); - } - - if (this.showFullProfile() && (this.options.accountSettingsModel.get('accomplishments_shared'))) { tabs = [ - {view: this.sectionTwoView, title: gettext('About Me'), url: 'about_me'}, - { - view: this.options.badgeListContainer, - title: gettext('Accomplishments'), - url: 'accomplishments' - } + {view: this.sectionTwoView, title: gettext('About Me'), url: 'about_me'} ]; - // Build the accomplishments Tab and fill with data - this.options.badgeListContainer.collection.fetch().done(function() { - self.options.badgeListContainer.render(); - }).error(function() { - self.options.badgeListContainer.renderError(); - }); - this.tabbedView = new TabbedView({ tabs: tabs, router: this.router, diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/share_modal_view.js b/openedx/features/learner_profile/static/learner_profile/js/views/share_modal_view.js deleted file mode 100644 index c018c3479b..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/share_modal_view.js +++ /dev/null @@ -1,56 +0,0 @@ -(function(define) { - 'use strict'; - - define( - [ - 'gettext', 'jquery', 'underscore', 'backbone', 'moment', - 'text!learner_profile/templates/share_modal.underscore', - 'edx-ui-toolkit/js/utils/html-utils' - ], - function(gettext, $, _, Backbone, Moment, badgeModalTemplate, HtmlUtils) { - var ShareModalView = Backbone.View.extend({ - attributes: { - class: 'badges-overlay' - }, - template: _.template(badgeModalTemplate), - events: { - 'click .badges-modal': function(event) { event.stopPropagation(); }, - 'click .badges-modal .close': 'close', - 'click .badges-overlay': 'close', - keydown: 'keyAction', - 'focus .focusguard-start': 'focusGuardStart', - 'focus .focusguard-end': 'focusGuardEnd' - }, - initialize: function(options) { - this.options = _.extend({}, options); - }, - focusGuardStart: function() { - // Should only be selected directly if shift-tabbing from the start, so grab last item. - this.$el.find('a').last().focus(); - }, - focusGuardEnd: function() { - this.$el.find('.badges-modal').focus(); - }, - close: function() { - this.$el.fadeOut('short', 'swing', _.bind(this.remove, this)); - this.options.shareButton.focus(); - }, - keyAction: function(event) { - if (event.keyCode === $.ui.keyCode.ESCAPE) { - this.close(); - } - }, - ready: function() { - // Focusing on the modal background directly doesn't work, probably due - // to its positioning. - this.$el.find('.badges-modal').focus(); - }, - render: function() { - this.$el.html(HtmlUtils.HTML(this.template(this.model.toJSON())).toString()); - return this; - } - }); - - return ShareModalView; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/templates/badge.underscore b/openedx/features/learner_profile/static/learner_profile/templates/badge.underscore deleted file mode 100644 index 1090149727..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/badge.underscore +++ /dev/null @@ -1,30 +0,0 @@ -<div class="badge-image-container"> - <img class="badge" src="<%- image_url %>" alt=""/> -</div> -<div class="badge-details"> - <div class="badge-name"><%- badge_class.display_name %></div> - <p class="badge-description"><%- badge_class.description %></p> - <% if (ownProfile) { %> - <button class="share-button"> - <div class="share-icon-container"> - <img class="icon icon-mozillaopenbadges" src="<%- badgeMeta.badges_icon %>" alt="<%- - interpolate( - // Translators: display_name is the name of an OpenBadges award. - gettext('Share your "%(display_name)s" award'), - {'display_name': badge_class.display_name}, - true - )%>"> - </div> - <div class="share-prefix" aria-hidden="true"><%- gettext("Share") %></div> - </div> - <% } %> - <div class="badge-date-stamp"> - <%- - interpolate( - // Translators: Date stamp for earned badges. Example: Earned December 3, 2015. - gettext('Earned %(created)s.'), - {created: created.format('LL')}, - true - ) - %></div> -</div> diff --git a/openedx/features/learner_profile/static/learner_profile/templates/badge_list.underscore b/openedx/features/learner_profile/static/learner_profile/templates/badge_list.underscore deleted file mode 100644 index 80c2cc2ca7..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/badge_list.underscore +++ /dev/null @@ -1,4 +0,0 @@ -<div class="sr-is-focusable sr-<%- type %>-view" tabindex="-1"></div> -<div class="<%- type %>-paging-header"></div> -<div class="<%- type %>-list cards-list"></div> -<div class="<%- type %>-paging-footer"></div> diff --git a/openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore b/openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore deleted file mode 100644 index b7eb9976e8..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/badge_placeholder.underscore +++ /dev/null @@ -1,10 +0,0 @@ -<div class="badge-display badge-placeholder"> - <div class="badge-image-container"> - <span class="accomplishment-placeholder" aria-hidden="true"> - </div> - <div class="badge-details"> - <div class="badge-name"><%- gettext("What's Your Next Accomplishment?") %></div> - <p class="badge-description"><%- gettext('Start working toward your next learning goal.') %></p> - <a class="find-course" href="<%- find_courses_url %>"><span class="find-button-container"><%- gettext('Find a course') %></span></a> - </div> -</div> diff --git a/openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore b/openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore deleted file mode 100644 index b71e89823b..0000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/share_modal.underscore +++ /dev/null @@ -1,41 +0,0 @@ -<div class="focusguard focusguard-start" tabindex="0"></div> -<div class="badges-modal" tabindex="0"> - <button class="close"><span class="fa fa-close" aria-hidden="true"></span><span class="sr"><%- gettext("Close") %></span></button> - <h1 class="modal-header"><%- gettext("Share on Mozilla Backpack") %></h1> - <p class="explanation"><%- gettext("To share your certificate on Mozilla Backpack, you must first have a Backpack account. Complete the following steps to add your certificate to Backpack.") %> - </p> - <hr class="modal-hr"/> - <img class="backpack-logo" src="<%- badgeMeta.badges_logo %>" alt=""> - <ol class="badges-steps"> - <li class="step"> - <%= edx.HtmlUtils.interpolateHtml( - gettext("Create a {link_start}Mozilla Backpack{link_end} account, or log in to your existing account"), - { - link_start: edx.HtmlUtils.HTML('<a href="https://backpack.openbadges.org/" rel="noopener" target="_blank">'), - link_end: edx.HtmlUtils.HTML('</a>') - } - ) - %> - </li> - - <li class="step"> - <%= edx.HtmlUtils.interpolateHtml( - gettext("{download_link_start}Download this image (right-click or option-click, save as){link_end} and then {upload_link_start}upload{link_end} it to your backpack."), - { - download_link_start: edx.HtmlUtils.joinHtml( - edx.HtmlUtils.HTML('<a class="badge-link" href="'), - image_url, - edx.HtmlUtils.HTML('" rel="noopener" target="_blank">'), - ), - link_end: edx.HtmlUtils.HTML('</a>'), - upload_link_start: edx.HtmlUtils.HTML('<a href="https://backpack.openbadges.org/backpack/add" rel="noopener" target="_blank">') - } - ) - %> - </li> - </ol> - <div class="image-container"> - <img class="badges-backpack-example" src="<%- badgeMeta.backpack_ui_img %>" alt=""> - </div> -</div> -<div class="focusguard focusguard-end" tabindex="0"></div> \ No newline at end of file diff --git a/openedx/features/learner_profile/views/learner_profile.py b/openedx/features/learner_profile/views/learner_profile.py index e19e6853e8..6a3a251fde 100644 --- a/openedx/features/learner_profile/views/learner_profile.py +++ b/openedx/features/learner_profile/views/learner_profile.py @@ -11,7 +11,6 @@ from django.urls import reverse from django.views.decorators.http import require_http_methods from django_countries import countries -from lms.djangoapps.badges.utils import badges_enabled from common.djangoapps.edxmako.shortcuts import marketing_link from openedx.core.djangoapps.credentials.utils import get_credentials_records_url from openedx.core.djangoapps.programs.models import ProgramsApiConfig @@ -105,8 +104,6 @@ def learner_profile_context(request, profile_username, user_is_staff): 'country_options': list(countries), 'find_courses_url': marketing_link('COURSES'), 'language_options': settings.ALL_LANGUAGES, - 'badges_logo': staticfiles_storage.url('certificates/images/backpack-logo.png'), - 'badges_icon': staticfiles_storage.url('certificates/images/ico-mozillaopenbadges.png'), 'backpack_ui_img': staticfiles_storage.url('certificates/images/backpack-ui.png'), 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), 'social_platforms': settings.SOCIAL_PLATFORMS, @@ -128,7 +125,4 @@ def learner_profile_context(request, profile_username, user_is_staff): ) context['achievements_fragment'] = achievements_fragment - if badges_enabled(): - context['data']['badges_api_url'] = reverse("badges_api:user_assertions", kwargs={'username': profile_username}) - return context diff --git a/openedx/tests/xblock_integration/test_crowdsource_hinter.py b/openedx/tests/xblock_integration/test_crowdsource_hinter.py index a2fffdc6e9..61f1097481 100644 --- a/openedx/tests/xblock_integration/test_crowdsource_hinter.py +++ b/openedx/tests/xblock_integration/test_crowdsource_hinter.py @@ -21,8 +21,8 @@ class TestCrowdsourceHinter(SharedModuleStoreTestCase, LoginEnrollmentTestCase): Create the test environment with the crowdsourcehinter xblock. """ STUDENTS = [ - {'email': 'view@test.com', 'password': 'foo'}, - {'email': 'view2@test.com', 'password': 'foo'} + {'email': 'view@test.com', 'password': SharedModuleStoreTestCase.TEST_PASSWORD}, + {'email': 'view2@test.com', 'password': SharedModuleStoreTestCase.TEST_PASSWORD} ] XBLOCK_NAMES = ['crowdsourcehinter'] diff --git a/openedx/tests/xblock_integration/test_recommender.py b/openedx/tests/xblock_integration/test_recommender.py index a494fc9c13..ae06b09db1 100644 --- a/openedx/tests/xblock_integration/test_recommender.py +++ b/openedx/tests/xblock_integration/test_recommender.py @@ -27,8 +27,8 @@ class TestRecommender(SharedModuleStoreTestCase, LoginEnrollmentTestCase): Check that Recommender state is saved properly """ STUDENTS = [ - {'email': 'view@test.com', 'password': 'foo'}, - {'email': 'view2@test.com', 'password': 'foo'} + {'email': 'view@test.com', 'password': 'Password1234'}, + {'email': 'view2@test.com', 'password': 'Password1234'} ] XBLOCK_NAMES = ['recommender', 'recommender_second'] @@ -141,7 +141,7 @@ class TestRecommender(SharedModuleStoreTestCase, LoginEnrollmentTestCase): Staff login and enroll for the course """ email = staff.email - password = 'test' + password = self.TEST_PASSWORD self.login(email, password) self.enroll(self.course, verify=True) diff --git a/openedx/tests/xblock_integration/xblock_testcase.py b/openedx/tests/xblock_integration/xblock_testcase.py index a6a4ac67a4..7e6d9a9387 100644 --- a/openedx/tests/xblock_integration/xblock_testcase.py +++ b/openedx/tests/xblock_integration/xblock_testcase.py @@ -282,9 +282,9 @@ class XBlockStudentTestCaseMixin: Creates a default set of students for XBlock tests ''' student_list = [ - {'email': 'alice@test.edx.org', 'password': 'foo'}, - {'email': 'bob@test.edx.org', 'password': 'foo'}, - {'email': 'eve@test.edx.org', 'password': 'foo'}, + {'email': 'alice@test.edx.org', 'password': 'Password1234'}, + {'email': 'bob@test.edx.org', 'password': 'Password1234'}, + {'email': 'eve@test.edx.org', 'password': 'Password1234'}, ] def setUp(self): diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 77f60e9937..5e99182b7b 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -26,7 +26,7 @@ django-storages==1.14 # The team that owns this package will manually bump this package rather than having it pulled in automatically. # This is to allow them to better control its deployment and to do it in a process that works better # for them. -edx-enterprise==4.5.0 +edx-enterprise==4.6.7 # django-oauth-toolkit version >=2.0.0 has breaking changes. More details # mentioned on this issue https://github.com/openedx/edx-platform/issues/32884 @@ -121,7 +121,7 @@ libsass==0.10.0 click==8.1.6 # pinning this version to avoid updates while the library is being developed -openedx-learning==0.1.7 +openedx-learning==0.2.5 # lti-consumer-xblock 9.6.2 contains a breaking change that makes # existing custom parameter configurations unusable. diff --git a/requirements/edx-sandbox/py38.txt b/requirements/edx-sandbox/py38.txt index d1cc50e869..c4674d259c 100644 --- a/requirements/edx-sandbox/py38.txt +++ b/requirements/edx-sandbox/py38.txt @@ -20,9 +20,9 @@ cryptography==38.0.4 # via # -c requirements/edx-sandbox/../constraints.txt # -r requirements/edx-sandbox/py38.in -cycler==0.12.0 +cycler==0.12.1 # via matplotlib -fonttools==4.43.0 +fonttools==4.43.1 # via matplotlib importlib-resources==6.1.0 # via matplotlib @@ -75,7 +75,7 @@ python-dateutil==2.8.2 # via matplotlib random2==1.0.1 # via -r requirements/edx-sandbox/py38.in -regex==2023.8.8 +regex==2023.10.3 # via nltk scipy==1.7.3 # via diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 63dfd49229..1c9d41bbd4 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -4,9 +4,11 @@ # # make upgrade # +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + # via -r requirements/edx/github.in acid-xblock==0.2.1 # via -r requirements/edx/kernel.in -aiohttp==3.8.5 +aiohttp==3.8.6 # via # geoip2 # openai @@ -64,7 +66,7 @@ beautifulsoup4==4.12.2 # via pynliner billiard==4.1.0 # via celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -77,13 +79,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in -boto3==1.28.58 +boto3==1.28.62 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.31.58 +botocore==1.31.62 # via # -r requirements/edx/kernel.in # boto3 @@ -178,7 +180,7 @@ defusedxml==0.7.1 # social-auth-core deprecated==1.2.14 # via jwcrypto -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/kernel.in @@ -424,7 +426,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/kernel.in # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/bundled.in # edx-enterprise @@ -468,7 +470,7 @@ edx-django-utils==5.7.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/edx/kernel.in # edx-completion @@ -480,15 +482,15 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.0 +edx-enterprise==4.6.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in edx-event-bus-kafka==5.5.0 # via -r requirements/edx/kernel.in -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via -r requirements/edx/kernel.in -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via ora2 edx-milestones==0.5.0 # via -r requirements/edx/kernel.in @@ -516,8 +518,6 @@ edx-proctoring==4.16.1 # via # -r requirements/edx/kernel.in # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.2.1 - # via -r requirements/edx/kernel.in edx-rbac==1.8.0 # via edx-enterprise edx-rest-api-client==5.6.0 @@ -552,7 +552,7 @@ edx-when==2.4.0 # via # -r requirements/edx/kernel.in # edx-proctoring -edxval==2.4.3 +edxval==2.4.4 # via -r requirements/edx/kernel.in elasticsearch==7.13.4 # via @@ -567,7 +567,7 @@ event-tracking==2.2.0 # -r requirements/edx/kernel.in # edx-proctoring # edx-search -fastavro==1.8.3 +fastavro==1.8.4 # via openedx-events filelock==3.12.4 # via snowflake-connector-python @@ -681,6 +681,7 @@ lti-consumer-xblock==9.6.1 lxml==4.9.3 # via # -r requirements/edx/kernel.in + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -696,6 +697,7 @@ mako==1.2.4 # -r requirements/edx/kernel.in # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -774,7 +776,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.0.3 # via -r requirements/edx/kernel.in -openedx-events==8.8.0 +openedx-events==9.0.0 # via # -r requirements/edx/kernel.in # edx-event-bus-kafka @@ -783,7 +785,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/kernel.in # lti-consumer-xblock -openedx-learning==0.1.7 +openedx-learning==0.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -791,7 +793,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/kernel.in optimizely-sdk==4.1.1 # via -r requirements/edx/bundled.in -ora2==5.5.3 +ora2==5.5.5 # via -r requirements/edx/bundled.in oscrypto==1.3.0 # via snowflake-connector-python @@ -833,7 +835,7 @@ pillow==9.5.0 # edxval pkgutil-resolve-name==1.3.10 # via jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via snowflake-connector-python polib==1.2.0 # via edx-i18n-tools @@ -843,7 +845,7 @@ psutil==5.9.5 # via # -r requirements/edx/paver.txt # edx-django-utils -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in @@ -937,7 +939,7 @@ python3-openid==3.2.0 ; python_version >= "3" # via # -r requirements/edx/kernel.in # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via -r requirements/edx/kernel.in pytz==2022.7.1 # via @@ -984,7 +986,7 @@ referencing==0.30.2 # via # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via nltk requests==2.31.0 # via @@ -1013,13 +1015,13 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/kernel.in # social-auth-core -rpds-py==0.10.3 +rpds-py==0.10.4 # via # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.35 # via drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via ruamel-yaml rules==3.3 # via @@ -1040,11 +1042,12 @@ semantic-version==2.10.0 # via edx-drf-extensions shapely==2.0.1 # via -r requirements/edx/kernel.in -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/kernel.in # sailthru-client # super-csv + # xblock # xblock-utils six==1.16.0 # via @@ -1081,7 +1084,7 @@ slumber==0.7.1 # edx-bulk-grades # edx-enterprise # edx-rest-api-client -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via edx-enterprise social-auth-app-django==5.0.0 # via @@ -1094,7 +1097,7 @@ social-auth-core==4.3.0 # -r requirements/edx/kernel.in # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/kernel.in # openedx-django-wiki @@ -1128,7 +1131,7 @@ testfixtures==7.2.0 # via edx-enterprise text-unidecode==1.3 # via python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via bleach tomlkit==0.12.1 # via snowflake-connector-python @@ -1203,7 +1206,7 @@ wrapt==1.15.0 # via # -r requirements/edx/paver.txt # deprecated -xblock[django]==1.8.0 +xblock[django]==1.8.1 # via # -r requirements/edx/kernel.in # acid-xblock @@ -1224,9 +1227,8 @@ xblock-google-drive==0.4.0 # via -r requirements/edx/bundled.in xblock-poll==1.13.0 # via -r requirements/edx/bundled.in -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via - # -r requirements/edx/kernel.in # done-xblock # edx-sga # lti-consumer-xblock diff --git a/requirements/edx/bundled.in b/requirements/edx/bundled.in index e115f667df..b8ada003ca 100644 --- a/requirements/edx/bundled.in +++ b/requirements/edx/bundled.in @@ -20,7 +20,11 @@ # 4. If the package is not needed in production, add it to another file such # as development.in or testing.in instead. -py2neo # Driver for converting Python modulestore structures to Neo4j's schema (for Coursegraph). +# Driver for converting Python modulestore structures to Neo4j's schema (for Coursegraph). +# Using the fork because official package has been removed from PyPI/GitHub +# Follow up issue to remove this fork: https://github.com/openedx/edx-platform/issues/33456 +https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz + pygments # Used to support colors in paver command output ## Third party integrations diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index 0f42820cfe..ab4954475c 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -4,6 +4,10 @@ # # make upgrade # +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt accessible-pygments==0.0.4 # via # -r requirements/edx/doc.txt @@ -12,7 +16,7 @@ acid-xblock==0.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -46,7 +50,7 @@ aniso8601==9.0.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-tincan-py35 -annotated-types==0.5.0 +annotated-types==0.6.0 # via # -r requirements/edx/testing.txt # pydantic @@ -128,7 +132,7 @@ billiard==4.1.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -143,14 +147,14 @@ boto==2.39.0 # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.28.58 +boto3==1.28.62 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.58 +botocore==1.31.62 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -333,7 +337,7 @@ distlib==0.3.7 # via # -r requirements/edx/testing.txt # virtualenv -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/doc.txt @@ -679,7 +683,7 @@ edx-auth-backends==4.2.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -735,7 +739,7 @@ edx-django-utils==5.7.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -748,7 +752,7 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.0 +edx-enterprise==4.6.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -757,11 +761,11 @@ edx-event-bus-kafka==5.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -801,10 +805,6 @@ edx-proctoring==4.16.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.2.1 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt edx-rbac==1.8.0 # via # -r requirements/edx/doc.txt @@ -855,7 +855,7 @@ edx-when==2.4.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edx-proctoring -edxval==2.4.3 +edxval==2.4.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -891,7 +891,7 @@ execnet==2.0.2 # pytest-xdist factory-boy==3.3.0 # via -r requirements/edx/testing.txt -faker==19.6.2 +faker==19.9.0 # via # -r requirements/edx/testing.txt # factory-boy @@ -899,7 +899,7 @@ fastapi==0.103.2 # via # -r requirements/edx/testing.txt # pact-python -fastavro==1.8.3 +fastavro==1.8.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1143,6 +1143,7 @@ lxml==4.9.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -1162,6 +1163,7 @@ mako==1.2.4 # -r requirements/edx/testing.txt # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -1224,7 +1226,7 @@ multidict==6.0.4 # -r requirements/edx/testing.txt # aiohttp # yarl -mypy==1.5.1 +mypy==1.6.0 # via # -r requirements/edx/development.in # django-stubs @@ -1302,7 +1304,7 @@ openedx-django-wiki==2.0.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==8.8.0 +openedx-events==9.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1313,7 +1315,7 @@ openedx-filters==1.6.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # lti-consumer-xblock -openedx-learning==0.1.7 +openedx-learning==0.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -1326,7 +1328,7 @@ optimizely-sdk==4.1.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -ora2==5.5.3 +ora2==5.5.5 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1406,7 +1408,7 @@ pkgutil-resolve-name==1.3.10 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1440,7 +1442,7 @@ py==1.11.0 # via # -r requirements/edx/testing.txt # tox -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -1657,7 +1659,7 @@ python3-openid==3.2.0 ; python_version >= "3" # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1720,7 +1722,7 @@ referencing==0.30.2 # -r requirements/edx/testing.txt # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1761,18 +1763,18 @@ rfc3986[idna2008]==1.5.0 # via # -r requirements/edx/testing.txt # httpx -rpds-py==0.10.3 +rpds-py==0.10.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.35 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1810,12 +1812,13 @@ shapely==2.0.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # sailthru-client # super-csv + # xblock # xblock-utils singledispatch==4.1.0 # via -r requirements/edx/testing.txt @@ -1875,7 +1878,7 @@ snowballstemmer==2.2.0 # via # -r requirements/edx/doc.txt # sphinx -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1893,7 +1896,7 @@ social-auth-core==4.3.0 # -r requirements/edx/testing.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2004,7 +2007,7 @@ text-unidecode==1.3 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2126,7 +2129,7 @@ vine==5.0.0 # amqp # celery # kombu -virtualenv==20.24.1 +virtualenv==20.24.5 # via # -r requirements/edx/testing.txt # tox @@ -2135,7 +2138,7 @@ voluptuous==0.13.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -vulture==2.9.1 +vulture==2.10 # via -r requirements/edx/development.in walrus==0.9.3 # via @@ -2183,7 +2186,7 @@ wrapt==1.15.0 # -r requirements/edx/testing.txt # astroid # deprecated -xblock[django]==1.8.0 +xblock[django]==1.8.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2211,7 +2214,7 @@ xblock-poll==1.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 2ccab0b634..fef17b68d5 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -4,11 +4,13 @@ # # make upgrade # +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + # via -r requirements/edx/base.txt accessible-pygments==0.0.4 # via pydata-sphinx-theme acid-xblock==0.2.1 # via -r requirements/edx/base.txt -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/edx/base.txt # geoip2 @@ -90,7 +92,7 @@ billiard==4.1.0 # via # -r requirements/edx/base.txt # celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -103,13 +105,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -boto3==1.28.58 +boto3==1.28.62 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.58 +botocore==1.31.62 # via # -r requirements/edx/base.txt # boto3 @@ -225,7 +227,7 @@ deprecated==1.2.14 # via # -r requirements/edx/base.txt # jwcrypto -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt @@ -500,7 +502,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/base.txt # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/base.txt # edx-enterprise @@ -544,7 +546,7 @@ edx-django-utils==5.7.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/edx/base.txt # edx-completion @@ -556,15 +558,15 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.0 +edx-enterprise==4.6.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt edx-event-bus-kafka==5.5.0 # via -r requirements/edx/base.txt -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via -r requirements/edx/base.txt -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via # -r requirements/edx/base.txt # ora2 @@ -593,8 +595,6 @@ edx-proctoring==4.16.1 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.2.1 - # via -r requirements/edx/base.txt edx-rbac==1.8.0 # via # -r requirements/edx/base.txt @@ -633,7 +633,7 @@ edx-when==2.4.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.4.3 +edxval==2.4.4 # via -r requirements/edx/base.txt elasticsearch==7.13.4 # via @@ -651,7 +651,7 @@ event-tracking==2.2.0 # -r requirements/edx/base.txt # edx-proctoring # edx-search -fastavro==1.8.3 +fastavro==1.8.4 # via # -r requirements/edx/base.txt # openedx-events @@ -805,6 +805,7 @@ lti-consumer-xblock==9.6.1 lxml==4.9.3 # via # -r requirements/edx/base.txt + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -820,6 +821,7 @@ mako==1.2.4 # -r requirements/edx/base.txt # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -914,7 +916,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.0.3 # via -r requirements/edx/base.txt -openedx-events==8.8.0 +openedx-events==9.0.0 # via # -r requirements/edx/base.txt # edx-event-bus-kafka @@ -923,7 +925,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock -openedx-learning==0.1.7 +openedx-learning==0.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -931,7 +933,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/base.txt optimizely-sdk==4.1.1 # via -r requirements/edx/base.txt -ora2==5.5.3 +ora2==5.5.5 # via -r requirements/edx/base.txt oscrypto==1.3.0 # via @@ -986,7 +988,7 @@ pkgutil-resolve-name==1.3.10 # via # -r requirements/edx/base.txt # jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/edx/base.txt # snowflake-connector-python @@ -1002,7 +1004,7 @@ psutil==5.9.5 # via # -r requirements/edx/base.txt # edx-django-utils -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1117,7 +1119,7 @@ python3-openid==3.2.0 ; python_version >= "3" # via # -r requirements/edx/base.txt # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via -r requirements/edx/base.txt pytz==2022.7.1 # via @@ -1166,7 +1168,7 @@ referencing==0.30.2 # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via # -r requirements/edx/base.txt # nltk @@ -1198,16 +1200,16 @@ requests-oauthlib==1.3.1 # via # -r requirements/edx/base.txt # social-auth-core -rpds-py==0.10.3 +rpds-py==0.10.4 # via # -r requirements/edx/base.txt # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.35 # via # -r requirements/edx/base.txt # drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via # -r requirements/edx/base.txt # ruamel-yaml @@ -1237,11 +1239,12 @@ semantic-version==2.10.0 # edx-drf-extensions shapely==2.0.1 # via -r requirements/edx/base.txt -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/base.txt # sailthru-client # super-csv + # xblock # xblock-utils six==1.16.0 # via @@ -1283,7 +1286,7 @@ smmap==5.0.1 # via gitdb snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1298,7 +1301,7 @@ social-auth-core==4.3.0 # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/base.txt # openedx-django-wiki @@ -1377,7 +1380,7 @@ text-unidecode==1.3 # via # -r requirements/edx/base.txt # python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via # -r requirements/edx/base.txt # bleach @@ -1468,7 +1471,7 @@ wrapt==1.15.0 # via # -r requirements/edx/base.txt # deprecated -xblock[django]==1.8.0 +xblock[django]==1.8.1 # via # -r requirements/edx/base.txt # acid-xblock @@ -1489,7 +1492,7 @@ xblock-google-drive==0.4.0 # via -r requirements/edx/base.txt xblock-poll==1.13.0 # via -r requirements/edx/base.txt -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via # -r requirements/edx/base.txt # done-xblock diff --git a/requirements/edx/github.in b/requirements/edx/github.in index d36b6f9629..ea6d47eec8 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -86,3 +86,7 @@ ############################################################################## # ... add dependencies here + +# django42 support PR merged but new release is pending. +# https://github.com/openedx/edx-platform/issues/33431 +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index fb0307ba43..1c727d2f9c 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -81,7 +81,8 @@ edx-name-affirmation edx-opaque-keys edx-organizations edx-proctoring>=2.0.1 -edx-proctoring-proctortrack==1.2.1 # Intentionally and permanently pinned to ensure code changes are reviewed +# using hash to support django42 +# edx-proctoring-proctortrack==1.2.1 # Intentionally and permanently pinned to ensure code changes are reviewed edx-rest-api-client edx-search edx-submissions @@ -157,5 +158,4 @@ user-util # Functionality for retiring users (GDPR com webob web-fragments # Provides the ability to render fragments of web pages XBlock[django] # Courseware component architecture -xblock-utils # Provides utilities used by the Discussion XBlock xss-utils # https://github.com/edx/edx-platform/pull/20633 Fix XSS via Translations diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index fb696264ac..31dcbbf984 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -70,15 +70,15 @@ requests==2.31.0 # via semgrep rich==13.6.0 # via semgrep -rpds-py==0.10.3 +rpds-py==0.10.4 # via # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.35 # via semgrep -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via ruamel-yaml -semgrep==1.42.0 +semgrep==1.43.0 # via -r requirements/edx/semgrep.in tomli==2.0.1 # via semgrep diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 5dbcd9c8d8..b43ae8bb3e 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -4,9 +4,11 @@ # # make upgrade # +-e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + # via -r requirements/edx/base.txt acid-xblock==0.2.1 # via -r requirements/edx/base.txt -aiohttp==3.8.5 +aiohttp==3.8.6 # via # -r requirements/edx/base.txt # geoip2 @@ -29,7 +31,7 @@ aniso8601==9.0.1 # via # -r requirements/edx/base.txt # edx-tincan-py35 -annotated-types==0.5.0 +annotated-types==0.6.0 # via pydantic anyio==3.7.1 # via @@ -95,7 +97,7 @@ billiard==4.1.0 # via # -r requirements/edx/base.txt # celery -bleach[css]==6.0.0 +bleach[css]==6.1.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -108,13 +110,13 @@ boto==2.39.0 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt -boto3==1.28.58 +boto3==1.28.62 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.31.58 +botocore==1.31.62 # via # -r requirements/edx/base.txt # boto3 @@ -256,7 +258,7 @@ dill==0.3.7 # via pylint distlib==0.3.7 # via virtualenv -django==3.2.21 +django==3.2.22 # via # -c requirements/edx/../common_constraints.txt # -r requirements/edx/base.txt @@ -525,7 +527,7 @@ edx-auth-backends==4.2.0 # via # -r requirements/edx/base.txt # openedx-blockstore -edx-braze-client==0.1.7 +edx-braze-client==0.1.8 # via # -r requirements/edx/base.txt # edx-enterprise @@ -569,7 +571,7 @@ edx-django-utils==5.7.0 # openedx-blockstore # ora2 # super-csv -edx-drf-extensions==8.10.0 +edx-drf-extensions==8.12.0 # via # -r requirements/edx/base.txt # edx-completion @@ -581,15 +583,15 @@ edx-drf-extensions==8.10.0 # edx-when # edxval # openedx-learning -edx-enterprise==4.5.0 +edx-enterprise==4.6.7 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt edx-event-bus-kafka==5.5.0 # via -r requirements/edx/base.txt -edx-event-bus-redis==0.3.1 +edx-event-bus-redis==0.3.2 # via -r requirements/edx/base.txt -edx-i18n-tools==1.2.0 +edx-i18n-tools==1.3.0 # via # -r requirements/edx/base.txt # -r requirements/edx/testing.in @@ -621,8 +623,6 @@ edx-proctoring==4.16.1 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack -edx-proctoring-proctortrack==1.2.1 - # via -r requirements/edx/base.txt edx-rbac==1.8.0 # via # -r requirements/edx/base.txt @@ -661,7 +661,7 @@ edx-when==2.4.0 # via # -r requirements/edx/base.txt # edx-proctoring -edxval==2.4.3 +edxval==2.4.4 # via -r requirements/edx/base.txt elasticsearch==7.13.4 # via @@ -687,11 +687,11 @@ execnet==2.0.2 # via pytest-xdist factory-boy==3.3.0 # via -r requirements/edx/testing.in -faker==19.6.2 +faker==19.9.0 # via factory-boy fastapi==0.103.2 # via pact-python -fastavro==1.8.3 +fastavro==1.8.4 # via # -r requirements/edx/base.txt # openedx-events @@ -867,6 +867,7 @@ lti-consumer-xblock==9.6.1 lxml==4.9.3 # via # -r requirements/edx/base.txt + # edx-i18n-tools # edxval # lti-consumer-xblock # olxcleaner @@ -883,6 +884,7 @@ mako==1.2.4 # -r requirements/edx/base.txt # acid-xblock # lti-consumer-xblock + # xblock # xblock-google-drive # xblock-utils markdown==3.3.7 @@ -978,7 +980,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.0.3 # via -r requirements/edx/base.txt -openedx-events==8.8.0 +openedx-events==9.0.0 # via # -r requirements/edx/base.txt # edx-event-bus-kafka @@ -987,7 +989,7 @@ openedx-filters==1.6.0 # via # -r requirements/edx/base.txt # lti-consumer-xblock -openedx-learning==0.1.7 +openedx-learning==0.2.5 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -995,7 +997,7 @@ openedx-mongodbproxy==0.2.0 # via -r requirements/edx/base.txt optimizely-sdk==4.1.1 # via -r requirements/edx/base.txt -ora2==5.5.3 +ora2==5.5.5 # via -r requirements/edx/base.txt oscrypto==1.3.0 # via @@ -1050,7 +1052,7 @@ pkgutil-resolve-name==1.3.10 # via # -r requirements/edx/base.txt # jsonschema -platformdirs==3.8.1 +platformdirs==3.11.0 # via # -r requirements/edx/base.txt # pylint @@ -1079,7 +1081,7 @@ psutil==5.9.5 # pytest-xdist py==1.11.0 # via tox -py2neo==2021.2.3 +py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo-2021.2.3.tar.gz # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1247,7 +1249,7 @@ python3-openid==3.2.0 ; python_version >= "3" # via # -r requirements/edx/base.txt # social-auth-core -python3-saml==1.15.0 +python3-saml==1.16.0 # via -r requirements/edx/base.txt pytz==2022.7.1 # via @@ -1295,7 +1297,7 @@ referencing==0.30.2 # -r requirements/edx/base.txt # jsonschema # jsonschema-specifications -regex==2023.8.8 +regex==2023.10.3 # via # -r requirements/edx/base.txt # nltk @@ -1329,16 +1331,16 @@ requests-oauthlib==1.3.1 # social-auth-core rfc3986[idna2008]==1.5.0 # via httpx -rpds-py==0.10.3 +rpds-py==0.10.4 # via # -r requirements/edx/base.txt # jsonschema # referencing -ruamel-yaml==0.17.33 +ruamel-yaml==0.17.35 # via # -r requirements/edx/base.txt # drf-yasg -ruamel-yaml-clib==0.2.7 +ruamel-yaml-clib==0.2.8 # via # -r requirements/edx/base.txt # ruamel-yaml @@ -1368,11 +1370,12 @@ semantic-version==2.10.0 # edx-drf-extensions shapely==2.0.1 # via -r requirements/edx/base.txt -simplejson==3.19.1 +simplejson==3.19.2 # via # -r requirements/edx/base.txt # sailthru-client # super-csv + # xblock # xblock-utils singledispatch==4.1.0 # via -r requirements/edx/testing.in @@ -1419,7 +1422,7 @@ sniffio==1.3.0 # anyio # httpcore # httpx -snowflake-connector-python==3.2.0 +snowflake-connector-python==3.2.1 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1434,7 +1437,7 @@ social-auth-core==4.3.0 # -r requirements/edx/base.txt # edx-auth-backends # social-auth-app-django -sorl-thumbnail==12.9.0 +sorl-thumbnail==12.10.0 # via # -r requirements/edx/base.txt # openedx-django-wiki @@ -1480,7 +1483,7 @@ text-unidecode==1.3 # via # -r requirements/edx/base.txt # python-slugify -tinycss2==1.1.1 +tinycss2==1.2.1 # via # -r requirements/edx/base.txt # bleach @@ -1565,7 +1568,7 @@ vine==5.0.0 # amqp # celery # kombu -virtualenv==20.24.1 +virtualenv==20.24.5 # via tox voluptuous==0.13.1 # via @@ -1604,7 +1607,7 @@ wrapt==1.15.0 # -r requirements/edx/base.txt # astroid # deprecated -xblock[django]==1.8.0 +xblock[django]==1.8.1 # via # -r requirements/edx/base.txt # acid-xblock @@ -1625,7 +1628,7 @@ xblock-google-drive==0.4.0 # via -r requirements/edx/base.txt xblock-poll==1.13.0 # via -r requirements/edx/base.txt -xblock-utils==3.4.1 +xblock-utils==4.0.0 # via # -r requirements/edx/base.txt # done-xblock diff --git a/scripts/copy-node-modules.sh b/scripts/copy-node-modules.sh index db997da957..f49514b6de 100755 --- a/scripts/copy-node-modules.sh +++ b/scripts/copy-node-modules.sh @@ -26,28 +26,33 @@ log ( ) { echo -e "${COL_LOG}$* $COL_OFF" } +# Print a command (prefixed with '+') and then run it, to aid in debugging. +# This is just like `set -x`, except that `set -x` prints to STDERR, which is hidden +# by GitHub Actions logs. This functions prints to STDOUT, which is visible. +log_and_run ( ) { + log "+$*" + "$@" # Joins arguments to form a command (quoting as necessary) and runs the command. +} + log "=====================================================================================" log "Copying required assets from node_modules..." log "-------------------------------------------------------------------------------" -# Start echoing all commands back to user for ease of debugging. -set -x - log "Ensuring vendor directories exist..." -mkdir -p "$vendor_js" -mkdir -p "$vendor_css" +log_and_run mkdir -p "$vendor_js" +log_and_run mkdir -p "$vendor_css" log "Copying studio-frontend JS & CSS from node_modules into vendor directores..." while read -r -d $'\0' src_file ; do if [[ "$src_file" = *.css ]] || [[ "$src_file" = *.css.map ]] ; then - cp --force "$src_file" "$vendor_css" + log_and_run cp --force "$src_file" "$vendor_css" else - cp --force "$src_file" "$vendor_js" + log_and_run cp --force "$src_file" "$vendor_js" fi done < <(find "$node_modules/@edx/studio-frontend/dist" -type f -print0) log "Copying certain JS modules from node_modules into vendor directory..." -cp --force \ +log_and_run cp --force \ "$node_modules/backbone.paginator/lib/backbone.paginator.js" \ "$node_modules/backbone/backbone.js" \ "$node_modules/bootstrap/dist/js/bootstrap.bundle.js" \ @@ -66,8 +71,8 @@ cp --force \ log "Copying certain JS developer modules into vendor directory..." if [[ "${NODE_ENV:-production}" = development ]] ; then - cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" - cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" + log_and_run cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" + log_and_run cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" else # TODO: https://github.com/openedx/edx-platform/issues/31768 # In the old implementation of this scipt (pavelib/assets.py), these two @@ -77,13 +82,10 @@ else # However, in the future, it would be good to only copy them for dev # builds. Furthermore, these libraries should not be `npm install`ed # into prod builds in the first place. - cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" || true # "|| true" means "tolerate errors"; in this case, - cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" || true # that's "tolerate if these files don't exist." + log_and_run cp --force "$node_modules/sinon/pkg/sinon.js" "$vendor_js" || true # "|| true" means "tolerate errors"; in this case, + log_and_run cp --force "$node_modules/squirejs/src/Squire.js" "$vendor_js" || true # that's "tolerate if these files don't exist." fi -# Done echoing. -set +x - log "-------------------------------------------------------------------------------" log " Done copying required assets from node_modules." log "=====================================================================================" diff --git a/webpack-config/file-lists.js b/webpack-config/file-lists.js index ddb9cf4f78..7167a6f5dd 100644 --- a/webpack-config/file-lists.js +++ b/webpack-config/file-lists.js @@ -99,9 +99,6 @@ module.exports = { '../openedx/features/course_search/static/course_search/js/views/dashboard_search_results_view.js' ), path.resolve(__dirname, '../openedx/features/course_search/static/course_search/js/views/search_results_view.js'), - path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_list_container.js'), - path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_list_view.js'), - path.resolve(__dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/badge_view.js'), path.resolve( __dirname, '../openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js' diff --git a/xmodule/capa/safe_exec/tests/test_safe_exec.py b/xmodule/capa/safe_exec/tests/test_safe_exec.py index 0f6429ec5e..fa12e4f699 100644 --- a/xmodule/capa/safe_exec/tests/test_safe_exec.py +++ b/xmodule/capa/safe_exec/tests/test_safe_exec.py @@ -18,6 +18,7 @@ from django.test import override_settings from six import unichr from six.moves import range +from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.capa.safe_exec import safe_exec, update_hash from xmodule.capa.safe_exec.remote_exec import is_codejail_rest_service_enabled @@ -81,17 +82,30 @@ class TestSafeExec(unittest.TestCase): # lint-amnesty, pylint: disable=missing- class TestSafeOrNot(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring + + @skip_unless_lms def test_cant_do_something_forbidden(self): ''' Demonstrates that running unsafe code inside the code jail throws SafeExecException, protecting the calling process. + + This test generally is skipped in CI due to its complex setup. That said, we recommend that devs who are + hacking on CodeJail or advanced CAPA in any significant way take the time to make sure it passes locally. + See either: + * in-platform setup: https://github.com/openedx/edx-platform/blob/master/xmodule/capa/safe_exec/README.rst + * remote setup (using Tutor): https://github.com/eduNEXT/tutor-contrib-codejail + + Note on @skip_unless_lms: + This test can also be run in a CMS context, but that's giving us trouble in CI right now (the skip logic isn't + working). So, if you're running this locally, feel free to remove @skip_unless_lms and run it against CMS too. ''' - # Can't test for forbiddenness if CodeJail isn't configured for python. + # If in-platform codejail isn't configured... if not jail_code.is_configured("python"): - # Can't test for forbiddenness if CodeJail rest service isn't enabled. - # Remote codejailservice must be running, see https://github.com/eduNEXT/tutor-contrib-codejail/ + # ...AND if remote codejail isn't configured... if not is_codejail_rest_service_enabled(): + + # ...then skip this test. pytest.skip(reason="Local or remote codejail has to be configured and enabled to run this test.") g = {} diff --git a/xmodule/capa/tests/test_xqueue_interface.py b/xmodule/capa/tests/test_xqueue_interface.py index 5fd913a15e..819fd73c79 100644 --- a/xmodule/capa/tests/test_xqueue_interface.py +++ b/xmodule/capa/tests/test_xqueue_interface.py @@ -8,9 +8,11 @@ from django.test.utils import override_settings from opaque_keys.edx.locator import BlockUsageLocator, CourseLocator from xblock.fields import ScopeIds +from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.capa.xqueue_interface import XQueueInterface, XQueueService +@skip_unless_lms class XQueueServiceTest(TestCase): """Test the XQueue service methods.""" def setUp(self): diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index 67d5fd58c2..7b58b5aa9a 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -504,6 +504,11 @@ class ProblemBlock( ProblemBlock.markdown, ProblemBlock.use_latex_compiler, ProblemBlock.show_correctness, + + # Temporarily remove the ability to see MATLAB API key in Studio, as + # a pre-cursor to removing it altogether. + # https://github.com/openedx/public-engineering/issues/192 + ProblemBlock.matlab_api_key, ]) return non_editable_fields diff --git a/xmodule/course_block.py b/xmodule/course_block.py index bb08e252b0..ae6d88b7b7 100644 --- a/xmodule/course_block.py +++ b/xmodule/course_block.py @@ -619,14 +619,6 @@ class CourseFields: # lint-amnesty, pylint: disable=missing-class-docstring # Ensure that courses imported from XML keep their image default="images_course_image.jpg" ) - issue_badges = Boolean( - display_name=_("Issue Open Badges"), - help=_( - "Issue Open Badges badges for this course. Badges are generated when certificates are created." - ), - scope=Scope.settings, - default=True - ) ## Course level Certificate Name overrides. cert_name_short = String( help=_( diff --git a/xmodule/discussion_block.py b/xmodule/discussion_block.py index b5c5c01907..943c6359dd 100644 --- a/xmodule/discussion_block.py +++ b/xmodule/discussion_block.py @@ -12,8 +12,8 @@ from web_fragments.fragment import Fragment from xblock.completable import XBlockCompletionMode from xblock.core import XBlock from xblock.fields import UNIQUE_ID, Scope, String -from xblockutils.resources import ResourceLoader -from xblockutils.studio_editable import StudioEditableXBlockMixin +from xblock.utils.resources import ResourceLoader +from xblock.utils.studio_editable import StudioEditableXBlockMixin from lms.djangoapps.discussion.django_comment_client.permissions import has_permission from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider diff --git a/xmodule/modulestore/tests/django_utils.py b/xmodule/modulestore/tests/django_utils.py index fcc01763fd..952a88800f 100644 --- a/xmodule/modulestore/tests/django_utils.py +++ b/xmodule/modulestore/tests/django_utils.py @@ -369,7 +369,7 @@ class ModuleStoreTestUsersMixin(): """ A mixin to help manage test users. """ - TEST_PASSWORD = 'test' + TEST_PASSWORD = 'Password1234' def create_user_for_course(self, course, user_type=CourseUserType.ENROLLED): """ diff --git a/xmodule/tests/test_capa_block.py b/xmodule/tests/test_capa_block.py index b511fe403e..ab94028fc9 100644 --- a/xmodule/tests/test_capa_block.py +++ b/xmodule/tests/test_capa_block.py @@ -29,6 +29,7 @@ from xblock.fields import ScopeIds from xblock.scorable import Score import xmodule +from openedx.core.djangolib.testing.utils import skip_unless_lms from xmodule.capa import responsetypes from xmodule.capa.correctmap import CorrectMap from xmodule.capa.responsetypes import LoncapaProblemError, ResponseError, StudentInputError @@ -182,6 +183,7 @@ if submission[0] == '': @ddt.ddt +@skip_unless_lms class ProblemBlockTest(unittest.TestCase): # lint-amnesty, pylint: disable=missing-class-docstring def setUp(self): @@ -2898,6 +2900,7 @@ class ComplexEncoderTest(unittest.TestCase): # lint-amnesty, pylint: disable=mi # ignore quotes +@skip_unless_lms class ProblemCheckTrackingTest(unittest.TestCase): """ Ensure correct tracking information is included in events emitted during problem checks. diff --git a/xmodule/tests/test_library_tools.py b/xmodule/tests/test_library_tools.py index 950307b696..e2daa723cc 100644 --- a/xmodule/tests/test_library_tools.py +++ b/xmodule/tests/test_library_tools.py @@ -1,9 +1,10 @@ """ -Tests for library tools service. +Tests for library tools service (only used by CMS) """ from unittest.mock import patch from opaque_keys.edx.keys import UsageKey +from openedx.core.djangolib.testing.utils import skip_unless_cms from openedx.core.djangoapps.content_libraries import api as library_api from openedx.core.djangoapps.content_libraries.tests.base import ContentLibrariesRestApiTest from openedx.core.djangoapps.xblock.api import load_block @@ -13,6 +14,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, LibraryFactory from xmodule.modulestore.tests.utils import MixedSplitTestCase +@skip_unless_cms class LibraryToolsServiceTest(MixedSplitTestCase): """ Tests for library service. @@ -40,6 +42,7 @@ class LibraryToolsServiceTest(MixedSplitTestCase): assert mock_get_library_summaries.called +@skip_unless_cms class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest): """ Tests for LibraryToolsService which interact with blockstore-based content libraries @@ -63,16 +66,16 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest): course = CourseFactory.create(modulestore=self.store, user_id=self.user.id) CourseInstructorRole(course.id).add_users(self.user) # Add Source from library block to the course - sourced_block = self.make_block("library_sourced", course, user_id=self.user.id) + lc_block = self.make_block("library_content", course, user_id=self.user.id) # Import the unit block from the library to the course - self.tools.import_from_blockstore(sourced_block, [unit_block_id]) + self.tools.import_from_blockstore(lc_block, [unit_block_id]) # Verify imported block with its children - assert len(sourced_block.children) == 1 - assert sourced_block.children[0].category == 'unit' + assert len(lc_block.children) == 1 + assert lc_block.children[0].category == 'unit' - imported_unit_block = self.store.get_item(sourced_block.children[0]) + imported_unit_block = self.store.get_item(lc_block.children[0]) assert len(imported_unit_block.children) == 1 assert imported_unit_block.children[0].category == 'html' @@ -86,10 +89,10 @@ class ContentLibraryToolsTest(MixedSplitTestCase, ContentLibrariesRestApiTest): # Check that reimporting updates the target block self._set_library_block_olx(html_block_id, '<html><a href="/static/test.txt">Foo bar</a></html>') - self.tools.import_from_blockstore(sourced_block, [unit_block_id]) + self.tools.import_from_blockstore(lc_block, [unit_block_id]) - assert len(sourced_block.children) == 1 - imported_unit_block = self.store.get_item(sourced_block.children[0]) + assert len(lc_block.children) == 1 + imported_unit_block = self.store.get_item(lc_block.children[0]) assert len(imported_unit_block.children) == 1 imported_html_block = self.store.get_item(imported_unit_block.children[0]) assert 'Hello world' not in imported_html_block.data