diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index c41fe40b4d2..ce9342849ec 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -58,3 +58,7 @@ lms/templates/dashboard.html @openedx/ax # Ensure minimal.yml stays minimal, this could be a team in the future # but it's just me for now, others can sign up if they care as well. lms/envs/minimal.yml @feanil + +# Ensure that un-necessary changes don't happen to the settings files as we're cleaning them up. +lms/envs/production.py @feanil @kdmccormick +cms/envs/production.py @feanil @kdmccormick diff --git a/.github/workflows/check_python_dependencies.yml b/.github/workflows/check_python_dependencies.yml index 85a4e796ce7..b691e68d4be 100644 --- a/.github/workflows/check_python_dependencies.yml +++ b/.github/workflows/check_python_dependencies.yml @@ -14,18 +14,18 @@ jobs: steps: - name: Checkout Repository uses: actions/checkout@v4 - + - name: Set up Python uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - + - name: Install repo-tools run: pip install edx-repo-tools[find_dependencies] - name: Install setuptool - run: pip install setuptools - + run: pip install setuptools + - name: Run Python script run: | find_python_dependencies \ @@ -35,6 +35,5 @@ jobs: --ignore https://github.com/edx/braze-client \ --ignore https://github.com/edx/edx-name-affirmation \ --ignore https://github.com/mitodl/edx-sga \ - --ignore https://github.com/edx/token-utils \ --ignore https://github.com/open-craft/xblock-poll - + diff --git a/.github/workflows/publish-ci-docker-image.yml b/.github/workflows/publish-ci-docker-image.yml deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/.github/workflows/pylint-checks.yml b/.github/workflows/pylint-checks.yml index 1afa032b6ad..abece08a593 100644 --- a/.github/workflows/pylint-checks.yml +++ b/.github/workflows/pylint-checks.yml @@ -16,7 +16,7 @@ jobs: - module-name: lms-1 path: "lms/djangoapps/badges/ lms/djangoapps/branding/ lms/djangoapps/bulk_email/ lms/djangoapps/bulk_enroll/ lms/djangoapps/bulk_user_retirement/ lms/djangoapps/ccx/ lms/djangoapps/certificates/ lms/djangoapps/commerce/ lms/djangoapps/course_api/ lms/djangoapps/course_blocks/ lms/djangoapps/course_home_api/ lms/djangoapps/course_wiki/ lms/djangoapps/coursewarehistoryextended/ lms/djangoapps/debug/ lms/djangoapps/courseware/ lms/djangoapps/course_goals/ lms/djangoapps/rss_proxy/" - module-name: lms-2 - path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/email_marketing/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py" + path: "lms/djangoapps/gating/ lms/djangoapps/grades/ lms/djangoapps/instructor/ lms/djangoapps/instructor_analytics/ lms/djangoapps/discussion/ lms/djangoapps/edxnotes/ lms/djangoapps/experiments/ lms/djangoapps/instructor_task/ lms/djangoapps/learner_dashboard/ lms/djangoapps/learner_home/ lms/djangoapps/lms_initialization/ lms/djangoapps/lms_xblock/ lms/djangoapps/lti_provider/ lms/djangoapps/mailing/ lms/djangoapps/mobile_api/ lms/djangoapps/monitoring/ lms/djangoapps/ora_staff_grader/ lms/djangoapps/program_enrollments/ lms/djangoapps/rss_proxy lms/djangoapps/static_template_view/ lms/djangoapps/staticbook/ lms/djangoapps/support/ lms/djangoapps/survey/ lms/djangoapps/teams/ lms/djangoapps/tests/ lms/djangoapps/user_tours/ lms/djangoapps/verify_student/ lms/djangoapps/mfe_config_api/ lms/envs/ lms/lib/ lms/tests.py" - module-name: openedx-1 path: "openedx/core/types/ openedx/core/djangoapps/ace_common/ openedx/core/djangoapps/agreements/ openedx/core/djangoapps/api_admin/ openedx/core/djangoapps/auth_exchange/ openedx/core/djangoapps/bookmarks/ openedx/core/djangoapps/cache_toolbox/ openedx/core/djangoapps/catalog/ openedx/core/djangoapps/ccxcon/ openedx/core/djangoapps/commerce/ openedx/core/djangoapps/common_initialization/ openedx/core/djangoapps/common_views/ openedx/core/djangoapps/config_model_utils/ openedx/core/djangoapps/content/ openedx/core/djangoapps/content_libraries/ openedx/core/djangoapps/content_staging/ openedx/core/djangoapps/contentserver/ openedx/core/djangoapps/cookie_metadata/ openedx/core/djangoapps/cors_csrf/ openedx/core/djangoapps/course_apps/ openedx/core/djangoapps/course_date_signals/ openedx/core/djangoapps/course_groups/ openedx/core/djangoapps/courseware_api/ openedx/core/djangoapps/crawlers/ openedx/core/djangoapps/credentials/ openedx/core/djangoapps/credit/ openedx/core/djangoapps/dark_lang/ openedx/core/djangoapps/debug/ openedx/core/djangoapps/discussions/ openedx/core/djangoapps/django_comment_common/ openedx/core/djangoapps/embargo/ openedx/core/djangoapps/enrollments/ openedx/core/djangoapps/external_user_ids/ openedx/core/djangoapps/zendesk_proxy/ openedx/core/djangolib/ openedx/core/lib/ openedx/core/tests/ openedx/core/djangoapps/course_live/" - module-name: openedx-2 diff --git a/.github/workflows/unit-test-shards.json b/.github/workflows/unit-test-shards.json index 4709930493c..0acc1ebdc8f 100644 --- a/.github/workflows/unit-test-shards.json +++ b/.github/workflows/unit-test-shards.json @@ -34,7 +34,6 @@ "paths": [ "lms/djangoapps/discussion/", "lms/djangoapps/edxnotes/", - "lms/djangoapps/email_marketing/", "lms/djangoapps/experiments/" ] }, diff --git a/cms/djangoapps/api/v1/views/course_runs.py b/cms/djangoapps/api/v1/views/course_runs.py index b405207bd9c..7b27193d175 100644 --- a/cms/djangoapps/api/v1/views/course_runs.py +++ b/cms/djangoapps/api/v1/views/course_runs.py @@ -23,6 +23,7 @@ class CourseRunViewSet(viewsets.GenericViewSet): # lint-amnesty, pylint: disabl lookup_value_regex = settings.COURSE_KEY_REGEX permission_classes = (permissions.IsAdminUser,) serializer_class = CourseRunSerializer + queryset = [] def get_object(self): lookup_url_kwarg = self.lookup_url_kwarg or self.lookup_field diff --git a/cms/djangoapps/contentstore/admin.py b/cms/djangoapps/contentstore/admin.py index 5b58c9495f1..0b01abe0507 100644 --- a/cms/djangoapps/contentstore/admin.py +++ b/cms/djangoapps/contentstore/admin.py @@ -13,6 +13,8 @@ from cms.djangoapps.contentstore.models import ( BackfillCourseTabsConfig, CleanStaleCertificateAvailabilityDatesConfig, + LearningContextLinksStatus, + PublishableEntityLink, VideoUploadConfig ) from cms.djangoapps.contentstore.outlines_regenerate import CourseOutlineRegenerate @@ -86,6 +88,71 @@ class CleanStaleCertificateAvailabilityDatesConfigAdmin(ConfigurationModelAdmin) pass +@admin.register(PublishableEntityLink) +class PublishableEntityLinkAdmin(admin.ModelAdmin): + """ + PublishableEntityLink admin. + """ + fields = ( + "uuid", + "upstream_block", + "upstream_usage_key", + "upstream_context_key", + "downstream_usage_key", + "downstream_context_key", + "version_synced", + "version_declined", + "created", + "updated", + ) + readonly_fields = fields + list_display = [ + "upstream_block", + "upstream_usage_key", + "downstream_usage_key", + "version_synced", + "updated", + ] + search_fields = [ + "upstream_usage_key", + "upstream_context_key", + "downstream_usage_key", + "downstream_context_key", + ] + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + +@admin.register(LearningContextLinksStatus) +class LearningContextLinksStatusAdmin(admin.ModelAdmin): + """ + LearningContextLinksStatus admin. + """ + fields = ( + "context_key", + "status", + "created", + "updated", + ) + readonly_fields = ("created", "updated") + list_display = ( + "context_key", + "status", + "created", + "updated", + ) + + def has_add_permission(self, request): + return False + + def has_change_permission(self, request, obj=None): + return False + + admin.site.register(BackfillCourseTabsConfig, ConfigurationModelAdmin) admin.site.register(VideoUploadConfig, ConfigurationModelAdmin) admin.site.register(CourseOutlineRegenerate, CourseOutlineRegenerateAdmin) diff --git a/cms/djangoapps/contentstore/api/views/course_import.py b/cms/djangoapps/contentstore/api/views/course_import.py index dd7828c2d94..3027b1926d0 100644 --- a/cms/djangoapps/contentstore/api/views/course_import.py +++ b/cms/djangoapps/contentstore/api/views/course_import.py @@ -106,7 +106,7 @@ class CourseImportView(CourseImportExportViewMixin, GenericAPIView): # TODO: ARCH-91 # This view is excluded from Swagger doc generation because it # does not specify a serializer class. - exclude_from_schema = True + swagger_schema = None @course_author_access_required def post(self, request, course_key): diff --git a/cms/djangoapps/contentstore/api/views/course_quality.py b/cms/djangoapps/contentstore/api/views/course_quality.py index 8f647accaa9..b301f5ac142 100644 --- a/cms/djangoapps/contentstore/api/views/course_quality.py +++ b/cms/djangoapps/contentstore/api/views/course_quality.py @@ -77,6 +77,11 @@ class CourseQualityView(DeveloperErrorViewMixin, GenericAPIView): * mode """ + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None + @course_author_access_required def get(self, request, course_key): """ diff --git a/cms/djangoapps/contentstore/api/views/course_validation.py b/cms/djangoapps/contentstore/api/views/course_validation.py index 0fa8d9041c1..d1c2c2b8c46 100644 --- a/cms/djangoapps/contentstore/api/views/course_validation.py +++ b/cms/djangoapps/contentstore/api/views/course_validation.py @@ -65,6 +65,11 @@ class CourseValidationView(DeveloperErrorViewMixin, GenericAPIView): * has_proctoring_escalation_email - whether the course has a proctoring escalation email """ + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None + @course_author_access_required def get(self, request, course_key): """ diff --git a/lms/djangoapps/email_marketing/__init__.py b/cms/djangoapps/contentstore/core/__init__.py similarity index 100% rename from lms/djangoapps/email_marketing/__init__.py rename to cms/djangoapps/contentstore/core/__init__.py diff --git a/cms/djangoapps/contentstore/core/course_optimizer_provider.py b/cms/djangoapps/contentstore/core/course_optimizer_provider.py new file mode 100644 index 00000000000..c52ee686567 --- /dev/null +++ b/cms/djangoapps/contentstore/core/course_optimizer_provider.py @@ -0,0 +1,279 @@ +""" +Logic for handling actions in Studio related to Course Optimizer. +""" +import json +from user_tasks.conf import settings as user_tasks_settings +from user_tasks.models import UserTaskArtifact, UserTaskStatus + +from cms.djangoapps.contentstore.tasks import CourseLinkCheckTask +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_xblock +from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import usage_key_with_run + + +# Restricts status in the REST API to only those which the requesting user has permission to view. +# These can be overwritten in django settings. +# By default, these should be the UserTaskStatus statuses: +# 'Pending', 'In Progress', 'Succeeded', 'Failed', 'Canceled', 'Retrying' +STATUS_FILTERS = user_tasks_settings.USER_TASKS_STATUS_FILTERS + + +def get_link_check_data(request, course_id): + """ + Retrives data and formats it for the link check get request. + """ + task_status = _latest_task_status(request, course_id) + status = None + created_at = None + broken_links_dto = None + error = None + if task_status is None: + # The task hasn't been initialized yet; did we store info in the session already? + try: + session_status = request.session['link_check_status'] + status = session_status[course_id] + except KeyError: + status = 'Uninitiated' + else: + status = task_status.state + created_at = task_status.created + if task_status.state == UserTaskStatus.SUCCEEDED: + artifact = UserTaskArtifact.objects.get(status=task_status, name='BrokenLinks') + with artifact.file as file: + content = file.read() + json_content = json.loads(content) + broken_links_dto = generate_broken_links_descriptor(json_content, request.user) + elif task_status.state in (UserTaskStatus.FAILED, UserTaskStatus.CANCELED): + errors = UserTaskArtifact.objects.filter(status=task_status, name='Error') + if errors: + error = errors[0].text + try: + error = json.loads(error) + except ValueError: + # Wasn't JSON, just use the value as a string + pass + + data = { + 'LinkCheckStatus': status, + **({'LinkCheckCreatedAt': created_at} if created_at else {}), + **({'LinkCheckOutput': broken_links_dto} if broken_links_dto else {}), + **({'LinkCheckError': error} if error else {}) + } + return data + + +def _latest_task_status(request, course_key_string, view_func=None): + """ + Get the most recent link check status update for the specified course + key. + """ + args = {'course_key_string': course_key_string} + name = CourseLinkCheckTask.generate_name(args) + task_status = UserTaskStatus.objects.filter(name=name) + for status_filter in STATUS_FILTERS: + task_status = status_filter().filter_queryset(request, task_status, view_func) + return task_status.order_by('-created').first() + + +def generate_broken_links_descriptor(json_content, request_user): + """ + Returns a Data Transfer Object for frontend given a list of broken links. + + ** Example json_content structure ** + Note: is_locked is true if the link is a studio link and returns 403 + [ + ['block_id_1', 'link_1', is_locked], + ['block_id_1', 'link_2', is_locked], + ['block_id_2', 'link_3', is_locked], + ... + ] + + ** Example DTO structure ** + { + 'sections': [ + { + 'id': 'section_id', + 'displayName': 'section name', + 'subsections': [ + { + 'id': 'subsection_id', + 'displayName': 'subsection name', + 'units': [ + { + 'id': 'unit_id', + 'displayName': 'unit name', + 'blocks': [ + { + 'id': 'block_id', + 'displayName': 'block name', + 'url': 'url/to/block', + 'brokenLinks: [], + 'lockedLinks: [], + }, + ..., + ] + }, + ..., + ] + }, + ..., + ] + }, + ..., + ] + } + """ + xblock_node_tree = {} # tree representation of xblock relationships + xblock_dictionary = {} # dictionary of xblock attributes + + for item in json_content: + block_id, link, *rest = item + if rest: + is_locked_flag = bool(rest[0]) + else: + is_locked_flag = False + + usage_key = usage_key_with_run(block_id) + block = get_xblock(usage_key, request_user) + xblock_node_tree, xblock_dictionary = _update_node_tree_and_dictionary( + block=block, + link=link, + is_locked=is_locked_flag, + node_tree=xblock_node_tree, + dictionary=xblock_dictionary + ) + + return _create_dto_recursive(xblock_node_tree, xblock_dictionary) + + +def _update_node_tree_and_dictionary(block, link, is_locked, node_tree, dictionary): + """ + Inserts a block into the node tree and add its attributes to the dictionary. + + ** Example node tree structure ** + { + 'section_id1': { + 'subsection_id1': { + 'unit_id1': { + 'block_id1': {}, + 'block_id2': {}, + ..., + }, + 'unit_id2': { + 'block_id3': {}, + ..., + }, + ..., + }, + ..., + }, + ..., + } + + ** Example dictionary structure ** + { + 'xblock_id: { + 'display_name': 'xblock name', + 'category': 'chapter' + }, + 'html_block_id': { + 'display_name': 'xblock name', + 'category': 'chapter', + 'url': 'url_1', + 'locked_links': [...], + 'broken_links': [...] + } + ..., + } + """ + updated_tree, updated_dictionary = node_tree, dictionary + + path = _get_node_path(block) + current_node = updated_tree + xblock_id = '' + + # Traverse the path and build the tree structure + for xblock in path: + xblock_id = xblock.location.block_id + updated_dictionary.setdefault( + xblock_id, + { + 'display_name': xblock.display_name, + 'category': getattr(xblock, 'category', ''), + } + ) + # Sets new current node and creates the node if it doesn't exist + current_node = current_node.setdefault(xblock_id, {}) + + # Add block-level details for the last xblock in the path (URL and broken/locked links) + updated_dictionary[xblock_id].setdefault( + 'url', + f'/course/{block.course_id}/editor/{block.category}/{block.location}' + ) + + if is_locked: + updated_dictionary[xblock_id].setdefault('locked_links', []).append(link) + else: + updated_dictionary[xblock_id].setdefault('broken_links', []).append(link) + + return updated_tree, updated_dictionary + + +def _get_node_path(block): + """ + Retrieves the path from the course root node to a specific block, excluding the root. + + ** Example Path structure ** + [chapter_node, sequential_node, vertical_node, html_node] + """ + path = [] + current_node = block + + while current_node.get_parent(): + path.append(current_node) + current_node = current_node.get_parent() + + return list(reversed(path)) + + +CATEGORY_TO_LEVEL_MAP = { + "chapter": "sections", + "sequential": "subsections", + "vertical": "units" +} + + +def _create_dto_recursive(xblock_node, xblock_dictionary): + """ + Recursively build the Data Transfer Object by using + the structure from the node tree and data from the dictionary. + """ + # Exit condition when there are no more child nodes (at block level) + if not xblock_node: + return None + + level = None + xblock_children = [] + + for xblock_id, node in xblock_node.items(): + child_blocks = _create_dto_recursive(node, xblock_dictionary) + xblock_data = xblock_dictionary.get(xblock_id, {}) + + xblock_entry = { + 'id': xblock_id, + 'displayName': xblock_data.get('display_name', ''), + } + if child_blocks is None: # Leaf node + level = 'blocks' + xblock_entry.update({ + 'url': xblock_data.get('url', ''), + 'brokenLinks': xblock_data.get('broken_links', []), + 'lockedLinks': xblock_data.get('locked_links', []), + }) + else: # Non-leaf node + category = xblock_data.get('category', None) + level = CATEGORY_TO_LEVEL_MAP.get(category, None) + xblock_entry.update(child_blocks) + + xblock_children.append(xblock_entry) + + return {level: xblock_children} if level else None diff --git a/lms/djangoapps/email_marketing/migrations/__init__.py b/cms/djangoapps/contentstore/core/tests/__init__.py similarity index 100% rename from lms/djangoapps/email_marketing/migrations/__init__.py rename to cms/djangoapps/contentstore/core/tests/__init__.py diff --git a/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py b/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py new file mode 100644 index 00000000000..5dd098956e3 --- /dev/null +++ b/cms/djangoapps/contentstore/core/tests/test_course_optimizer_provider.py @@ -0,0 +1,219 @@ +""" +Tests for course optimizer +""" +from unittest.mock import Mock + +from cms.djangoapps.contentstore.tests.utils import CourseTestCase +from cms.djangoapps.contentstore.core.course_optimizer_provider import ( + _update_node_tree_and_dictionary, + _create_dto_recursive +) + + +class TestLinkCheckProvider(CourseTestCase): + """ + Tests for functions that generate a json structure of locked and broken links + to send to the frontend. + """ + def setUp(self): + """Setup course blocks for tests""" + super().setUp() + self.mock_course = Mock() + self.mock_section = Mock( + location=Mock(block_id='chapter_1'), + display_name='Section Name', + category='chapter' + ) + self.mock_subsection = Mock( + location=Mock(block_id='sequential_1'), + display_name='Subsection Name', + category='sequential' + ) + self.mock_unit = Mock( + location=Mock(block_id='vertical_1'), + display_name='Unit Name', + category='vertical' + ) + self.mock_block = Mock( + location=Mock(block_id='block_1'), + display_name='Block Name', + course_id=self.course.id, + category='html' + ) + self.mock_course.get_parent.return_value = None + self.mock_section.get_parent.return_value = self.mock_course + self.mock_subsection.get_parent.return_value = self.mock_section + self.mock_unit.get_parent.return_value = self.mock_subsection + self.mock_block.get_parent.return_value = self.mock_unit + + def test_update_node_tree_and_dictionary_returns_node_tree(self): + """ + Verify _update_node_tree_and_dictionary creates a node tree structure + when passed a block level xblock. + """ + expected_tree = { + 'chapter_1': { + 'sequential_1': { + 'vertical_1': { + 'block_1': {} + } + } + } + } + result_tree, result_dictionary = _update_node_tree_and_dictionary( + self.mock_block, 'example_link', True, {}, {} + ) + + self.assertEqual(expected_tree, result_tree) + + def test_update_node_tree_and_dictionary_returns_dictionary(self): + """ + Verify _update_node_tree_and_dictionary creates a dictionary of parent xblock entries + when passed a block level xblock. + """ + expected_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'category': 'html', + 'url': f'/course/{self.course.id}/editor/html/{self.mock_block.location}', + 'locked_links': ['example_link'] + } + } + result_tree, result_dictionary = _update_node_tree_and_dictionary( + self.mock_block, 'example_link', True, {}, {} + ) + + self.assertEqual(expected_dictionary, result_dictionary) + + def test_create_dto_recursive_returns_for_empty_node(self): + """ + Test _create_dto_recursive behavior at the end of recursion. + Function should return None when given empty node tree and empty dictionary. + """ + expected = _create_dto_recursive({}, {}) + self.assertEqual(None, expected) + + def test_create_dto_recursive_returns_for_leaf_node(self): + """ + Test _create_dto_recursive behavior at the step before the end of recursion. + When evaluating a leaf node in the node tree, the function should return broken links + and locked links data from the leaf node. + """ + expected_result = { + 'blocks': [ + { + 'id': 'block_1', + 'displayName': 'Block Name', + 'url': '/block/1', + 'brokenLinks': ['broken_link_1', 'broken_link_2'], + 'lockedLinks': ['locked_link'] + } + ] + } + + mock_node_tree = { + 'block_1': {} + } + mock_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'url': '/block/1', + 'broken_links': ['broken_link_1', 'broken_link_2'], + 'locked_links': ['locked_link'] + } + } + expected = _create_dto_recursive(mock_node_tree, mock_dictionary) + self.assertEqual(expected_result, expected) + + def test_create_dto_recursive_returns_for_full_tree(self): + """ + Test _create_dto_recursive behavior when recursing many times. + When evaluating a fully mocked node tree and dictionary, the function should return + a full json DTO prepared for frontend. + """ + expected_result = { + 'sections': [ + { + 'id': 'chapter_1', + 'displayName': 'Section Name', + 'subsections': [ + { + 'id': 'sequential_1', + 'displayName': 'Subsection Name', + 'units': [ + { + 'id': 'vertical_1', + 'displayName': 'Unit Name', + 'blocks': [ + { + 'id': 'block_1', + 'displayName': 'Block Name', + 'url': '/block/1', + 'brokenLinks': ['broken_link_1', 'broken_link_2'], + 'lockedLinks': ['locked_link'] + } + ] + } + ] + } + ] + } + ] + } + + mock_node_tree = { + 'chapter_1': { + 'sequential_1': { + 'vertical_1': { + 'block_1': {} + } + } + } + } + mock_dictionary = { + 'chapter_1': { + 'display_name': 'Section Name', + 'category': 'chapter' + }, + 'sequential_1': { + 'display_name': 'Subsection Name', + 'category': 'sequential' + }, + 'vertical_1': { + 'display_name': 'Unit Name', + 'category': 'vertical' + }, + 'block_1': { + 'display_name': 'Block Name', + 'url': '/block/1', + 'broken_links': ['broken_link_1', 'broken_link_2'], + 'locked_links': ['locked_link'] + } + } + expected = _create_dto_recursive(mock_node_tree, mock_dictionary) + + self.assertEqual(expected_result, expected) diff --git a/cms/djangoapps/contentstore/docs/how-tos/test_course_related_view_auth.rst b/cms/djangoapps/contentstore/docs/how-tos/test_course_related_view_auth.rst new file mode 100644 index 00000000000..b9b797ac436 --- /dev/null +++ b/cms/djangoapps/contentstore/docs/how-tos/test_course_related_view_auth.rst @@ -0,0 +1,64 @@ +============================================== +How to test View Auth for course-related views +============================================== + +What to test +------------ +Each view endpoint that exposes an internal API endpoint - like in files in the rest_api folder - must +be tested for the following. + +- Only authenticated users can access the endpoint. +- Only users with the correct permissions (authorization) can access the endpoint. +- All data and params that are part of the request are properly validated. + +How to test +----------- +The `AuthorizeStaffTestCase` class provides a set of tests that can be used to test the authorization +of a view. If you inherit from this class, these tests will be automatically run. For details, +please look at the source code of the `AuthorizeStaffTestCase` class. + +A lot of these tests can be easily implemented by inheriting from the `AuthorizeStaffTestCase`. +This parent class assumes that the view is for a specific course and that only users who have access +to the course can access the view. (They are either staff or instructors for the course, or global admin). + +Here is an example of how to test a view that requires a user to be authenticated and have access to a course. + +.. code-block:: python + + from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase + from django.test import TestCase + from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase + from django.urls import reverse + + class TestMyGetView(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): + def make_request(self, course_id=None, data=None): + url = self.get_url(self.course.id) + response = self.client.get(url, data) + return response + + def get_url(self, course_key): + url = reverse( + 'cms.djangoapps.contentstore:v0:my_get_view', + kwargs={'course_id': self.course.id} + ) + return url + +As you can see, you need to inherit from `AuthorizeStaffTestCase` and `ModuleStoreTestCase`, and then either +`TestCase` or `APITestCase` depending on the type of view you are testing. For cookie-based +authentication, `TestCase` is sufficient, for Oauth2 use `ApiTestCase`. + +The only two methods you need to implement are `make_request` and `get_url`. The `make_request` method +should make the request to the view and return the response. The `get_url` method should return the URL +for the view you are testing. + +Overwriting Tests +----------------- +If you need different behavior you can overwrite the tests from the parent class. +For example, if students should have access to the view, simply implement the +`test_student` method in your test class. + +Adding other tests +------------------ +If you want to test other things in the view - let's say validation - +it's easy to just add another `test_...` function to your test class +and you can use the `make_request` method to make the request. diff --git a/cms/djangoapps/contentstore/helpers.py b/cms/djangoapps/contentstore/helpers.py index e40eddb6c99..1ceb2bc3c01 100644 --- a/cms/djangoapps/contentstore/helpers.py +++ b/cms/djangoapps/contentstore/helpers.py @@ -29,6 +29,7 @@ from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers import openedx.core.djangoapps.content_staging.api as content_staging_api import openedx.core.djangoapps.content_tagging.api as content_tagging_api +from openedx.core.djangoapps.content_staging.data import LIBRARY_SYNC_PURPOSE from .utils import reverse_course_url, reverse_library_url, reverse_usage_url @@ -262,6 +263,37 @@ class StaticFileNotices: error_files: list[str] = Factory(list) +def _insert_static_files_into_downstream_xblock( + downstream_xblock: XBlock, staged_content_id: int, request +) -> StaticFileNotices: + """ + Gets static files from staged content, and inserts them into the downstream XBlock. + """ + static_files = content_staging_api.get_staged_content_static_files(staged_content_id) + notices, substitutions = _import_files_into_course( + course_key=downstream_xblock.context_key, + staged_content_id=staged_content_id, + static_files=static_files, + usage_key=downstream_xblock.scope_ids.usage_id, + ) + + # Rewrite the OLX's static asset references to point to the new + # locations for those assets. See _import_files_into_course for more + # info on why this is necessary. + store = modulestore() + if hasattr(downstream_xblock, "data") and substitutions: + data_with_substitutions = downstream_xblock.data + for old_static_ref, new_static_ref in substitutions.items(): + data_with_substitutions = data_with_substitutions.replace( + old_static_ref, + new_static_ref, + ) + downstream_xblock.data = data_with_substitutions + if store is not None: + store.update_item(downstream_xblock, request.user.id) + return notices + + def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tuple[XBlock | None, StaticFileNotices]: """ Import a block (along with its children and any required static assets) from @@ -299,31 +331,43 @@ def import_staged_content_from_user_clipboard(parent_key: UsageKey, request) -> tags=user_clipboard.content.tags, ) - # Now handle static files that need to go into Files & Uploads. - static_files = content_staging_api.get_staged_content_static_files(user_clipboard.content.id) - notices, substitutions = _import_files_into_course( - course_key=parent_key.context_key, - staged_content_id=user_clipboard.content.id, - static_files=static_files, - usage_key=new_xblock.scope_ids.usage_id, - ) - - # Rewrite the OLX's static asset references to point to the new - # locations for those assets. See _import_files_into_course for more - # info on why this is necessary. - if hasattr(new_xblock, 'data') and substitutions: - data_with_substitutions = new_xblock.data - for old_static_ref, new_static_ref in substitutions.items(): - data_with_substitutions = data_with_substitutions.replace( - old_static_ref, - new_static_ref, - ) - new_xblock.data = data_with_substitutions - store.update_item(new_xblock, request.user.id) + notices = _insert_static_files_into_downstream_xblock(new_xblock, user_clipboard.content.id, request) return new_xblock, notices +def import_static_assets_for_library_sync(downstream_xblock: XBlock, lib_block: XBlock, request) -> StaticFileNotices: + """ + Import the static assets from the library xblock to the downstream xblock + through staged content. Also updates the OLX references to point to the new + locations of those assets in the downstream course. + + Does not deal with permissions or REST stuff - do that before calling this. + + Returns a summary of changes made to static files in the destination + course. + """ + if not lib_block.runtime.get_block_assets(lib_block, fetch_asset_data=False): + return StaticFileNotices() + if not content_staging_api: + raise RuntimeError("The required content_staging app is not installed") + staged_content = content_staging_api.stage_xblock_temporarily(lib_block, request.user.id, LIBRARY_SYNC_PURPOSE) + if not staged_content: + # expired/error/loading + return StaticFileNotices() + + store = modulestore() + try: + with store.bulk_operations(downstream_xblock.context_key): + # Now handle static files that need to go into Files & Uploads. + # If the required files already exist, nothing will happen besides updating the olx. + notices = _insert_static_files_into_downstream_xblock(downstream_xblock, staged_content.id, request) + finally: + staged_content.delete() + + return notices + + def _fetch_and_set_upstream_link( copied_from_block: str, copied_from_version_num: int, @@ -548,6 +592,9 @@ def _import_files_into_course( if result is True: new_files.append(file_data_obj.filename) substitutions.update(substitution_for_file) + elif substitution_for_file: + # substitutions need to be made because OLX references to these files need to be updated + substitutions.update(substitution_for_file) elif result is None: pass # This file already exists; no action needed. else: @@ -618,8 +665,8 @@ def _import_file_into_course( contentstore().save(content) return True, {clipboard_file_path: f"static/{import_path}"} elif current_file.content_digest == file_data_obj.md5_hash: - # The file already exists and matches exactly, so no action is needed - return None, {} + # The file already exists and matches exactly, so no action is needed except substitutions + return None, {clipboard_file_path: f"static/{import_path}"} else: # There is a conflict with some other file that has the same name. return False, {} diff --git a/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py b/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py index 878a8dabaa2..768c3a53f7a 100644 --- a/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py +++ b/cms/djangoapps/contentstore/management/commands/backfill_course_tabs.py @@ -71,6 +71,5 @@ def handle(self, *args, **options): if error_keys: msg = 'The following courses encountered errors and were not updated:\n' - for error_key in error_keys: - msg += f' - {error_key}\n' + msg += '\n'.join(f' - {error_key}' for error_key in error_keys) logger.info(msg) diff --git a/cms/djangoapps/contentstore/management/commands/export_content_library.py b/cms/djangoapps/contentstore/management/commands/export_content_library.py index 0b4cbfb1fba..b56c172e374 100644 --- a/cms/djangoapps/contentstore/management/commands/export_content_library.py +++ b/cms/djangoapps/contentstore/management/commands/export_content_library.py @@ -51,16 +51,15 @@ def handle(self, *args, **options): tarball = tasks.create_export_tarball(library, library_key, {}, None) except Exception as e: raise CommandError(f'Failed to export "{library_key}" with "{e}"') # lint-amnesty, pylint: disable=raise-missing-from - else: - with tarball: - # Save generated archive with keyed filename - prefix, suffix, n = str(library_key).replace(':', '+'), '.tar.gz', 0 - while os.path.exists(prefix + suffix): - n += 1 - prefix = '{}_{}'.format(prefix.rsplit('_', 1)[0], n) if n > 1 else f'{prefix}_1' - filename = prefix + suffix - target = os.path.join(dest_path, filename) - tarball.file.seek(0) - with open(target, 'wb') as f: - shutil.copyfileobj(tarball.file, f) - print(f'Library "{library.location.library_key}" exported to "{target}"') + with tarball: + # Save generated archive with keyed filename + prefix, suffix, n = str(library_key).replace(':', '+'), '.tar.gz', 0 + while os.path.exists(prefix + suffix): + n += 1 + prefix = '{}_{}'.format(prefix.rsplit('_', 1)[0], n) if n > 1 else f'{prefix}_1' + filename = prefix + suffix + target = os.path.join(dest_path, filename) + tarball.file.seek(0) + with open(target, 'wb') as f: + shutil.copyfileobj(tarball.file, f) + print(f'Library "{library.location.library_key}" exported to "{target}"') diff --git a/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py b/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py new file mode 100644 index 00000000000..c1a8454cd56 --- /dev/null +++ b/cms/djangoapps/contentstore/management/commands/recreate_upstream_links.py @@ -0,0 +1,94 @@ +""" +Management command to recreate upstream-dowstream links in PublishableEntityLink for course(s). + +This command can be run for all the courses or for given list of courses. +""" + +from __future__ import annotations + +import logging +from datetime import datetime, timezone + +from django.core.management.base import BaseCommand, CommandError +from django.utils.translation import gettext as _ +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + +from ...tasks import create_or_update_upstream_links + +log = logging.getLogger(__name__) + + +class Command(BaseCommand): + """ + Recreate links for course(s) in PublishableEntityLink table. + + Examples: + # Recreate upstream links for two courses. + $ ./manage.py cms recreate_upstream_links --course course-v1:edX+DemoX.1+2014 \ + --course course-v1:edX+DemoX.2+2015 + # Force recreate upstream links for one or more courses including processed ones. + $ ./manage.py cms recreate_upstream_links --course course-v1:edX+DemoX.1+2014 \ + --course course-v1:edX+DemoX.2+2015 --force + # Recreate upstream links for all courses. + $ ./manage.py cms recreate_upstream_links --all + # Force recreate links for all courses including completely processed ones. + $ ./manage.py cms recreate_upstream_links --all --force + # Delete all links and force recreate links for all courses + $ ./manage.py cms recreate_upstream_links --all --force --replace + """ + + def add_arguments(self, parser): + parser.add_argument( + '--course', + metavar=_('COURSE_KEY'), + action='append', + help=_('Recreate links for xblocks under given course keys. For eg. course-v1:edX+DemoX.1+2014'), + default=[], + ) + parser.add_argument( + '--all', + action='store_true', + help=_( + 'Recreate links for xblocks under all courses. NOTE: this can take long time depending' + ' on number of course and xblocks' + ), + ) + parser.add_argument( + '--force', + action='store_true', + help=_('Recreate links even for completely processed courses.'), + ) + parser.add_argument( + '--replace', + action='store_true', + help=_('Delete all and create links for given course(s).'), + ) + + def handle(self, *args, **options): + """ + Handle command + """ + courses = options['course'] + should_process_all = options['all'] + force = options['force'] + replace = options['replace'] + time_now = datetime.now(tz=timezone.utc) + if not courses and not should_process_all: + raise CommandError('Either --course or --all argument should be provided.') + + if should_process_all and courses: + raise CommandError('Only one of --course or --all argument should be provided.') + + if should_process_all: + courses = CourseOverview.get_all_course_keys() + for course in courses: + log.info(f"Start processing upstream->dowstream links in course: {course}") + try: + CourseKey.from_string(str(course)) + except InvalidKeyError: + log.error(f"Invalid course key: {course}, skipping..") + continue + create_or_update_upstream_links.delay(str(course), force=force, replace=replace, created=time_now) diff --git a/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py b/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py new file mode 100644 index 00000000000..84b80cd6335 --- /dev/null +++ b/cms/djangoapps/contentstore/migrations/0009_learningcontextlinksstatus_publishableentitylink.py @@ -0,0 +1,93 @@ +# Generated by Django 4.2.18 on 2025-02-05 05:33 + +import uuid + +import django.db.models.deletion +import opaque_keys.edx.django.models +import openedx_learning.lib.fields +import openedx_learning.lib.validators +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ('oel_publishing', '0002_alter_learningpackage_key_and_more'), + ('contentstore', '0008_cleanstalecertificateavailabilitydatesconfig'), + ] + + operations = [ + migrations.CreateModel( + name='LearningContextLinksStatus', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ( + 'context_key', + opaque_keys.edx.django.models.CourseKeyField( + help_text='Linking status for course context key', max_length=255, unique=True + ), + ), + ( + 'status', + models.CharField( + choices=[ + ('pending', 'Pending'), + ('processing', 'Processing'), + ('failed', 'Failed'), + ('completed', 'Completed'), + ], + help_text='Status of links in given learning context/course.', + max_length=20, + ), + ), + ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ], + options={ + 'verbose_name': 'Learning Context Links status', + 'verbose_name_plural': 'Learning Context Links status', + }, + ), + migrations.CreateModel( + name='PublishableEntityLink', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, unique=True, verbose_name='UUID')), + ( + 'upstream_usage_key', + opaque_keys.edx.django.models.UsageKeyField( + help_text='Upstream block usage key, this value cannot be null and useful to track upstream library blocks that do not exist yet', + max_length=255, + ), + ), + ( + 'upstream_context_key', + openedx_learning.lib.fields.MultiCollationCharField( + db_collations={'mysql': 'utf8mb4_bin', 'sqlite': 'BINARY'}, + db_index=True, + help_text='Upstream context key i.e., learning_package/library key', + max_length=500, + ), + ), + ('downstream_usage_key', opaque_keys.edx.django.models.UsageKeyField(max_length=255, unique=True)), + ('downstream_context_key', opaque_keys.edx.django.models.CourseKeyField(db_index=True, max_length=255)), + ('version_synced', models.IntegerField()), + ('version_declined', models.IntegerField(blank=True, null=True)), + ('created', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ('updated', models.DateTimeField(validators=[openedx_learning.lib.validators.validate_utc_datetime])), + ( + 'upstream_block', + models.ForeignKey( + blank=True, + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name='links', + to='oel_publishing.publishableentity', + ), + ), + ], + options={ + 'verbose_name': 'Publishable Entity Link', + 'verbose_name_plural': 'Publishable Entity Links', + }, + ), + ] diff --git a/cms/djangoapps/contentstore/models.py b/cms/djangoapps/contentstore/models.py index f3b39397cf9..6a1750b8e1c 100644 --- a/cms/djangoapps/contentstore/models.py +++ b/cms/djangoapps/contentstore/models.py @@ -3,8 +3,20 @@ """ +from datetime import datetime, timezone + from config_models.models import ConfigurationModel +from django.db import models from django.db.models.fields import IntegerField, TextField +from django.utils.translation import gettext_lazy as _ +from opaque_keys.edx.django.models import CourseKeyField, UsageKeyField +from opaque_keys.edx.keys import CourseKey, UsageKey +from openedx_learning.api.authoring_models import Component, PublishableEntity +from openedx_learning.lib.fields import ( + immutable_uuid_field, + key_field, + manual_date_time_field, +) class VideoUploadConfig(ConfigurationModel): @@ -63,3 +75,169 @@ class Meta: "`clean_stale_certificate_available_dates` management command.' See the management command for options." ) ) + + +class PublishableEntityLink(models.Model): + """ + This represents link between any two publishable entities or link between publishable entity and a course + xblock. It helps in tracking relationship between xblocks imported from libraries and used in different courses. + """ + uuid = immutable_uuid_field() + upstream_block = models.ForeignKey( + PublishableEntity, + on_delete=models.SET_NULL, + related_name="links", + null=True, + blank=True, + ) + upstream_usage_key = UsageKeyField( + max_length=255, + help_text=_( + "Upstream block usage key, this value cannot be null" + " and useful to track upstream library blocks that do not exist yet" + ) + ) + # Search by library/upstream context key + upstream_context_key = key_field( + help_text=_("Upstream context key i.e., learning_package/library key"), + db_index=True, + ) + # A downstream entity can only link to single upstream entity + # whereas an entity can be upstream for multiple downstream entities. + downstream_usage_key = UsageKeyField(max_length=255, unique=True) + # Search by course/downstream key + downstream_context_key = CourseKeyField(max_length=255, db_index=True) + version_synced = models.IntegerField() + version_declined = models.IntegerField(null=True, blank=True) + created = manual_date_time_field() + updated = manual_date_time_field() + + def __str__(self): + return f"{self.upstream_usage_key}->{self.downstream_usage_key}" + + class Meta: + verbose_name = _("Publishable Entity Link") + verbose_name_plural = _("Publishable Entity Links") + + @classmethod + def update_or_create( + cls, + upstream_block: Component | None, + /, + upstream_usage_key: UsageKey, + upstream_context_key: str, + downstream_usage_key: UsageKey, + downstream_context_key: CourseKey, + version_synced: int, + version_declined: int | None = None, + created: datetime | None = None, + ) -> "PublishableEntityLink": + """ + Update or create entity link. This will only update `updated` field if something has changed. + """ + if not created: + created = datetime.now(tz=timezone.utc) + new_values = { + 'upstream_usage_key': upstream_usage_key, + 'upstream_context_key': upstream_context_key, + 'downstream_usage_key': downstream_usage_key, + 'downstream_context_key': downstream_context_key, + 'version_synced': version_synced, + 'version_declined': version_declined, + } + if upstream_block: + new_values.update( + { + 'upstream_block': upstream_block.publishable_entity, + } + ) + try: + link = cls.objects.get(downstream_usage_key=downstream_usage_key) + has_changes = False + for key, value in new_values.items(): + prev = getattr(link, key) + # None != None is True, so we need to check for it specially + if prev != value and ~(prev is None and value is None): + has_changes = True + setattr(link, key, value) + if has_changes: + link.updated = created + link.save() + except cls.DoesNotExist: + link = cls(**new_values) + link.created = created + link.updated = created + link.save() + return link + + +class LearningContextLinksStatusChoices(models.TextChoices): + """ + Enumerates the states that a LearningContextLinksStatus can be in. + """ + PENDING = "pending", _("Pending") + PROCESSING = "processing", _("Processing") + FAILED = "failed", _("Failed") + COMPLETED = "completed", _("Completed") + + +class LearningContextLinksStatus(models.Model): + """ + This table stores current processing status of upstream-downstream links in PublishableEntityLink table for a + course or a learning context. + """ + context_key = CourseKeyField( + max_length=255, + # Single entry for a learning context or course + unique=True, + help_text=_("Linking status for course context key"), + ) + status = models.CharField( + max_length=20, + choices=LearningContextLinksStatusChoices.choices, + help_text=_("Status of links in given learning context/course."), + ) + created = manual_date_time_field() + updated = manual_date_time_field() + + class Meta: + verbose_name = _("Learning Context Links status") + verbose_name_plural = _("Learning Context Links status") + + def __str__(self): + return f"{self.status}|{self.context_key}" + + @classmethod + def get_or_create(cls, context_key: str, created: datetime | None = None) -> "LearningContextLinksStatus": + """ + Get or create course link status row from LearningContextLinksStatus table for given course key. + + Args: + context_key: Learning context or Course key + + Returns: + LearningContextLinksStatus object + """ + if not created: + created = datetime.now(tz=timezone.utc) + status, _ = cls.objects.get_or_create( + context_key=context_key, + defaults={ + 'status': LearningContextLinksStatusChoices.PENDING, + 'created': created, + 'updated': created, + }, + ) + return status + + def update_status( + self, + status: LearningContextLinksStatusChoices, + updated: datetime | None = None + ) -> None: + """ + Updates entity links processing status of given learning context. + """ + self.status = status + self.updated = updated or datetime.now(tz=timezone.utc) + self.save() diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py index 33931a4a199..171f746be43 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/__init__.py @@ -4,6 +4,7 @@ from .advanced_settings import AdvancedSettingsFieldSerializer, CourseAdvancedSettingsSerializer from .assets import AssetSerializer from .authoring_grading import CourseGradingModelSerializer +from .course_optimizer import LinkCheckSerializer from .tabs import CourseTabSerializer, CourseTabUpdateSerializer, TabIDLocatorSerializer from .transcripts import TranscriptSerializer, YoutubeTranscriptCheckSerializer, YoutubeTranscriptUploadSerializer from .xblock import XblockSerializer diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py index e3dd070573a..e42c3e2ee39 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/authoring_grading.py @@ -14,7 +14,13 @@ class GradersSerializer(serializers.Serializer): weight = serializers.IntegerField() id = serializers.IntegerField() + class Meta: + ref_name = "authoring_grading.Graders.v0" + class CourseGradingModelSerializer(serializers.Serializer): """ Serializer for course grading model data """ graders = GradersSerializer(many=True, allow_null=True, allow_empty=True) + + class Meta: + ref_name = "authoring_grading.CourseGrading.v0" diff --git a/cms/djangoapps/contentstore/rest_api/v0/serializers/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/serializers/course_optimizer.py new file mode 100644 index 00000000000..a7b38e07271 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/serializers/course_optimizer.py @@ -0,0 +1,48 @@ +""" +API Serializers for Course Optimizer +""" + +from rest_framework import serializers + + +class LinkCheckBlockSerializer(serializers.Serializer): + """ Serializer for broken links block model data """ + id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + displayName = serializers.CharField(required=True, allow_null=False, allow_blank=True) + url = serializers.CharField(required=True, allow_null=False, allow_blank=False) + brokenLinks = serializers.ListField(required=False) + lockedLinks = serializers.ListField(required=False) + + +class LinkCheckUnitSerializer(serializers.Serializer): + """ Serializer for broken links unit model data """ + id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + displayName = serializers.CharField(required=True, allow_null=False, allow_blank=True) + blocks = LinkCheckBlockSerializer(many=True) + + +class LinkCheckSubsectionSerializer(serializers.Serializer): + """ Serializer for broken links subsection model data """ + id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + displayName = serializers.CharField(required=True, allow_null=False, allow_blank=True) + units = LinkCheckUnitSerializer(many=True) + + +class LinkCheckSectionSerializer(serializers.Serializer): + """ Serializer for broken links section model data """ + id = serializers.CharField(required=True, allow_null=False, allow_blank=False) + displayName = serializers.CharField(required=True, allow_null=False, allow_blank=True) + subsections = LinkCheckSubsectionSerializer(many=True) + + +class LinkCheckOutputSerializer(serializers.Serializer): + """ Serializer for broken links output model data """ + sections = LinkCheckSectionSerializer(many=True) + + +class LinkCheckSerializer(serializers.Serializer): + """ Serializer for broken links """ + LinkCheckStatus = serializers.CharField(required=True) + LinkCheckCreatedAt = serializers.DateTimeField(required=False) + LinkCheckOutput = LinkCheckOutputSerializer(required=False) + LinkCheckError = serializers.CharField(required=False) diff --git a/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py new file mode 100644 index 00000000000..14d5a20fb41 --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/tests/test_course_optimizer.py @@ -0,0 +1,79 @@ +""" +Unit tests for course optimizer +""" +from django.test import TestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from django.urls import reverse + +from cms.djangoapps.contentstore.tests.test_utils import AuthorizeStaffTestCase + + +class TestGetLinkCheckStatus(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): + ''' + Authentication and Authorization Tests for CourseOptimizer. + For concrete tests that are run, check `AuthorizeStaffTestCase`. + ''' + def make_request(self, course_id=None, data=None, **kwargs): + url = self.get_url(self.course.id) + response = self.client.get(url, data) + return response + + def get_url(self, course_key): + url = reverse( + 'cms.djangoapps.contentstore:v0:link_check_status', + kwargs={'course_id': self.course.id} + ) + return url + + def test_produces_4xx_when_invalid_course_id(self): + ''' + Test course_id validation + ''' + response = self.make_request(course_id='invalid_course_id') + self.assertIn(response.status_code, range(400, 500)) + + def test_produces_4xx_when_additional_kwargs(self): + ''' + Test additional kwargs validation + ''' + response = self.make_request(course_id=self.course.id, malicious_kwarg='malicious_kwarg') + self.assertIn(response.status_code, range(400, 500)) + + +class TestPostLinkCheck(AuthorizeStaffTestCase, ModuleStoreTestCase, TestCase): + ''' + Authentication and Authorization Tests for CourseOptimizer. + For concrete tests that are run, check `AuthorizeStaffTestCase`. + ''' + def make_request(self, course_id=None, data=None, **kwargs): + url = self.get_url(self.course.id) + response = self.client.post(url, data) + return response + + def get_url(self, course_key): + url = reverse( + 'cms.djangoapps.contentstore:v0:link_check', + kwargs={'course_id': self.course.id} + ) + return url + + def test_produces_4xx_when_invalid_course_id(self): + ''' + Test course_id validation + ''' + response = self.make_request(course_id='invalid_course_id') + self.assertIn(response.status_code, range(400, 500)) + + def test_produces_4xx_when_additional_kwargs(self): + ''' + Test additional kwargs validation + ''' + response = self.make_request(course_id=self.course.id, malicious_kwarg='malicious_kwarg') + self.assertIn(response.status_code, range(400, 500)) + + def test_produces_4xx_when_unexpected_data(self): + ''' + Test validation when request contains unexpected data + ''' + response = self.make_request(course_id=self.course.id, data={'unexpected_data': 'unexpected_data'}) + self.assertIn(response.status_code, range(400, 500)) diff --git a/cms/djangoapps/contentstore/rest_api/v0/urls.py b/cms/djangoapps/contentstore/rest_api/v0/urls.py index cc1e13b0929..9d7006a708c 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/urls.py +++ b/cms/djangoapps/contentstore/rest_api/v0/urls.py @@ -7,14 +7,16 @@ from .views import ( AdvancedCourseSettingsView, + APIHeartBeatView, AuthoringGradingView, CourseTabSettingsView, CourseTabListView, CourseTabReorderView, + LinkCheckView, + LinkCheckStatusView, TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView, - APIHeartBeatView ) from .views import assets from .views import authoring_videos @@ -63,7 +65,7 @@ authoring_videos.VideoEncodingsDownloadView.as_view(), name='cms_api_videos_encodings' ), re_path( - fr'grading/{settings.COURSE_ID_PATTERN}', + fr'grading/{settings.COURSE_ID_PATTERN}$', AuthoringGradingView.as_view(), name='cms_api_update_grading' ), path( @@ -102,4 +104,14 @@ fr'^youtube_transcripts/{settings.COURSE_ID_PATTERN}/upload?$', YoutubeTranscriptUploadView.as_view(), name='cms_api_youtube_transcripts_upload' ), + + # Course Optimizer + re_path( + fr'^link_check/{settings.COURSE_ID_PATTERN}$', + LinkCheckView.as_view(), name='link_check' + ), + re_path( + fr'^link_check_status/{settings.COURSE_ID_PATTERN}$', + LinkCheckStatusView.as_view(), name='link_check_status' + ), ] diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py index 00d22a1ea71..2ce3ea22ea4 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/__init__.py @@ -2,7 +2,8 @@ Views for v0 contentstore API. """ from .advanced_settings import AdvancedCourseSettingsView +from .api_heartbeat import APIHeartBeatView from .authoring_grading import AuthoringGradingView +from .course_optimizer import LinkCheckView, LinkCheckStatusView from .tabs import CourseTabSettingsView, CourseTabListView, CourseTabReorderView from .transcripts import TranscriptView, YoutubeTranscriptCheckView, YoutubeTranscriptUploadView -from .api_heartbeat import APIHeartBeatView diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py index 972b6229f55..8fb66070f3b 100644 --- a/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py +++ b/cms/djangoapps/contentstore/rest_api/v0/views/authoring_videos.py @@ -128,6 +128,11 @@ class VideoEncodingsDownloadView(DeveloperErrorViewMixin, RetrieveAPIView): course_key: required argument, needed to authorize course authors and identify relevant videos. """ + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None + def dispatch(self, request, *args, **kwargs): # TODO: probably want to refactor this to a decorator. """ @@ -151,6 +156,11 @@ class VideoFeaturesView(DeveloperErrorViewMixin, RetrieveAPIView): public rest API endpoint providing a list of enabled video features. """ + # TODO: ARCH-91 + # This view is excluded from Swagger doc generation because it + # does not specify a serializer class. + swagger_schema = None + def dispatch(self, request, *args, **kwargs): # TODO: probably want to refactor this to a decorator. """ diff --git a/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py new file mode 100644 index 00000000000..9aa23838e6c --- /dev/null +++ b/cms/djangoapps/contentstore/rest_api/v0/views/course_optimizer.py @@ -0,0 +1,144 @@ +""" API Views for Course Optimizer. """ +import edx_api_doc_tools as apidocs +from opaque_keys.edx.keys import CourseKey +from rest_framework.views import APIView +from rest_framework.request import Request +from rest_framework.response import Response +from user_tasks.models import UserTaskStatus + +from cms.djangoapps.contentstore.core.course_optimizer_provider import get_link_check_data +from cms.djangoapps.contentstore.rest_api.v0.serializers.course_optimizer import LinkCheckSerializer +from cms.djangoapps.contentstore.tasks import check_broken_links +from common.djangoapps.student.auth import has_course_author_access, has_studio_read_access +from common.djangoapps.util.json_request import JsonResponse +from openedx.core.lib.api.view_utils import DeveloperErrorViewMixin, verify_course_exists, view_auth_classes + + +@view_auth_classes(is_authenticated=True) +class LinkCheckView(DeveloperErrorViewMixin, APIView): + """ + View for queueing a celery task to scan a course for broken links. + """ + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: "Celery task queued.", + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + @verify_course_exists() + def post(self, request: Request, course_id: str): + """ + Queue celery task to scan a course for broken links. + + **Example Request** + POST /api/contentstore/v0/link_check/{course_id} + + **Response Values** + ```json + { + "LinkCheckStatus": "Pending" + } + """ + course_key = CourseKey.from_string(course_id) + + if not has_studio_read_access(request.user, course_key): + self.permission_denied(request) + + check_broken_links.delay(request.user.id, course_id, request.LANGUAGE_CODE) + return JsonResponse({'LinkCheckStatus': UserTaskStatus.PENDING}) + + +@view_auth_classes() +class LinkCheckStatusView(DeveloperErrorViewMixin, APIView): + """ + View for checking the status of the celery task and returning the results. + """ + @apidocs.schema( + parameters=[ + apidocs.string_parameter("course_id", apidocs.ParameterLocation.PATH, description="Course ID"), + ], + responses={ + 200: "OK", + 401: "The requester is not authenticated.", + 403: "The requester cannot access the specified course.", + 404: "The requested course does not exist.", + }, + ) + def get(self, request: Request, course_id: str): + """ + GET handler to return the status of the link_check task from UserTaskStatus. + If no task has been started for the course, return 'Uninitiated'. + If link_check task was successful, an output result is also returned. + + For reference, the following status are in UserTaskStatus: + 'Pending', 'In Progress' (sent to frontend as 'In-Progress'), + 'Succeeded', 'Failed', 'Canceled', 'Retrying' + This function adds a status for when status from UserTaskStatus is None: + 'Uninitiated' + + **Example Request** + GET /api/contentstore/v0/link_check_status/{course_id} + + **Example Response** + ```json + { + "LinkCheckStatus": "Succeeded", + "LinkCheckCreatedAt": "2025-02-05T14:32:01.294587Z", + "LinkCheckOutput": { + sections: [ + { + id: , + displayName: , + subsections: [ + { + id: , + displayName: , + units: [ + { + id: , + displayName: , + blocks: [ + { + id: , + url: , + brokenLinks: [ + , + , + , + ..., + ], + lockedLinks: [ + , + , + , + ..., + ], + }, + { }, + ], + }, + { }, + ], + }, + { }, + ], + }, + } + """ + course_key = CourseKey.from_string(course_id) + if not has_course_author_access(request.user, course_key): + print('missing course author access') + self.permission_denied(request) + + data = get_link_check_data(request, course_id) + serializer = LinkCheckSerializer(data) + + return Response(serializer.data) diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py index 8c0d651910a..46e67e87ea0 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/downstreams.py @@ -56,8 +56,10 @@ "ready_to_sync": Boolean } """ + import logging +from attrs import asdict as attrs_asdict from django.contrib.auth.models import User # pylint: disable=imported-auth-user from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import UsageKey @@ -71,6 +73,7 @@ UpstreamLink, UpstreamLinkException, NoUpstream, BadUpstream, BadDownstream, fetch_customizable_fields, sync_from_upstream, decline_sync, sever_upstream_link ) +from cms.djangoapps.contentstore.helpers import import_static_assets_for_library_sync from common.djangoapps.student.auth import has_studio_write_access, has_studio_read_access from openedx.core.lib.api.view_utils import ( DeveloperErrorViewMixin, @@ -195,7 +198,8 @@ def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respons """ downstream = _load_accessible_block(request.user, usage_key_string, require_write_access=True) try: - sync_from_upstream(downstream, request.user) + upstream = sync_from_upstream(downstream, request.user) + static_file_notices = import_static_assets_for_library_sync(downstream, upstream, request) except UpstreamLinkException as exc: logger.exception( "Could not sync from upstream '%s' to downstream '%s'", @@ -206,7 +210,9 @@ def post(self, request: _AuthenticatedRequest, usage_key_string: str) -> Respons modulestore().update_item(downstream, request.user.id) # Note: We call `get_for_block` (rather than `try_get_for_block`) because if anything is wrong with the # upstream at this point, then that is completely unexpected, so it's appropriate to let the 500 happen. - return Response(UpstreamLink.get_for_block(downstream).to_json()) + response = UpstreamLink.get_for_block(downstream).to_json() + response["static_file_notices"] = attrs_asdict(static_file_notices) + return Response(response) def delete(self, request: _AuthenticatedRequest, usage_key_string: str) -> Response: """ diff --git a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py index 92da28bde98..31877b9153d 100644 --- a/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py +++ b/cms/djangoapps/contentstore/rest_api/v2/views/tests/test_downstreams.py @@ -4,6 +4,7 @@ from unittest.mock import patch from django.conf import settings +from cms.djangoapps.contentstore.helpers import StaticFileNotices from cms.lib.xblock.upstream_sync import UpstreamLink, BadUpstream from common.djangoapps.student.tests.factories import UserFactory from xmodule.modulestore.django import modulestore @@ -247,7 +248,8 @@ def call_api(self, usage_key_string): @patch.object(UpstreamLink, "get_for_block", _get_upstream_link_good_and_syncable) @patch.object(downstreams_views, "sync_from_upstream") - def test_200(self, mock_sync_from_upstream): + @patch.object(downstreams_views, "import_static_assets_for_library_sync", return_value=StaticFileNotices()) + def test_200(self, mock_sync_from_upstream, mock_import_staged_content): """ Does the happy path work? """ @@ -255,6 +257,7 @@ def test_200(self, mock_sync_from_upstream): response = self.call_api(self.downstream_video_key) assert response.status_code == 200 assert mock_sync_from_upstream.call_count == 1 + assert mock_import_staged_content.call_count == 1 class DeleteDownstreamSyncViewtest(_DownstreamSyncViewTestMixin, SharedModuleStoreTestCase): diff --git a/cms/djangoapps/contentstore/signals/handlers.py b/cms/djangoapps/contentstore/signals/handlers.py index d756424bcca..5635d465562 100644 --- a/cms/djangoapps/contentstore/signals/handlers.py +++ b/cms/djangoapps/contentstore/signals/handlers.py @@ -12,8 +12,21 @@ from django.dispatch import receiver from edx_toggles.toggles import SettingToggle from opaque_keys.edx.keys import CourseKey -from openedx_events.content_authoring.data import CourseCatalogData, CourseScheduleData -from openedx_events.content_authoring.signals import COURSE_CATALOG_INFO_CHANGED +from openedx_events.content_authoring.data import ( + CourseCatalogData, + CourseData, + CourseScheduleData, + LibraryBlockData, + XBlockData, +) +from openedx_events.content_authoring.signals import ( + COURSE_CATALOG_INFO_CHANGED, + COURSE_IMPORT_COMPLETED, + LIBRARY_BLOCK_DELETED, + XBLOCK_CREATED, + XBLOCK_DELETED, + XBLOCK_UPDATED, +) from pytz import UTC from cms.djangoapps.contentstore.courseware_index import ( @@ -29,6 +42,14 @@ from openedx.core.lib.gating import api as gating_api from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import SignalHandler, modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError + +from ..models import PublishableEntityLink +from ..tasks import ( + create_or_update_upstream_links, + handle_create_or_update_xblock_upstream_link, + handle_unlink_upstream_block, +) from .signals import GRADING_POLICY_CHANGED log = logging.getLogger(__name__) @@ -197,12 +218,19 @@ def handle_item_deleted(**kwargs): # Strip branch info usage_key = usage_key.for_branch(None) course_key = usage_key.course_key - deleted_block = modulestore().get_item(usage_key) + try: + deleted_block = modulestore().get_item(usage_key) + except ItemNotFoundError: + return + id_list = {deleted_block.location} for block in yield_dynamic_block_descendants(deleted_block, kwargs.get('user_id')): # Remove prerequisite milestone data gating_api.remove_prerequisite(block.location) # Remove any 'requires' course content milestone relationships gating_api.set_required_content(course_key, block.location, None, None, None) + id_list.add(block.location) + + PublishableEntityLink.objects.filter(downstream_usage_key__in=id_list).delete() @receiver(GRADING_POLICY_CHANGED) @@ -224,3 +252,62 @@ def handle_grading_policy_changed(sender, **kwargs): task_id=result.task_id, kwargs=kwargs, )) + + +@receiver(XBLOCK_CREATED) +@receiver(XBLOCK_UPDATED) +def create_or_update_upstream_downstream_link_handler(**kwargs): + """ + Automatically create or update upstream->downstream link in database. + """ + xblock_info = kwargs.get("xblock_info", None) + if not xblock_info or not isinstance(xblock_info, XBlockData): + log.error("Received null or incorrect data for event") + return + + handle_create_or_update_xblock_upstream_link.delay(str(xblock_info.usage_key)) + + +@receiver(XBLOCK_DELETED) +def delete_upstream_downstream_link_handler(**kwargs): + """ + Delete upstream->downstream link from database on xblock delete. + """ + xblock_info = kwargs.get("xblock_info", None) + if not xblock_info or not isinstance(xblock_info, XBlockData): + log.error("Received null or incorrect data for event") + return + + PublishableEntityLink.objects.filter( + downstream_usage_key=xblock_info.usage_key + ).delete() + + +@receiver(COURSE_IMPORT_COMPLETED) +def handle_new_course_import(**kwargs): + """ + Automatically create upstream->downstream links for course in database on new import. + """ + course_data = kwargs.get("course", None) + if not course_data or not isinstance(course_data, CourseData): + log.error("Received null or incorrect data for event") + return + + create_or_update_upstream_links.delay( + str(course_data.course_key), + force=True, + replace=True + ) + + +@receiver(LIBRARY_BLOCK_DELETED) +def unlink_upstream_block_handler(**kwargs): + """ + Handle unlinking the upstream (library) block from any downstream (course) blocks. + """ + library_block = kwargs.get("library_block", None) + if not library_block or not isinstance(library_block, LibraryBlockData): + log.error("Received null or incorrect data for event") + return + + handle_unlink_upstream_block.delay(str(library_block.usage_key)) diff --git a/cms/djangoapps/contentstore/tasks.py b/cms/djangoapps/contentstore/tasks.py index 032747f6368..c2803f3e275 100644 --- a/cms/djangoapps/contentstore/tasks.py +++ b/cms/djangoapps/contentstore/tasks.py @@ -2,16 +2,19 @@ This file contains celery tasks for contentstore views """ +import asyncio import base64 import json import os +import re import shutil import tarfile -from datetime import datetime +from datetime import datetime, timezone +from importlib.metadata import entry_points from tempfile import NamedTemporaryFile, mkdtemp +import aiohttp import olxcleaner -from importlib.metadata import entry_points from ccx_keys.locator import CCXLocator from celery import shared_task from celery.utils.log import get_task_logger @@ -25,11 +28,12 @@ set_code_owner_attribute, set_code_owner_attribute_from_module, set_custom_attribute, - set_custom_attributes_for_course_key + set_custom_attributes_for_course_key, ) from olxcleaner.exceptions import ErrorLevel from olxcleaner.reporting import report_error_summary, report_errors -from opaque_keys.edx.keys import CourseKey +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import CourseKey, UsageKey from opaque_keys.edx.locator import LibraryLocator from organizations.api import add_organization_course, ensure_organization from organizations.exceptions import InvalidOrganizationException @@ -43,28 +47,33 @@ from cms.djangoapps.contentstore.courseware_index import ( CoursewareSearchIndexer, LibrarySearchIndexer, - SearchIndexingError + SearchIndexingError, ) from cms.djangoapps.contentstore.storage import course_import_export_storage from cms.djangoapps.contentstore.utils import ( IMPORTABLE_FILE_TYPES, + create_or_update_xblock_upstream_link, + delete_course, initialize_permissions, reverse_usage_url, translation_language, - delete_course ) +from cms.djangoapps.contentstore.xblock_storage_handlers.view_handlers import get_block_info from cms.djangoapps.models.settings.course_metadata import CourseMetadata from common.djangoapps.course_action_state.models import CourseRerunState +from common.djangoapps.static_replace import replace_static_urls from common.djangoapps.student.auth import has_course_author_access from common.djangoapps.student.roles import CourseInstructorRole, CourseStaffRole, LibraryUserRole from common.djangoapps.util.monitoring import monitor_import_failure from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines from openedx.core.djangoapps.content_libraries import api as v2contentlib_api +from openedx.core.djangoapps.content_tagging.api import make_copied_tags_editable from openedx.core.djangoapps.course_apps.toggles import exams_ida_enabled from openedx.core.djangoapps.discussions.config.waffle import ENABLE_NEW_STRUCTURE_DISCUSSIONS from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration, Provider from openedx.core.djangoapps.discussions.tasks import update_unit_discussion_state_from_discussion_blocks from openedx.core.djangoapps.embargo.models import CountryAccessRule, RestrictedCourse +from openedx.core.lib import ensure_cms from openedx.core.lib.extract_archive import safe_extractall from xmodule.contentstore.django import contentstore from xmodule.course_block import CourseFields @@ -74,6 +83,8 @@ from xmodule.modulestore.exceptions import DuplicateCourseError, InvalidProctoringProvider, ItemNotFoundError from xmodule.modulestore.xml_exporter import export_course_to_xml, export_library_to_xml from xmodule.modulestore.xml_importer import CourseImportException, import_course_from_xml, import_library_from_xml + +from .models import LearningContextLinksStatus, LearningContextLinksStatusChoices, PublishableEntityLink from .outlines import update_outline_from_modulestore from .outlines_regenerate import CourseOutlineRegenerate from .toggles import bypass_olx_failure_enabled @@ -890,7 +901,7 @@ def _get_users_by_access_level(v1_library_key): def _create_copy_content_task(v2_library_key, v1_library_key): """ spin up a celery task to import the V1 Library's content into the V2 library. - This utalizes the fact that course and v1 library content is stored almost identically. + This utilizes the fact that course and v1 library content is stored almost identically. """ return v2contentlib_api.import_blocks_create_task( v2_library_key, v1_library_key, @@ -1066,3 +1077,413 @@ def undo_all_library_source_blocks_ids_for_course(course_key_string, v1_to_v2_li store.update_item(draft_library_source_block, None) # return success return + + +class CourseLinkCheckTask(UserTask): # pylint: disable=abstract-method + """ + Base class for course link check tasks. + """ + + @staticmethod + def calculate_total_steps(arguments_dict): + """ + Get the number of in-progress steps in the link check process, as shown in the UI. + + For reference, these are: + 1. Scanning + """ + return 1 + + @classmethod + def generate_name(cls, arguments_dict): + """ + Create a name for this particular task instance. + + Arguments: + arguments_dict (dict): The arguments given to the task function + + Returns: + str: The generated name + """ + key = arguments_dict['course_key_string'] + return f'Broken link check of {key}' + + +# -------------- Course optimizer functions ------------------ + + +@shared_task(base=CourseLinkCheckTask, bind=True) +# Note: The decorator @set_code_owner_attribute cannot be used here because the UserTaskMixin +# does stack inspection and can't handle additional decorators. +def check_broken_links(self, user_id, course_key_string, language): + """ + Checks for broken links in a course and store the results in a file. + """ + set_code_owner_attribute_from_module(__name__) + return _check_broken_links(self, user_id, course_key_string, language) + + +def _check_broken_links(task_instance, user_id, course_key_string, language): + """ + Checks for broken links in a course and store the results in a file. + """ + user = _validate_user(task_instance, user_id, language) + + task_instance.status.set_state('Scanning') + course_key = CourseKey.from_string(course_key_string) + + url_list = _scan_course_for_links(course_key) + validated_url_list = asyncio.run(_validate_urls_access_in_batches(url_list, course_key, batch_size=100)) + broken_or_locked_urls, retry_list = _filter_by_status(validated_url_list) + + if retry_list: + retry_results = _retry_validation(retry_list, course_key, retry_count=3) + broken_or_locked_urls.extend(retry_results) + + try: + task_instance.status.increment_completed_steps() + + file_name = str(course_key) + broken_links_file = NamedTemporaryFile(prefix=file_name + '.', suffix='.json') + LOGGER.debug(f'[Link Check] json file being generated at {broken_links_file.name}') + + with open(broken_links_file.name, 'w') as file: + json.dump(broken_or_locked_urls, file, indent=4) + + _write_broken_links_to_file(broken_or_locked_urls, broken_links_file) + + artifact = UserTaskArtifact(status=task_instance.status, name='BrokenLinks') + _save_broken_links_file(artifact, broken_links_file) + + # catch all exceptions so we can record useful error messages + except Exception as e: # pylint: disable=broad-except + LOGGER.exception('Error checking links for course %s', course_key, exc_info=True) + if task_instance.status.state != UserTaskStatus.FAILED: + task_instance.status.fail({'raw_error_msg': str(e)}) + + +def _validate_user(task, user_id, language): + """Validate if the user exists. Otherwise log an unknown user id error.""" + try: + return User.objects.get(pk=user_id) + except User.DoesNotExist as exc: + with translation_language(language): + task.status.fail(UserErrors.UNKNOWN_USER_ID.format(user_id)) + return + + +def _scan_course_for_links(course_key): + """ + Scans a course for links found in the data contents of blocks. + + Returns: + list: block id and URL pairs + + Example return: + [ + [block_id1, url1], + [block_id2, url2], + ... + ] + """ + verticals = modulestore().get_items( + course_key, + qualifiers={'category': 'vertical'}, + revision=ModuleStoreEnum.RevisionOption.published_only + ) + blocks = [] + urls_to_validate = [] + + for vertical in verticals: + blocks.extend(vertical.get_children()) + + for block in blocks: + block_id = str(block.usage_key) + block_info = get_block_info(block) + block_data = block_info['data'] + + url_list = _get_urls(block_data) + urls_to_validate += [[block_id, url] for url in url_list] + + return urls_to_validate + + +def _get_urls(content): + """ + Finds and returns a list of URLs in the given content. + Includes strings following 'href=' and 'src='. + Excludes strings that are only '#'. + + Arguments: + content (str): entire content of a block + + Returns: + list: urls + """ + regex = r'\s+(?:href|src)=["\'](?!#)([^"\']*)["\']' + url_list = re.findall(regex, content) + return url_list + + +async def _validate_urls_access_in_batches(url_list, course_key, batch_size=100): + """ + Returns the statuses of a list of URL requests. + + Arguments: + url_list (list): block id and URL pairs + + Returns: + list: dictionary containing URL, associated block id, and request status + """ + responses = [] + url_count = len(url_list) + + for i in range(0, url_count, batch_size): + batch = url_list[i:i + batch_size] + batch_results = await _validate_batch(batch, course_key) + responses.extend(batch_results) + LOGGER.debug(f'[Link Check] request batch {i // batch_size + 1} of {url_count // batch_size + 1}') + + return responses + + +async def _validate_batch(batch, course_key): + """Validate a batch of URLs""" + async with aiohttp.ClientSession() as session: + tasks = [_validate_url_access(session, url_data, course_key) for url_data in batch] + batch_results = await asyncio.gather(*tasks) + return batch_results + + +async def _validate_url_access(session, url_data, course_key): + """ + Validates a URL. + + Arguments: + url_data (list): block id and URL pairs + course_key (str): locator id for a course + + Returns: + dict: URL, associated block id, and request status + + Example return: + { + 'block_id': block_id1, + 'url': url1, + 'status': status + } + """ + block_id, url = url_data + result = {'block_id': block_id, 'url': url} + standardized_url = _convert_to_standard_url(url, course_key) + try: + async with session.get(standardized_url, timeout=5) as response: + result.update({'status': response.status}) + except Exception as e: # lint-amnesty, pylint: disable=broad-except + result.update({'status': None}) + LOGGER.debug(f'[Link Check] Request error when validating {url}: {str(e)}') + return result + + +def _convert_to_standard_url(url, course_key): + """ + Returns standard URLs when given studio URLs. Otherwise returns the URL as is. + + Example URLs: + /assets/courseware/v1/506da5d6f866e8f0be44c5df8b6e6b2a/... + ...asset-v1:edX+DemoX+Demo_Course+type@asset+block/getting-started_x250.png + /static/getting-started_x250.png + /container/block-v1:edX+DemoX+Demo_Course+type@vertical+block@2152d4a4aadc4cb0af5256394a3d1fc7 + """ + if _is_studio_url_without_base(url): + if url.startswith('/static/'): + processed_url = replace_static_urls(f'\"{url}\"', course_id=course_key)[1:-1] + return 'https://' + settings.CMS_BASE + processed_url + elif url.startswith('/'): + return 'https://' + settings.CMS_BASE + url + else: + return 'https://' + settings.CMS_BASE + '/container/' + url + else: + return url + + +def _is_studio_url(url): + """Returns True if url is a studio url.""" + return _is_studio_url_with_base(url) or _is_studio_url_without_base(url) + + +def _is_studio_url_with_base(url): + """Returns True if url is a studio url with cms base.""" + return url.startswith('http://' + settings.CMS_BASE) or url.startswith('https://' + settings.CMS_BASE) + + +def _is_studio_url_without_base(url): + """Returns True if url is a studio url without cms base.""" + return not url.startswith('http://') and not url.startswith('https://') + + +def _filter_by_status(results): + """ + Filter results by status. + + Statuses: + 200: OK. No need to do more + 403: Forbidden. Record as locked link. + None: Error. Retry up to 3 times. + Other: Failure. Record as broken link. + + Arguments: + results (list): URL, associated block id, and request status + + Returns: + filtered_results (list): list of block id, URL and if URL is locked + retry_list (list): block id and url pairs + + Example return: + [ + [block_id1, filtered_results_url1, is_locked], + ... + ], + [ + [block_id1, retry_url1], + ... + ] + """ + filtered_results = [] + retry_list = [] + for result in results: + status, block_id, url = result['status'], result['block_id'], result['url'] + if status is None: + retry_list.append([block_id, url]) + elif status == 200: + continue + elif status == 403 and _is_studio_url(url): + filtered_results.append([block_id, url, True]) + else: + filtered_results.append([block_id, url, False]) + + return filtered_results, retry_list + + +def _retry_validation(url_list, course_key, retry_count=3): + """ + Retry validation for URLs that failed due to connection error. + + Returns: + list: URLs that could not be validated due to being locked or due to persistent connection problems + """ + results = [] + retry_list = url_list + for i in range(retry_count): + if retry_list: + LOGGER.debug(f'[Link Check] retry attempt #{i + 1}') + retry_list = _retry_validation_and_filter_results(course_key, results, retry_list) + results.extend(retry_list) + + return results + + +def _retry_validation_and_filter_results(course_key, results, retry_list): + """ + Validates URLs and then filter them by status. + + Arguments: + retry_list: list of urls to retry + + Returns: + list: URLs that did not pass validation and should be retried + """ + validated_url_list = asyncio.run( + _validate_urls_access_in_batches(retry_list, course_key, batch_size=100) + ) + filtered_url_list, retry_list = _filter_by_status(validated_url_list) + results.extend(filtered_url_list) + return retry_list + + +def _save_broken_links_file(artifact, file_to_save): + artifact.file.save(name=os.path.basename(file_to_save.name), content=File(file_to_save)) + artifact.save() + return True + + +def _write_broken_links_to_file(broken_or_locked_urls, broken_links_file): + with open(broken_links_file.name, 'w') as file: + json.dump(broken_or_locked_urls, file, indent=4) + + +@shared_task +@set_code_owner_attribute +def handle_create_or_update_xblock_upstream_link(usage_key): + """ + Create or update upstream link for a single xblock. + """ + ensure_cms("handle_create_or_update_xblock_upstream_link may only be executed in a CMS context") + try: + xblock = modulestore().get_item(UsageKey.from_string(usage_key)) + except (ItemNotFoundError, InvalidKeyError): + LOGGER.exception(f'Could not find item for given usage_key: {usage_key}') + return + if not xblock.upstream or not xblock.upstream_version: + return + create_or_update_xblock_upstream_link(xblock, xblock.course_id) + + +@shared_task +@set_code_owner_attribute +def create_or_update_upstream_links( + course_key_str: str, + force: bool = False, + replace: bool = False, + created: datetime | None = None, +): + """ + A Celery task to create or update upstream downstream links in database from course xblock content. + """ + ensure_cms("create_or_update_upstream_links may only be executed in a CMS context") + + if not created: + created = datetime.now(timezone.utc) + course_status = LearningContextLinksStatus.get_or_create(course_key_str, created) + if course_status.status in [ + LearningContextLinksStatusChoices.COMPLETED, + LearningContextLinksStatusChoices.PROCESSING + ] and not force: + return + store = modulestore() + course_key = CourseKey.from_string(course_key_str) + course_status.update_status( + LearningContextLinksStatusChoices.PROCESSING, + updated=created, + ) + if replace: + PublishableEntityLink.objects.filter(downstream_context_key=course_key).delete() + try: + xblocks = store.get_items(course_key, settings={"upstream": lambda x: x is not None}) + except ItemNotFoundError: + LOGGER.exception(f'Could not find items for given course: {course_key}') + course_status.update_status(LearningContextLinksStatusChoices.FAILED) + return + for xblock in xblocks: + create_or_update_xblock_upstream_link(xblock, course_key_str, created) + course_status.update_status(LearningContextLinksStatusChoices.COMPLETED) + + +@shared_task +@set_code_owner_attribute +def handle_unlink_upstream_block(upstream_usage_key_string: str) -> None: + """ + Handle updates needed to downstream blocks when the upstream link is severed. + """ + ensure_cms("handle_unlink_upstream_block may only be executed in a CMS context") + + try: + upstream_usage_key = UsageKey.from_string(upstream_usage_key_string) + except (InvalidKeyError): + LOGGER.exception(f'Invalid upstream usage_key: {upstream_usage_key_string}') + return + + for link in PublishableEntityLink.objects.filter( + upstream_usage_key=upstream_usage_key, + ): + make_copied_tags_editable(str(link.downstream_usage_key)) diff --git a/cms/djangoapps/contentstore/tests/test_tasks.py b/cms/djangoapps/contentstore/tests/test_tasks.py index cf82a6d1657..7c06692b6bb 100644 --- a/cms/djangoapps/contentstore/tests/test_tasks.py +++ b/cms/djangoapps/contentstore/tests/test_tasks.py @@ -1,31 +1,48 @@ """ Unit tests for course import and export Celery tasks """ - - import copy import json +import logging from unittest import mock +from unittest.mock import AsyncMock, patch, MagicMock from uuid import uuid4 +from celery import Task +import pytest from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.test.utils import override_settings from edx_toggles.toggles.testutils import override_waffle_flag +from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator from organizations.models import OrganizationCourse from organizations.tests.factories import OrganizationFactory from user_tasks.models import UserTaskArtifact, UserTaskStatus -from cms.djangoapps.contentstore.tasks import export_olx, update_special_exams_and_publish, rerun_course from cms.djangoapps.contentstore.tests.test_libraries import LibraryTestCase from cms.djangoapps.contentstore.tests.utils import CourseTestCase from common.djangoapps.course_action_state.models import CourseRerunState from common.djangoapps.student.tests.factories import UserFactory from openedx.core.djangoapps.course_apps.toggles import EXAMS_IDA from openedx.core.djangoapps.embargo.models import Country, CountryAccessRule, RestrictedCourse +from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE +from xmodule.modulestore.tests.django_utils import TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order +from ..tasks import ( + export_olx, + update_special_exams_and_publish, + rerun_course, + _validate_urls_access_in_batches, + _filter_by_status, + _get_urls, + _check_broken_links, + _is_studio_url, + _scan_course_for_links +) + +logging = logging.getLogger(__name__) TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['DOC_STORE_CONFIG']['db'] = 'test_xcontent_%s' % uuid4().hex @@ -199,3 +216,236 @@ def test_register_exams_failure(self, _mock_register_exams_proctoring, _mock_reg _mock_register_exams_proctoring.side_effect = Exception('boom!') update_special_exams_and_publish(str(self.course.id)) course_publish.assert_called() + + +class MockCourseLinkCheckTask(Task): + def __init__(self): + self.status = mock.Mock() + + +############## Course Optimizer tests ############## + + +class CheckBrokenLinksTaskTest(ModuleStoreTestCase): + """Tests for CheckBrokenLinksTask""" + def setUp(self): + super().setUp() + self.store = modulestore()._get_modulestore_by_type(ModuleStoreEnum.Type.mongo) # lint-amnesty, pylint: disable=protected-access + self.test_course = CourseFactory.create( + org="test", course="course1", display_name="run1" + ) + self.mock_urls = [ + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@1", "http://example.com/valid"], + ["block-v1:edX+DemoX+Demo_Course+type@vertical+block@2", "http://example.com/invalid"] + ] + self.expected_file_contents = [ + ['block-v1:edX+DemoX+Demo_Course+type@vertical+block@1', 'http://example.com/valid', False], + ['block-v1:edX+DemoX+Demo_Course+type@vertical+block@2', 'http://example.com/invalid', False] + ] + + def test_hash_tags_stripped_from_url_lists(self): + NUM_HASH_TAG_LINES = 2 + url_list = ''' + href='#' # 1 of 2 lines that will be stripped + href='http://google.com' + src='#' # 2 of 2 lines that will be stripped + href='https://microsoft.com' + src="/static/resource_name" + ''' + + # Correct for the two carriage returns surrounding the ''' marks + original_lines = len(url_list.splitlines()) - 2 + + processed_url_list = _get_urls(url_list) + processed_lines = len(processed_url_list) + + assert processed_lines == original_lines - NUM_HASH_TAG_LINES, \ + f'Processed URL list lines = {processed_lines}; expected {original_lines - 2}' + + def test_http_url_not_recognized_as_studio_url_scheme(self): + self.assertFalse(_is_studio_url('http://www.google.com')) + + def test_https_url_not_recognized_as_studio_url_scheme(self): + self.assertFalse(_is_studio_url('https://www.google.com')) + + def test_http_with_studio_base_url_recognized_as_studio_url_scheme(self): + self.assertTrue(_is_studio_url(f'http://{settings.CMS_BASE}/testurl')) + + def test_https_with_studio_base_url_recognized_as_studio_url_scheme(self): + self.assertTrue(_is_studio_url(f'https://{settings.CMS_BASE}/testurl')) + + def test_container_url_without_url_base_is_recognized_as_studio_url_scheme(self): + self.assertTrue(_is_studio_url('container/test')) + + def test_slash_url_without_url_base_is_recognized_as_studio_url_scheme(self): + self.assertTrue(_is_studio_url('/static/test')) + + @mock.patch('cms.djangoapps.contentstore.tasks.ModuleStoreEnum', autospec=True) + @mock.patch('cms.djangoapps.contentstore.tasks.modulestore', autospec=True) + def test_course_scan_occurs_on_published_version(self, mock_modulestore, mock_module_store_enum): + """_scan_course_for_links should only scan published courses""" + mock_modulestore_instance = mock.Mock() + mock_modulestore.return_value = mock_modulestore_instance + mock_modulestore_instance.get_items.return_value = [] + + mock_course_key_string = CourseKey.from_string("course-v1:edX+DemoX+Demo_Course") + mock_module_store_enum.RevisionOption.published_only = "mock_published_only" + + _scan_course_for_links(mock_course_key_string) + + mock_modulestore_instance.get_items.assert_called_once_with( + mock_course_key_string, + qualifiers={'category': 'vertical'}, + revision=mock_module_store_enum.RevisionOption.published_only + ) + + @mock.patch('cms.djangoapps.contentstore.tasks._get_urls', autospec=True) + def test_number_of_scanned_blocks_equals_blocks_in_course(self, mock_get_urls): + """ + _scan_course_for_links should call _get_urls once per block in course. + """ + expected_blocks = self.store.get_items(self.test_course.id) + + _scan_course_for_links(self.test_course.id) + self.assertEqual(len(expected_blocks), mock_get_urls.call_count) + + @pytest.mark.asyncio + async def test_every_detected_link_is_validated(self): + ''' + The call to _validate_urls_access_in_batches() should call _validate_batch() three times, once for each + of the three batches of length 2 in url_list. The lambda function supplied for _validate_batch will + simply return the set of urls fed to _validate_batch(), and _validate_urls_access_in_batches() will + aggregate these into a list identical to the original url_list. + + What this shows is that each url submitted to _validate_urls_access_in_batches() is ending up as an argument + to one of the generated _validate_batch() calls, and that no input URL is left unprocessed. + ''' + url_list = ['1', '2', '3', '4', '5'] + course_key = 'course-v1:edX+DemoX+Demo_Course' + batch_size = 2 + with patch("cms.djangoapps.contentstore.tasks._validate_batch", new_callable=AsyncMock) as mock_validate_batch: + mock_validate_batch.side_effect = lambda x, y: x + validated_urls = await _validate_urls_access_in_batches(url_list, course_key, batch_size) + mock_validate_batch.assert_called() + assert mock_validate_batch.call_count == 3 # two full batches and one partial batch + assert validated_urls == url_list, \ + f"List of validated urls {validated_urls} is not identical to sourced urls {url_list}" + + @pytest.mark.asyncio + async def test_all_links_are_validated_with_batch_validation(self): + ''' + Here the focus is not on batching, but rather that when validation occurs it does so on the intended + URL strings + ''' + with patch("cms.djangoapps.contentstore.tasks._validate_url_access", new_callable=AsyncMock) as mock_validate: + mock_validate.return_value = {"status": 200} + + url_list = ['1', '2', '3', '4', '5'] + course_key = 'course-v1:edX+DemoX+Demo_Course' + batch_size = 2 + await _validate_urls_access_in_batches(url_list, course_key, batch_size) + args_list = mock_validate.call_args_list + urls = [call_args.args[1] for call_args in args_list] # The middle argument in each of the function calls + for i in range(1, len(url_list) + 1): + assert str(i) in urls, f'{i} not supplied as a url for validation in batches function' + + def test_no_retries_on_403_access_denied_links(self): + ''' + No mocking required here. Will populate "filtering_input" with simulated results for link checks where + some links time out, some links receive 403 errors, and some receive 200 success. This test then + ensures that "_filter_by_status()" tallies the three categories as expected, and formats the result + as expected. + ''' + url_list = ['1', '2', '3', '4', '5'] + filtering_input = [] + for i in range(1, len(url_list) + 1): # Notch out one of the URLs, having it return a '403' status code + filtering_input.append({ + 'block_id': f'block_{i}', + 'url': str(i), + 'status': 200 + }) + filtering_input[2]['status'] = 403 + filtering_input[3]['status'] = 500 + filtering_input[4]['status'] = None + + broken_or_locked_urls, retry_list = _filter_by_status(filtering_input) + assert len(broken_or_locked_urls) == 2 # The inputs with status = 403 and 500 + assert len(retry_list) == 1 # The input with status = None + assert retry_list[0][1] == '5' # The only URL fit for a retry operation (status == None) + + @patch("cms.djangoapps.contentstore.tasks._validate_user", return_value=MagicMock()) + @patch("cms.djangoapps.contentstore.tasks._scan_course_for_links", return_value=["url1", "url2"]) + @patch( + "cms.djangoapps.contentstore.tasks._validate_urls_access_in_batches", + return_value=[{"url": "url1", "status": "ok"}] + ) + @patch( + "cms.djangoapps.contentstore.tasks._filter_by_status", + return_value=(["block_1", "url1", True], ["block_2", "url2"]) + ) + @patch("cms.djangoapps.contentstore.tasks._retry_validation", return_value=['block_2', 'url2']) + def test_check_broken_links_calls_expected_support_functions( + self, + mock_retry_validation, + mock_filter, + mock_validate_urls, + mock_scan_course, + mock_validate_user + ): + # Parameters for the function + user_id = 1234 + language = "en" + course_key_string = "course-v1:edX+DemoX+2025" + + # Mocking self and status attributes for the test + class MockStatus: + """Mock for status attributes""" + def __init__(self): + self.state = "READY" + + def set_state(self, state): + self.state = state + + def increment_completed_steps(self): + pass + + def fail(self, error_details): + self.state = "FAILED" + + class MockSelf: + def __init__(self): + self.status = MockStatus() + + mock_self = MockSelf() + + _check_broken_links(mock_self, user_id, course_key_string, language) + + # Prepare expected results based on mock settings + url_list = mock_scan_course.return_value + validated_url_list = mock_validate_urls.return_value + broken_or_locked_urls, retry_list = mock_filter.return_value + course_key = CourseKey.from_string(course_key_string) + + if retry_list: + retry_results = mock_retry_validation.return_value + broken_or_locked_urls.extend(retry_results) + + # Perform verifications + try: + mock_self.status.increment_completed_steps() + mock_retry_validation.assert_called_once_with( + mock_filter.return_value[1], course_key, retry_count=3 + ) + except Exception as e: # pylint: disable=broad-except + logging.exception("Error checking links for course %s", course_key_string, exc_info=True) + if mock_self.status.state != "FAILED": + mock_self.status.fail({"raw_error_msg": str(e)}) + assert False, "Exception should not occur" + + # Assertions to confirm patched calls were invoked + mock_validate_user.assert_called_once_with(mock_self, user_id, language) + mock_scan_course.assert_called_once_with(course_key) + mock_validate_urls.assert_called_once_with(url_list, course_key, batch_size=100) + mock_filter.assert_called_once_with(validated_url_list) + if retry_list: + mock_retry_validation.assert_called_once_with(retry_list, course_key, retry_count=3) diff --git a/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py new file mode 100644 index 00000000000..3f0703d8b31 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_upstream_downstream_links.py @@ -0,0 +1,274 @@ +""" +Tests for upstream downstream tracking links. +""" + +from io import StringIO +from uuid import uuid4 + +from django.core.management import call_command +from django.core.management.base import CommandError +from django.test import TestCase +from opaque_keys.edx.keys import CourseKey +from opaque_keys.edx.locator import LibraryUsageLocatorV2 +from openedx_events.tests.utils import OpenEdxEventsTestMixin + +from common.djangoapps.student.tests.factories import UserFactory +from openedx.core.djangolib.testing.utils import skip_unless_cms +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import BlockFactory, CourseFactory + +from ..models import LearningContextLinksStatus, LearningContextLinksStatusChoices, PublishableEntityLink + + +class BaseUpstreamLinksHelpers(TestCase): + """ + Base class with helpers to create xblocks. + """ + def _set_course_data(self, course): + self.section = BlockFactory.create(parent=course, category="chapter", display_name="Section") # pylint: disable=attribute-defined-outside-init + self.sequence = BlockFactory.create(parent=self.section, category="sequential", display_name="Sequence") # pylint: disable=attribute-defined-outside-init + self.unit = BlockFactory.create(parent=self.sequence, category="vertical", display_name="Unit") # pylint: disable=attribute-defined-outside-init + + def _create_block(self, num: int, category="html"): + """ + Create xblock with random upstream key and version number. + """ + random_upstream = LibraryUsageLocatorV2.from_string( + f"lb:OpenedX:CSPROB2:{category}:{uuid4()}" + ) + return random_upstream, BlockFactory.create( + parent=self.unit, # pylint: disable=attribute-defined-outside-init + category=category, + display_name=f"An {category} Block - {num}", + upstream=str(random_upstream), + upstream_version=num, + ) + + def _create_block_and_expected_links_data(self, course_key: str | CourseKey, num_blocks: int = 3): + """ + Creates xblocks and its expected links data for given course_key + """ + data = [] + for i in range(num_blocks): + upstream, block = self._create_block(i + 1) + data.append({ + "upstream_block": None, + "downstream_context_key": course_key, + "downstream_usage_key": block.usage_key, + "upstream_usage_key": upstream, + "upstream_context_key": str(upstream.context_key), + "version_synced": i + 1, + "version_declined": None, + }) + return data + + def _compare_links(self, course_key, expected): + """ + Compares links for given course with passed expected list of dicts. + """ + links = list(PublishableEntityLink.objects.filter(downstream_context_key=course_key).values( + 'upstream_block', + 'upstream_usage_key', + 'upstream_context_key', + 'downstream_usage_key', + 'downstream_context_key', + 'version_synced', + 'version_declined', + )) + self.assertListEqual(links, expected) + + +@skip_unless_cms +class TestRecreateUpstreamLinks(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseUpstreamLinksHelpers): + """ + Test recreate_upstream_links management command. + """ + + ENABLED_SIGNALS = ['course_deleted', 'course_published'] + ENABLED_OPENEDX_EVENTS = [] + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.course_1 = course_1 = CourseFactory.create(emit_signals=True) + self.course_key_1 = course_key_1 = self.course_1.id + with self.store.bulk_operations(course_key_1): + self._set_course_data(course_1) + self.expected_links_1 = self._create_block_and_expected_links_data(course_key_1) + self.course_2 = course_2 = CourseFactory.create(emit_signals=True) + self.course_key_2 = course_key_2 = self.course_2.id + with self.store.bulk_operations(course_key_2): + self._set_course_data(course_2) + self.expected_links_2 = self._create_block_and_expected_links_data(course_key_2) + self.course_3 = course_3 = CourseFactory.create(emit_signals=True) + self.course_key_3 = course_key_3 = self.course_3.id + with self.store.bulk_operations(course_key_3): + self._set_course_data(course_3) + self.expected_links_3 = self._create_block_and_expected_links_data(course_key_3) + + def call_command(self, *args, **kwargs): + """ + call command with pass args. + """ + out = StringIO() + kwargs['stdout'] = out + err = StringIO() + kwargs['stderr'] = err + call_command('recreate_upstream_links', *args, **kwargs) + return out, err + + def test_call_with_invalid_args(self): + """ + Test command with invalid args. + """ + with self.assertRaisesRegex(CommandError, 'Either --course or --all argument'): + self.call_command() + with self.assertRaisesRegex(CommandError, 'Only one of --course or --all argument'): + self.call_command('--all', '--course', str(self.course_key_1)) + + def test_call_for_single_course(self): + """ + Test command with single course argument + """ + # Pre-checks + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists() + assert not PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_1).exists() + # Run command + self.call_command('--course', str(self.course_key_1)) + # Post verfication + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_1) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + self._compare_links(self.course_key_1, self.expected_links_1) + + def test_call_for_multiple_course(self): + """ + Test command with multiple course arguments + """ + # Pre-checks + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists() + assert not PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_2).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_3)).exists() + assert not PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_3).exists() + + # Run command + self.call_command('--course', str(self.course_key_2), '--course', str(self.course_key_3)) + + # Post verfication + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_2) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_3) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + self._compare_links(self.course_key_2, self.expected_links_2) + self._compare_links(self.course_key_3, self.expected_links_3) + + def test_call_for_all_courses(self): + """ + Test command with multiple course arguments + """ + # Delete all links and status just to make sure --all option works + LearningContextLinksStatus.objects.all().delete() + PublishableEntityLink.objects.all().delete() + # Pre-checks + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_3)).exists() + + # Run command + self.call_command('--all') + + # Post verfication + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_1) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_2) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + assert LearningContextLinksStatus.objects.filter( + context_key=str(self.course_key_3) + ).first().status == LearningContextLinksStatusChoices.COMPLETED + self._compare_links(self.course_key_1, self.expected_links_1) + self._compare_links(self.course_key_2, self.expected_links_2) + self._compare_links(self.course_key_3, self.expected_links_3) + + def test_call_for_invalid_course(self): + """ + Test recreate_upstream_links with nonexistent course + """ + course_key = "invalid-course" + with self.assertLogs(level="ERROR") as ctx: + self.call_command('--course', course_key) + self.assertEqual( + f'Invalid course key: {course_key}, skipping..', + ctx.records[0].getMessage() + ) + + def test_call_for_nonexistent_course(self): + """ + Test recreate_upstream_links with nonexistent course + """ + course_key = "course-v1:unix+ux1+2024_T2" + with self.assertLogs(level="ERROR") as ctx: + self.call_command('--course', course_key) + self.assertIn( + f'Could not find items for given course: {course_key}', + ctx.records[0].getMessage() + ) + + +@skip_unless_cms +class TestUpstreamLinksEvents(ModuleStoreTestCase, OpenEdxEventsTestMixin, BaseUpstreamLinksHelpers): + """ + Test signals related to managing upstream->downstream links. + """ + + ENABLED_SIGNALS = ['course_deleted', 'course_published'] + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.content_authoring.xblock.created.v1", + "org.openedx.content_authoring.xblock.updated.v1", + "org.openedx.content_authoring.xblock.deleted.v1", + ] + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.course_1 = course_1 = CourseFactory.create(emit_signals=True) + self.course_key_1 = course_key_1 = self.course_1.id + with self.store.bulk_operations(course_key_1): + self._set_course_data(course_1) + self.expected_links_1 = self._create_block_and_expected_links_data(course_key_1) + self.course_2 = course_2 = CourseFactory.create(emit_signals=True) + self.course_key_2 = course_key_2 = self.course_2.id + with self.store.bulk_operations(course_key_2): + self._set_course_data(course_2) + self.expected_links_2 = self._create_block_and_expected_links_data(course_key_2) + self.course_3 = course_3 = CourseFactory.create(emit_signals=True) + self.course_key_3 = course_key_3 = self.course_3.id + with self.store.bulk_operations(course_key_3): + self._set_course_data(course_3) + self.expected_links_3 = self._create_block_and_expected_links_data(course_key_3) + + def test_create_or_update_events(self): + """ + Test task create_or_update_upstream_links for a course + """ + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_1)).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_2)).exists() + assert not LearningContextLinksStatus.objects.filter(context_key=str(self.course_key_3)).exists() + assert PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_1).count() == 3 + assert PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_2).count() == 3 + assert PublishableEntityLink.objects.filter(downstream_context_key=self.course_key_3).count() == 3 + self._compare_links(self.course_key_1, self.expected_links_1) + self._compare_links(self.course_key_2, self.expected_links_2) + self._compare_links(self.course_key_3, self.expected_links_3) + + def test_delete_handler(self): + """ + Test whether links are deleted on deletion of xblock. + """ + usage_key = self.expected_links_1[0]["downstream_usage_key"] + assert PublishableEntityLink.objects.filter(downstream_usage_key=usage_key).exists() + self.store.delete_item(usage_key, self.user.id) + assert not PublishableEntityLink.objects.filter(downstream_usage_key=usage_key).exists() diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index a9137979706..a46d9831d4e 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -12,6 +12,7 @@ from edx_toggles.toggles.testutils import override_waffle_flag from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import CourseLocator, LibraryLocator +from openedx_events.tests.utils import OpenEdxEventsTestMixin from path import Path as path from pytz import UTC from rest_framework import status @@ -31,10 +32,13 @@ from xmodule.modulestore.tests.django_utils import ( # lint-amnesty, pylint: disable=wrong-import-order TEST_DATA_SPLIT_MODULESTORE, ModuleStoreTestCase, - SharedModuleStoreTestCase + SharedModuleStoreTestCase, ) -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.factories import ( + BlockFactory, + CourseFactory, +) +from xmodule.partitions.partitions import Group, UserPartition class LMSLinksTestCase(TestCase): @@ -935,10 +939,13 @@ def test_update_course_details_instructor_paced(self, mock_update): @override_waffle_flag(ENABLE_NOTIFICATIONS, active=True) -class CourseUpdateNotificationTests(ModuleStoreTestCase): +class CourseUpdateNotificationTests(OpenEdxEventsTestMixin, ModuleStoreTestCase): """ Unit tests for the course_update notification. """ + ENABLED_OPENEDX_EVENTS = [ + "org.openedx.learning.course.notification.requested.v1", + ] def setUp(self): """ diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index a220b8d9139..79d8f757a7d 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -2,6 +2,7 @@ Common utility functions useful throughout the contentstore """ from __future__ import annotations + import configparser import html import logging @@ -9,12 +10,12 @@ from collections import defaultdict from contextlib import contextmanager from datetime import datetime, timezone -from urllib.parse import quote_plus, urlencode, urlunparse, urlparse +from urllib.parse import quote_plus, urlencode, urlparse, urlunparse from uuid import uuid4 from bs4 import BeautifulSoup from django.conf import settings -from django.core.exceptions import ValidationError +from django.core.exceptions import ObjectDoesNotExist, ValidationError from django.urls import reverse from django.utils import translation from django.utils.text import Truncator @@ -22,32 +23,31 @@ from eventtracking import tracker from help_tokens.core import HelpUrlExpert from lti_consumer.models import CourseAllowPIISharingInLTIFlag -from opaque_keys.edx.keys import CourseKey, UsageKey +from milestones import api as milestones_api +from opaque_keys.edx.keys import CourseKey, UsageKey, UsageKeyV2 from opaque_keys.edx.locator import LibraryLocator - -from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS, TEAM_SCHEME from openedx_events.content_authoring.data import DuplicatedXBlockData from openedx_events.content_authoring.signals import XBLOCK_DUPLICATED from openedx_events.learning.data import CourseNotificationData from openedx_events.learning.signals import COURSE_NOTIFICATION_REQUESTED - -from milestones import api as milestones_api from pytz import UTC from xblock.fields import Scope from cms.djangoapps.contentstore.toggles import ( + enable_course_optimizer, exam_setting_view_enabled, libraries_v1_enabled, libraries_v2_enabled, split_library_view_on_dashboard, use_new_advanced_settings_page, - use_new_course_outline_page, use_new_certificates_page, + use_new_course_outline_page, + use_new_course_team_page, + use_new_custom_pages, use_new_export_page, use_new_files_uploads_page, use_new_grading_page, use_new_group_configurations_page, - use_new_course_team_page, use_new_home_page, use_new_import_page, use_new_schedule_details_page, @@ -57,16 +57,15 @@ use_new_updates_page, use_new_video_editor, use_new_video_uploads_page, - use_new_custom_pages, ) from cms.djangoapps.models.settings.course_grading import CourseGradingModel from cms.djangoapps.models.settings.course_metadata import CourseMetadata -from common.djangoapps.course_action_state.models import CourseRerunUIStateManager, CourseRerunState from common.djangoapps.course_action_state.managers import CourseActionStateItemNotFoundError +from common.djangoapps.course_action_state.models import CourseRerunState, CourseRerunUIStateManager from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.services import MakoService from common.djangoapps.student import auth -from common.djangoapps.student.auth import has_studio_read_access, has_studio_write_access, STUDIO_EDIT_ROLES +from common.djangoapps.student.auth import STUDIO_EDIT_ROLES, has_studio_read_access, has_studio_write_access from common.djangoapps.student.models import CourseEnrollment from common.djangoapps.student.roles import ( CourseInstructorRole, @@ -75,15 +74,15 @@ ) from common.djangoapps.track import contexts from common.djangoapps.util.course import get_link_for_about_page +from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.util.milestones_helpers import ( + generate_milestone_namespace, + get_namespace_choices, is_prerequisite_courses_enabled, is_valid_course_key, remove_prerequisite_course, set_prerequisite_courses, - get_namespace_choices, - generate_milestone_namespace ) -from common.djangoapps.util.date_utils import get_default_time_display from common.djangoapps.xblock_django.api import deprecated_xblocks from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService from openedx.core import toggles as core_toggles @@ -93,23 +92,28 @@ from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.core.djangoapps.django_comment_common.models import assign_default_role from openedx.core.djangoapps.django_comment_common.utils import seed_permissions_roles +from openedx.core.djangoapps.models.course_details import CourseDetails from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.models import SiteConfiguration -from openedx.core.djangoapps.models.course_details import CourseDetails +from openedx.core.djangoapps.xblock.api import get_component_from_usage_key from openedx.core.lib.courses import course_image_url from openedx.core.lib.html_to_text import html_to_text +from openedx.core.lib.teams_config import CONTENT_GROUPS_FOR_TEAMS, TEAM_SCHEME from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.content_type_gating.partitions import CONTENT_TYPE_GATING_SCHEME from openedx.features.course_experience.waffle import ENABLE_COURSE_ABOUT_SIDEBAR_HTML -from xmodule.library_tools import LegacyLibraryToolsService from xmodule.course_block import DEFAULT_START_DATE # lint-amnesty, pylint: disable=wrong-import-order from xmodule.data import CertificatesDisplayBehaviors +from xmodule.library_tools import LegacyLibraryToolsService from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.partitions.partitions_service import get_all_partitions_for_course # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.services import SettingsService, ConfigurationService, TeamsConfigurationService +from xmodule.partitions.partitions_service import ( + get_all_partitions_for_course, # lint-amnesty, pylint: disable=wrong-import-order +) +from xmodule.services import ConfigurationService, SettingsService, TeamsConfigurationService +from .models import PublishableEntityLink IMPORTABLE_FILE_TYPES = ('.tar.gz', '.zip') log = logging.getLogger(__name__) @@ -390,6 +394,19 @@ def get_export_url(course_locator) -> str: return export_url +def get_optimizer_url(course_locator) -> str: + """ + Gets course authoring microfrontend URL for optimizer page view. + """ + optimizer_url = None + if enable_course_optimizer(course_locator): + mfe_base_url = get_course_authoring_url(course_locator) + course_mfe_url = f'{mfe_base_url}/course/{course_locator}/optimizer' + if mfe_base_url: + optimizer_url = course_mfe_url + return optimizer_url + + def get_files_uploads_url(course_locator) -> str: """ Gets course authoring microfrontend URL for files and uploads page view. @@ -2340,3 +2357,27 @@ def get_xblock_render_context(request, block): return str(exc) return "" + + +def create_or_update_xblock_upstream_link(xblock, course_key: str | CourseKey, created: datetime | None = None): + """ + Create or update upstream->downstream link in database for given xblock. + """ + if not xblock.upstream: + return None + upstream_usage_key = UsageKeyV2.from_string(xblock.upstream) + try: + lib_component = get_component_from_usage_key(upstream_usage_key) + except ObjectDoesNotExist: + log.error(f"Library component not found for {upstream_usage_key}") + lib_component = None + PublishableEntityLink.update_or_create( + lib_component, + upstream_usage_key=xblock.upstream, + upstream_context_key=str(upstream_usage_key.context_key), + downstream_context_key=course_key, + downstream_usage_key=xblock.usage_key, + version_synced=xblock.upstream_version, + version_declined=xblock.upstream_version_declined, + created=created, + ) diff --git a/cms/djangoapps/contentstore/video_storage_handlers.py b/cms/djangoapps/contentstore/video_storage_handlers.py index 4cc5c738b5d..1433bf2fc3a 100644 --- a/cms/djangoapps/contentstore/video_storage_handlers.py +++ b/cms/djangoapps/contentstore/video_storage_handlers.py @@ -995,9 +995,9 @@ def get_course_youtube_edx_video_ids(course_id): f"InvalidKeyError occurred while getting YouTube video IDs for course_id: {course_id}: {error}" ) return JsonResponse({'error': invalid_key_error_msg}, status=500) - except Exception as error: + except (TypeError, AttributeError) as error: LOGGER.exception( - f"Unexpected error occurred while getting YouTube video IDs for course_id: {course_id}: {error}" + f"Error occurred while getting YouTube video IDs for course_id: {course_id}: {error}" ) return JsonResponse({'error': unexpected_error_msg}, status=500) diff --git a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py index 9244ffa989b..3fae9d996fd 100644 --- a/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py +++ b/cms/djangoapps/contentstore/views/tests/test_clipboard_paste.py @@ -6,6 +6,13 @@ import ddt from opaque_keys.edx.keys import UsageKey from rest_framework.test import APIClient +from openedx_events.content_authoring.signals import ( + LIBRARY_BLOCK_DELETED, + XBLOCK_CREATED, + XBLOCK_DELETED, + XBLOCK_UPDATED, +) +from openedx_events.tests.utils import OpenEdxEventsTestMixin from openedx_tagging.core.tagging.models import Tag from organizations.models import Organization from xmodule.modulestore.django import contentstore, modulestore @@ -393,10 +400,16 @@ def test_paste_with_assets(self): assert source_pic2_hash != dest_pic2_hash # Because there was a conflict, this file was unchanged. -class ClipboardPasteFromV2LibraryTestCase(ModuleStoreTestCase): +class ClipboardPasteFromV2LibraryTestCase(OpenEdxEventsTestMixin, ModuleStoreTestCase): """ Test Clipboard Paste functionality with a "new" (as of Sumac) library """ + ENABLED_OPENEDX_EVENTS = [ + LIBRARY_BLOCK_DELETED.event_type, + XBLOCK_CREATED.event_type, + XBLOCK_DELETED.event_type, + XBLOCK_UPDATED.event_type, + ] def setUp(self): """ @@ -477,6 +490,16 @@ def test_paste_from_library_read_only_tags(self): assert object_tag.value in self.lib_block_tags assert object_tag.is_copied + # If we delete the upstream library block... + library_api.delete_library_block(self.lib_block_key) + + # ...the copied tags remain, but should no longer be marked as "copied" + object_tags = tagging_api.get_object_tags(new_block_key) + assert len(object_tags) == len(self.lib_block_tags) + for object_tag in object_tags: + assert object_tag.value in self.lib_block_tags + assert not object_tag.is_copied + def test_paste_from_library_copies_asset(self): """ Assets from a library component copied into a subdir of Files & Uploads. @@ -555,7 +578,6 @@ def _copy_paste_and_assert_link(key_to_copy): assert new_block.upstream == str(self.lib_block_key) assert new_block.upstream_version == 3 assert new_block.upstream_display_name == "MCQ-draft" - assert new_block.upstream_max_attempts == 5 return new_block_key # first verify link for copied block from library diff --git a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py index 3b9ba5798a0..775f08e5fa2 100644 --- a/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py +++ b/cms/djangoapps/contentstore/xblock_storage_handlers/view_handlers.py @@ -80,6 +80,7 @@ from ..helpers import ( get_parent_xblock, import_staged_content_from_user_clipboard, + import_static_assets_for_library_sync, is_unit, xblock_embed_lms_url, xblock_lms_url, @@ -598,7 +599,7 @@ def _create_block(request): try: # Set `created_block.upstream` and then sync this with the upstream (library) version. created_block.upstream = upstream_ref - sync_from_upstream(downstream=created_block, user=request.user) + lib_block = sync_from_upstream(downstream=created_block, user=request.user) except BadUpstream as exc: _delete_item(created_block.location, request.user) log.exception( @@ -606,8 +607,10 @@ def _create_block(request): f"using provided library_content_key='{upstream_ref}'" ) return JsonResponse({"error": str(exc)}, status=400) + static_file_notices = import_static_assets_for_library_sync(created_block, lib_block, request) modulestore().update_item(created_block, request.user.id) - response['upstreamRef'] = upstream_ref + response["upstreamRef"] = upstream_ref + response["static_file_notices"] = asdict(static_file_notices) return JsonResponse(response) diff --git a/cms/envs/common.py b/cms/envs/common.py index 591247388a9..a39e0656516 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -144,7 +144,7 @@ get_theme_base_dirs_from_settings ) from openedx.core.lib.license import LicenseMixin -from openedx.core.lib.derived import derived, derived_collection_entry +from openedx.core.lib.derived import Derived from openedx.core.release import doc_version # pylint: enable=useless-suppression @@ -740,7 +740,7 @@ # Don't look for template source files inside installed applications. 'APP_DIRS': False, # Instead, look for template source files in these dirs. - 'DIRS': _make_mako_template_dirs, + 'DIRS': Derived(_make_mako_template_dirs), # Options specific to this backend. 'OPTIONS': { 'loaders': ( @@ -759,7 +759,7 @@ 'NAME': 'mako', 'BACKEND': 'common.djangoapps.edxmako.backend.Mako', 'APP_DIRS': False, - 'DIRS': _make_mako_template_dirs, + 'DIRS': Derived(_make_mako_template_dirs), 'OPTIONS': { 'context_processors': CONTEXT_PROCESSORS, 'debug': False, @@ -778,8 +778,6 @@ } }, ] -derived_collection_entry('TEMPLATES', 0, 'DIRS') -derived_collection_entry('TEMPLATES', 1, 'DIRS') DEFAULT_TEMPLATE_ENGINE = TEMPLATES[0] #################################### AWS ####################################### @@ -825,8 +823,7 @@ # Warning: Must have trailing slash to activate correct logout view # (auth_backends, not LMS user_authn) FRONTEND_LOGOUT_URL = '/logout/' -FRONTEND_REGISTER_URL = lambda settings: settings.LMS_ROOT_URL + '/register' -derived('FRONTEND_REGISTER_URL') +FRONTEND_REGISTER_URL = Derived(lambda settings: settings.LMS_ROOT_URL + '/register') LMS_ENROLLMENT_API_PATH = "/api/enrollment/v1/" ENTERPRISE_API_URL = LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/' @@ -843,6 +840,7 @@ # Public domain name of Studio (should be resolvable from the end-user's browser) CMS_BASE = 'localhost:18010' +CMS_ROOT_URL = "https://localhost:18010" LOG_DIR = '/edx/var/log/edx' @@ -1315,8 +1313,7 @@ STATICI18N_FILENAME_FUNCTION = 'statici18n.utils.legacy_filename' STATICI18N_ROOT = PROJECT_ROOT / "static" -LOCALE_PATHS = _make_locale_paths -derived('LOCALE_PATHS') +LOCALE_PATHS = Derived(_make_locale_paths) # Messages MESSAGE_STORAGE = 'django.contrib.messages.storage.session.SessionStorage' @@ -2086,10 +2083,9 @@ # See annotations in lms/envs/common.py for details. RETIRED_EMAIL_DOMAIN = 'retired.invalid' # See annotations in lms/envs/common.py for details. -RETIRED_USERNAME_FMT = lambda settings: settings.RETIRED_USERNAME_PREFIX + '{}' +RETIRED_USERNAME_FMT = Derived(lambda settings: settings.RETIRED_USERNAME_PREFIX + '{}') # See annotations in lms/envs/common.py for details. -RETIRED_EMAIL_FMT = lambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN -derived('RETIRED_USERNAME_FMT', 'RETIRED_EMAIL_FMT') +RETIRED_EMAIL_FMT = Derived(lambda settings: settings.RETIRED_EMAIL_PREFIX + '{}@' + settings.RETIRED_EMAIL_DOMAIN) # See annotations in lms/envs/common.py for details. RETIRED_USER_SALTS = ['abc', '123'] # See annotations in lms/envs/common.py for details. @@ -2366,13 +2362,12 @@ ############## Settings for Studio Context Sensitive Help ############## HELP_TOKENS_INI_FILE = REPO_ROOT / "cms" / "envs" / "help_tokens.ini" -HELP_TOKENS_LANGUAGE_CODE = lambda settings: settings.LANGUAGE_CODE -HELP_TOKENS_VERSION = lambda settings: doc_version() +HELP_TOKENS_LANGUAGE_CODE = Derived(lambda settings: settings.LANGUAGE_CODE) +HELP_TOKENS_VERSION = Derived(lambda settings: doc_version()) HELP_TOKENS_BOOKS = { 'learner': 'https://edx.readthedocs.io/projects/open-edx-learner-guide', 'course_author': 'https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course', } -derived('HELP_TOKENS_LANGUAGE_CODE', 'HELP_TOKENS_VERSION') # Used with Email sending RETRY_ACTIVATION_EMAIL_MAX_ATTEMPTS = 5 @@ -2520,6 +2515,8 @@ CREDENTIALS_INTERNAL_SERVICE_URL = 'http://localhost:8005' CREDENTIALS_PUBLIC_SERVICE_URL = 'http://localhost:8005' CREDENTIALS_SERVICE_USERNAME = 'credentials_service_user' +# time between scheduled runs, in seconds +NOTIFY_CREDENTIALS_FREQUENCY = 14400 ANALYTICS_DASHBOARD_URL = 'http://localhost:18110/courses' ANALYTICS_DASHBOARD_NAME = 'Your Platform Name Here Insights' @@ -2873,15 +2870,15 @@ def _should_send_learning_badge_events(settings): }, 'org.openedx.content_authoring.xblock.published.v1': { 'course-authoring-xblock-lifecycle': - {'event_key_field': 'xblock_info.usage_key', 'enabled': _should_send_xblock_events}, + {'event_key_field': 'xblock_info.usage_key', 'enabled': Derived(_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}, + {'event_key_field': 'xblock_info.usage_key', 'enabled': Derived(_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}, + {'event_key_field': 'xblock_info.usage_key', 'enabled': Derived(_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. @@ -2896,38 +2893,17 @@ def _should_send_learning_badge_events(settings): "org.openedx.learning.course.passing.status.updated.v1": { "learning-badges-lifecycle": { "event_key_field": "course_passing_status.course.course_key", - "enabled": _should_send_learning_badge_events, + "enabled": Derived(_should_send_learning_badge_events), }, }, "org.openedx.learning.ccx.course.passing.status.updated.v1": { "learning-badges-lifecycle": { "event_key_field": "course_passing_status.course.ccx_course_key", - "enabled": _should_send_learning_badge_events, + "enabled": Derived(_should_send_learning_badge_events), }, }, } - -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') - -derived_collection_entry( - "EVENT_BUS_PRODUCER_CONFIG", - "org.openedx.learning.course.passing.status.updated.v1", - "learning-badges-lifecycle", - "enabled", -) -derived_collection_entry( - "EVENT_BUS_PRODUCER_CONFIG", - "org.openedx.learning.ccx.course.passing.status.updated.v1", - "learning-badges-lifecycle", - "enabled", -) - ################### Authoring API ###################### # This affects the Authoring API swagger docs but not the legacy swagger docs under /api-docs/. diff --git a/cms/envs/mock.yml b/cms/envs/mock.yml new file mode 100644 index 00000000000..b8d480d6a13 --- /dev/null +++ b/cms/envs/mock.yml @@ -0,0 +1,834 @@ +# This is a mock configuration file used for testing the +# settings-loading code in production.py. +# +# WARNING: Do not use this in production -- it contains randomized and +# nonsensical values. + + +ACTIVATION_EMAIL_SUPPORT_LINK: https://support.localhost/hc/en-us/articles/227340127-Why-haven-t-I-received-my-activation-email- +AFFILIATE_COOKIE_NAME: affiliate_id +AI_TRANSLATIONS_API_URL: https://ai-translations.localhost/api/v1 +AI_TRANSLATIONS_URL_ROOT: https://ai-translations.localhost +ALLOWED_HOSTS: +- hello +ALTERNATE_WORKER_QUEUES: lms +ANALYTICS_DASHBOARD_NAME: Insights +ANALYTICS_DASHBOARD_URL: https://insights.localhost +AUTHORING_API_URL: https://api.localhost/authoring +AUTH_PASSWORD_VALIDATORS: +- NAME: django.contrib.auth.password_validation.CommonPasswordValidator +- NAME: django.contrib.auth.password_validation.UserAttributeSimilarityValidator +- NAME: common.djangoapps.util.password_policy_validators.MinimumLengthValidator + OPTIONS: + min_length: 8 +- NAME: common.djangoapps.util.password_policy_validators.MaximumLengthValidator + OPTIONS: + max_length: 100 +- NAME: common.djangoapps.util.password_policy_validators.AlphabeticValidator + OPTIONS: + min_alphabetic: 1 +- NAME: common.djangoapps.util.password_policy_validators.NumericValidator + OPTIONS: + min_numeric: 1 +AWS_ACCESS_KEY_ID: null +AWS_QUERYSTRING_AUTH: true +AWS_S3_CUSTOM_DOMAIN: test.s3.amazonaws.com +AWS_SECRET_ACCESS_KEY: null +AWS_SES_REGION_ENDPOINT: test.amazonaws.com +AWS_SES_REGION_NAME: us-east-1 +AWS_STORAGE_BUCKET_NAME: test +BASE_COOKIE_DOMAIN: .localhost +BEAMER_PRODUCT_ID: test_beamer_product +BIG_BLUE_BUTTON_GLOBAL_KEY: test_bluebutton_key +BIG_BLUE_BUTTON_GLOBAL_SECRET: test_bluebutton_secret +BIG_BLUE_BUTTON_GLOBAL_URL: https://dev.com/ +BLOCK_STRUCTURES_SETTINGS: + COURSE_PUBLISH_TASK_DELAY: 30 + DIRECTORY_PREFIX: courses/ + PRUNING_ACTIVE: true + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: block-structures + TASK_DEFAULT_RETRY_DELAY: 30 + TASK_MAX_RETRIES: 5 +BRANCH_IO_KEY: test_BRANCH_IO_KEY +BRAZE_COURSE_ENROLLMENT_CANVAS_ID: test_canvas_id +BUGS_EMAIL: bugs@example.com +BULK_EMAIL_DEFAULT_FROM_EMAIL: no-reply@courseupdates.localhost +BULK_EMAIL_LOG_SENT_EMAILS: true +BUNDLE_ASSET_STORAGE_SETTINGS: + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: settings + custom_domain: null + default_acl: null + location: '' + querystring_auth: true + querystring_expire: 172800 + signature_version: s3v4 +BUNDLE_ASSET_URL_STORAGE_KEY: test_storage_key +BUNDLE_ASSET_URL_STORAGE_SECRET: test_storage_secret +CACHES: + celery: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: celery + LOCATION: + - test.amazonaws.com:11211 + - test.amazonaws.com:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + TIMEOUT: '7200' + configuration: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: ip-0 + LOCATION: + - test.amazonaws.com:11211 + - test.amazonaws.com:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + course_structure_cache: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: course_structure + LOCATION: + - test.amazonaws.com:11211 + - test.amazonaws.com:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + TIMEOUT: null + default: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: default + LOCATION: + - test.amazonaws.com:11211 + - test.amazonaws.com:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + VERSION: bb6cd7d + general: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: general + LOCATION: + - test.amazonaws.com:11211 + - test.amazonaws.com:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + mongo_metadata_inheritance: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: mongo_metadata_inheritance + LOCATION: + - test.amazonaws.com:11211 + - test.amazonaws.com:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true + TIMEOUT: 300 + staticfiles: + BACKEND: django.core.cache.backends.memcached.PyMemcacheCache + KEY_FUNCTION: common.djangoapps.util.memcache.safe_key + KEY_PREFIX: ip-0 + LOCATION: + - test.amazonaws.com:11211 + - test.amazonaws.com:11211 + OPTIONS: + connect_timeout: 0.5 + ignore_exc: true + no_delay: true + use_pooling: true +CELERY_BROKER_HOSTNAME: celery.cache.amazonaws.com:6379 +CELERY_BROKER_PASSWORD: '' +CELERY_BROKER_TRANSPORT: redis +CELERY_BROKER_USER: '' +CELERY_BROKER_USE_SSL: true +CELERY_BROKER_VHOST: 0 +CELERY_EVENT_QUEUE_TTL: 5 +CLOSEST_CLIENT_IP_FROM_HEADERS: +- index: 0 + name: CF-Connecting-IP +CMS_BASE: studio.localhost +CODE_JAIL: + limit_overrides: + codejail_expanded_limits: + CPU: 10 + FSIZE: 2097152 + PROXY: 1 + REALTIME: 60 + VMEM: 1073741824 + course-v1:Org+Course+Run: + CPU: 5 + FSIZE: 5 + PROXY: 5 + REALTIME: 5 + VMEM: 5 + limits: + CPU: 2 + FSIZE: 1048576 + PROXY: 1 + REALTIME: 6 + VMEM: 536870912 + python_bin: /edx/app/edxapp/venvs/edxapp-sandbox/bin/python + user: sandbox +COMMENTS_SERVICE_KEY: test_COMMENTS_SERVICE_KEY +COMMENTS_SERVICE_URL: https://forum.localhost +COMPLETION_VIDEO_COMPLETE_PERCENTAGE: 0.9 +COMPREHENSIVE_THEME_DIRS: [] +COMPREHENSIVE_THEME_LOCALE_PATHS: +- /edx/var/edx-themes/edx-themes/conf/locale +CONFIG_WATCHER_SERVICE_NAME: CMS +CONFIG_WATCHER_SLACK_WEBHOOK_URL: test_CONFIG_WATCHER_SLACK_WEBHOOK_URL +CONTACT_EMAIL: info@example.com +CONTENTSTORE: + ADDITIONAL_OPTIONS: + trashcan: + bucket: trash + DOC_STORE_CONFIG: + auth_source: null + collection: modulestore + connectTimeoutMS: 2000 + db: db + host: 127.0.0.1 + password: test_password + port: 27017 + read_preference: PRIMARY + replicaSet: rs0 + socketTimeoutMS: 3000 + ssl: true + user: user + ENGINE: xmodule.contentstore.mongo.MongoContentStore + OPTIONS: + auth_source: null + db: db + host: 127.0.0.1 + password: test_password + port: 27017 + ssl: true + user: user +COOKIE_HEADER_SIZE_LOGGING_THRESHOLD: 9000 +COOKIE_SAMPLING_REQUEST_COUNT: 600 +CORS_ORIGIN_WHITELIST: +- https://localhost +- https://www.localhost +COURSE_AUTHORING_MICROFRONTEND_URL: https://course-authoring.localhost +COURSE_CATALOG_API_URL: https://discovery.localhost/api/v1 +COURSE_CATALOG_URL_ROOT: https://discovery.localhost +COURSE_IMPORT_EXPORT_BUCKET: import-export +COURSE_METADATA_EXPORT_BUCKET: export +COURSE_OLX_VALIDATION_IGNORE_LIST: +- InvalidHTML +COURSE_OLX_VALIDATION_STAGE: 1 +CREDENTIALS_INTERNAL_SERVICE_URL: https://credentials.localhost +CREDENTIALS_PUBLIC_SERVICE_URL: https://credentials.localhost +CREDIT_PROVIDER_SECRET_KEYS: + org: + - '[encrypted]' +CROSS_DOMAIN_CSRF_COOKIE_DOMAIN: .localhost +CROSS_DOMAIN_CSRF_COOKIE_NAME: csrftoken +CSRF_COOKIE_SECURE: true +CSRF_TRUSTED_ORIGINS: +- .localhost +CSRF_TRUSTED_ORIGINS_WITH_SCHEME: +- https://*.localhost +DATABASES: + blockstore: + CONN_MAX_AGE: 600 + ENGINE: django.db.backends.mysql + HOST: localhost + NAME: blockstore + OPTIONS: + connect_timeout: 10 + init_command: SET sql_mode='STRICT_TRANS_TABLES', NAMES utf8mb4 + PASSWORD: test_password + PORT: '3306' + USER: user + default: + ATOMIC_REQUESTS: true + CONN_MAX_AGE: 600 + ENGINE: django.db.backends.mysql + HOST: localhost + NAME: default + OPTIONS: + charset: utf8mb4 + collation: utf8mb4_unicode_ci + PASSWORD: test_password + PORT: '3306' + USER: user + read_replica: + CONN_MAX_AGE: 600 + ENGINE: django.db.backends.mysql + HOST: localhost + NAME: read + OPTIONS: {} + PASSWORD: test_password + PORT: '3306' + USER: user + student_module_history: + CONN_MAX_AGE: 600 + ENGINE: django.db.backends.mysql + HOST: localhost + NAME: smh + OPTIONS: {} + PASSWORD: test_password + PORT: '3306' + USER: user +DATA_DIR: /edx/var/edxapp +DEFAULT_FEEDBACK_EMAIL: feedback@example.com +DEFAULT_FILE_STORAGE: storages.backends.s3boto3.S3Boto3Storage +DEFAULT_FROM_EMAIL: no-reply@registration.localhost +DEFAULT_HASHING_ALGORITHM: sha256 +DEFAULT_JWT_ISSUER: + AUDIENCE: test_password + ISSUER: https://courses.localhost/oauth2 + SECRET_KEY: test_secret_key +DEFAULT_SITE_THEME: localhost +DISABLED_COUNTRIES: +- US +DISCUSSIONS_INCONTEXT_FEEDBACK_URL: test_url +DISCUSSIONS_INCONTEXT_LEARNMORE_URL: test_learnmore_url +DISCUSSIONS_MFE_FEEDBACK_URL: https://bit.ly/ +DISCUSSIONS_MICROFRONTEND_URL: https://discussions.localhost +DJFS: + bucket: storage + prefix: prefix/ + type: s3fs +DOC_STORE_CONFIG: + auth_source: null + collection: modulestore + connectTimeoutMS: 2000 + db: db + host: localhost + password: test_secret_key + port: 27017 + read_preference: PRIMARY + replicaSet: rs0 + socketTimeoutMS: 3000 + ssl: true + user: user +ECOMMERCE_API_SIGNING_KEY: test_secret_key +ECOMMERCE_API_URL: https://ecommerce.localhost/api/v2/ +ECOMMERCE_PUBLIC_URL_ROOT: https://ecommerce.localhost +EDXMKTG_USER_INFO_COOKIE_NAME: user-info +EDX_BRAZE_API_KEY: test_braze_key +EDX_BRAZE_API_SERVER: https://braze.com +EDX_REST_API_CLIENT_NAME: edxapp-cms +ELASTIC_SEARCH_CONFIG: +- host: test.amazonaws.com + port: 443 + use_ssl: true +ELASTIC_SEARCH_CONFIG_ES7: +- host: test.amazonaws.com + port: 443 + use_ssl: true +EMAIL_BACKEND: django_ses.SESBackend +EMAIL_HOST: localhost +EMAIL_HOST_PASSWORD: '' +EMAIL_HOST_USER: '' +EMAIL_PORT: 25 +EMAIL_USE_TLS: true +ENABLE_COMPREHENSIVE_THEMING: true +ENTERPRISE_API_URL: https://courses.localhost/enterprise/api/v1 +ENTERPRISE_CATALOG_INTERNAL_ROOT_URL: https://enterprise-catalog.localhost +ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS: + utm_campaign: localhost Referral + utm_medium: Footer + utm_source: localhost +EVENTS_SERVICE_NAME: cms +EVENT_BUS_KAFKA_API_KEY: test_kafka_key +EVENT_BUS_KAFKA_API_SECRET: test_kafka_secret +EVENT_BUS_KAFKA_BOOTSTRAP_SERVERS: testconfluent.cloud:9092 +EVENT_BUS_KAFKA_SCHEMA_REGISTRY_API_KEY: test_kafka__api_key +EVENT_BUS_KAFKA_SCHEMA_REGISTRY_API_SECRET: test_schema_registry_secret +EVENT_BUS_KAFKA_SCHEMA_REGISTRY_URL: https://test.confluent.cloud +EVENT_BUS_PRODUCER: edx_event_bus_kafka.create_producer +EVENT_BUS_PRODUCER_CONFIG: + org.openedx.content_authoring.course.catalog_info.changed.v1: + course-catalog-info-changed: + enabled: true + event_key_field: catalog_info.course_key + org.openedx.learning.user.course_access_role.added.v1: + learning-course-access-role-lifecycle: + enabled: true + event_key_field: course_access_role_data.course_key + org.openedx.learning.user.course_access_role.removed.v1: + learning-course-access-role-lifecycle: + enabled: true + event_key_field: course_access_role_data.course_key +EVENT_BUS_TOPIC_PREFIX: prefix +EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST: +- hello +EXAMS_MICROFRONTEND_URL: https://exams-dashboard.localhost +EXAMS_SERVICE_URL: https://edx-exams.localhost/api/v1 +EXTRA_MIDDLEWARE_CLASSES: [] +FACEBOOK_APP_ID: test_facebook_app_id +FACEBOOK_APP_SECRET: test_facebook_app_secret +FAVICON_URL: https://cdn.org/favicon.ico +FEATURES: + ALLOW_COURSE_RERUNS: true + ALLOW_COURSE_STAFF_GRADE_DOWNLOADS: true + CERTIFICATES_HTML_VIEW: true + CERTIFICATES_INSTRUCTOR_GENERATION: true + COURSEWARE_SEARCH_INCLUSION_DATE: '2024-01-01' + CUSTOM_CERTIFICATE_TEMPLATES_ENABLED: true + CUSTOM_COURSES_EDX: true + DEPRECATE_OLD_COURSE_KEYS_IN_STUDIO: true + DISABLE_COURSE_CREATION: true + DISABLE_HONOR_CERTIFICATES: true + DISABLE_LIBRARY_CREATION: true + DISABLE_MOBILE_COURSE_AVAILABLE: true + DISPLAY_ANALYTICS_DEMOGRAPHICS: true + DISPLAY_ANALYTICS_ENROLLMENTS: true + EDITABLE_SHORT_DESCRIPTION: true + EMBARGO: true + ENABLE_ANALYTICS_ACTIVE_COUNT: true + ENABLE_API_DOCS: true + ENABLE_ASYNC_ANSWER_DISTRIBUTION: true + ENABLE_COMBINED_LOGIN_REGISTRATION: true + ENABLE_CONTENT_LIBRARIES_LTI_TOOL: true + ENABLE_CORS_HEADERS: true + ENABLE_COUNTRY_ACCESS: true + ENABLE_COURSEWARE_INDEX: true + ENABLE_COURSE_OLX_VALIDATION: true + ENABLE_CREATOR_GROUP: true + ENABLE_CREDIT_API: true + ENABLE_CREDIT_ELIGIBILITY: true + ENABLE_CROSS_DOMAIN_CSRF_COOKIE: true + ENABLE_CSMH_EXTENDED: true + ENABLE_DEBUG_RUN_PYTHON: true + ENABLE_DISCUSSION_HOME_PANEL: true + ENABLE_DISCUSSION_SERVICE: true + ENABLE_EDXNOTES: true + ENABLE_ENTERPRISE_INTEGRATION: true + ENABLE_EXAM_SETTINGS_HTML_VIEW: true + ENABLE_EXPORT_GIT: true + ENABLE_FEEDBACK_SUBMISSION: true + ENABLE_FINANCIAL_ASSISTANCE_FORM: true + ENABLE_FOOTER_MOBILE_APP_LINKS: true + ENABLE_FORUM_DAILY_DIGEST: true + ENABLE_GRADE_DOWNLOADS: true + ENABLE_INSTRUCTOR_ANALYTICS: true + ENABLE_INSTRUCTOR_BETA_DASHBOARD: true + ENABLE_INSTRUCTOR_LEGACY_DASHBOARD: true + ENABLE_INTEGRITY_SIGNATURE: true + ENABLE_LIBRARY_AUTHORING_MICROFRONTEND: true + ENABLE_LTI_PROVIDER: true + ENABLE_MAX_FAILED_LOGIN_ATTEMPTS: true + ENABLE_MKTG_EMAIL_OPT_IN: true + ENABLE_MKTG_SITE: true + ENABLE_MOBILE_REST_API: true + ENABLE_OAUTH2_PROVIDER: true + ENABLE_PASSWORD_RESET_FAILURE_EMAIL: true + ENABLE_PROCTORED_EXAMS: true + ENABLE_PUBLISHER: true + ENABLE_READING_FROM_MULTIPLE_HISTORY_TABLES: true + ENABLE_SEND_XBLOCK_EVENTS_OVER_BUS: true + ENABLE_SEND_XBLOCK_LIFECYCLE_EVENTS_OVER_BUS: true + ENABLE_SERVICE_STATUS: true + ENABLE_SPECIAL_EXAMS: true + ENABLE_THIRD_PARTY_AUTH: true + ENABLE_V2_CERT_DISPLAY_SETTINGS: true + ENABLE_VERIFIED_CERTIFICATES: true + ENABLE_VIDEO_ABSTRACTION_LAYER_API: true + ENABLE_VIDEO_BUMPER: true + ENABLE_VIDEO_UPLOAD_PIPELINE: true + ENABLE_XBLOCK_VIEW_ENDPOINT: true + FRONTEND_APP_PUBLISHER_URL: https://publisher.localhost + IS_EDX_DOMAIN: true + LICENSING: true + LTI_1P3_ENABLED: true + MILESTONES_APP: true + PREVENT_CONCURRENT_LOGINS: true + PREVIEW_LMS_BASE: preview.courses.localhost + SEGMENT_IO_LMS: true + SEPARATE_VERIFICATION_FROM_PAYMENT: true + SHOW_FOOTER_LANGUAGE_SELECTOR: true + SQUELCH_PII_IN_LOGS: true + STUDIO_REQUEST_EMAIL: studio-request@example.com +FEEDBACK_SUBMISSION_EMAIL: bugs@example.com +FERNET_KEYS: +- test_fernet_key +FILE_UPLOAD_STORAGE_BUCKET_NAME: upload +FILE_UPLOAD_STORAGE_PREFIX: submission_attachments +FINANCIAL_REPORTS: + BUCKET: reports + CUSTOM_DOMAIN: test.s3.amazonaws.com + ROOT_PATH: env + STORAGE_TYPE: s3 +FOOTER_ORGANIZATION_IMAGE: null +GITHUB_REPO_ROOT: /edx/var/edxapp/data +GRADES_DOWNLOAD: + BUCKET: '' + ROOT_PATH: '' + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: grades + custom_domain: null + default_acl: private + gzip: true + location: env + querystring_auth: true + querystring_expire: 300 + STORAGE_TYPE: '' +HELP_TOKENS_BOOKS: + course_author: https://edx.readthedocs.io/projects/edx-partner-course-staff + learner: https://edx.readthedocs.io/projects/edx-guide-for-students +HOTJAR_ID: 7890 +ICP_LICENSE: icp_license +ICP_LICENSE_INFO: + icp_license: icp_license + icp_license_link: https://beian.miit.gov.cn + text: icp_license_text +IDA_LOGOUT_URI_LIST: +- https://ecommerce.localhost/logout/ +- https://credentials.localhost/logout/ +- https://discovery.localhost/logout/ +- https://commerce-coordinator.localhost/logout/ +ID_VERIFICATION_SUPPORT_LINK: https://support.localhost/hc/en-us/articles/206503858-How-do-I-complete-photo-verification- +JS_ENV_EXTRA_CONFIG: {} +JWT_AUTH: + JWT_AUDIENCE: test_jwt_audience + JWT_AUTH_COOKIE_HEADER_PAYLOAD: jwt-cookie-header-payload + JWT_AUTH_COOKIE_SIGNATURE: jwt-cookie-signature + JWT_ISSUER: https://courses.localhost/oauth2 + JWT_ISSUERS: + - AUDIENCE: test_jwt_issuers + ISSUER: https://courses.localhost/oauth2 + SECRET_KEY: test_jwt_issuers + JWT_PUBLIC_SIGNING_JWK_SET: '{"keys": [{"n": "test-key", "kty": "RSA", "e": "AQAB", + "kid": "lms001"}]}' + JWT_SECRET_KEY: test_JWT_SECRET_KEY + JWT_SIGNING_ALGORITHM: RS512 +JWT_ISSUER: https://courses.localhost/oauth2 +LANGUAGE_COOKIE: language-preference +LEARNER_PORTAL_URL_ROOT: https://masters.localhost +LEARNER_RECORD_MICROFRONTEND_URL: https://records.localhost +LEARNING_ASSISTANT_AUDIT_TRIAL_LENGTH_DAYS: 14 +LEARNING_ASSISTANT_AVAILABLE: true +LEARNING_MICROFRONTEND_URL: https://learning.localhost +LIBRARY_AUTHORING_MICROFRONTEND_URL: https://library-authoring.localhost +LMS_BASE: courses.localhost +LMS_INTERNAL_ROOT_URL: https://courses.localhost +LMS_ROOT_URL: https://courses.localhost +LOGGING_ENV: edxapp +LOGIN_REDIRECT_WHITELIST: +- https://localhost +- https://courses.localhost +- https://ecommerce.localhost +- https://studio.localhost +LOGO_TRADEMARK_URL: https://cdn.org/v3/logo-trademark.svg +LOGO_TRADEMARK_URL_PNG: https://cdn.org/v3/logo-trademark.png +LOGO_TRADEMARK_URL_SVG: https://cdn.org/v3/logo-trademark.svg +LOGO_URL: https://cdn.org/v3/logo.svg +LOGO_URL_PNG: https://cdn.org/v3/logo.png +LOGO_URL_SVG: https://cdn.org/v3/logo.svg +LOGO_WHITE_URL: https://cdn.org/v3/logo-white.svg +LOGO_WHITE_URL_PNG: https://cdn.org/v3/logo-white.png +LOGO_WHITE_URL_SVG: https://cdn.org/v3/logo-white.svg +LOG_DIR: /edx/var/log/edx +MAINTENANCE_BANNER_TEXT: "System maintenance is scheduled for Wednesday, March 28,\ + \ 2018 from 15:00\u201316:00 UTC. Courses might not be available during this\ + \ time.\n" +MEDIA_URL: http://s3.amazonaws.com/something/ +MKTG_URLS: + ABOUT: /about-us + ACCESSIBILITY: /accessibility + AFFILIATES: /affiliate-program + BLOG: /resources + CAREERS: /careers + CCPA: https://www.localhost/?opendns=true + CONTACT: /support/contact_us + COOKIE: /privacy-policy/cookies + COURSES: /course + DONATE: /donate + ENTERPRISE: https://business.localhost + FAQ: /student-faq + HONOR: /terms-service + HOW_IT_WORKS: /how-it-works + MEDIA_KIT: /media-kit + NEWS: /news-announcements + PRESS: /press + PRIVACY: /privacy-policy + ROOT: https://localhost + SCHOOLS: /schools-partners + SITE_MAP: /sitemap + TOS: /terms-service + TOS_AND_HONOR: /terms-service + TRADEMARKS: /trademarks + WHAT_IS_VERIFIED_CERT: /verified-certificate +MOBILE_STORE_URLS: + apple: store_url + google: strore_url +MODULESTORE: + default: + ENGINE: xmodule.modulestore.mixed.MixedModuleStore + OPTIONS: + mappings: {} + stores: + - DOC_STORE_CONFIG: + auth_source: null + collection: modulestore + connectTimeoutMS: 2000 + db: db + host: 127.0.0.1 + password: test_password + port: 27017 + read_preference: PRIMARY + replicaSet: rs0 + socketTimeoutMS: 3000 + ssl: true + user: user + ENGINE: xmodule.modulestore.split_mongo.split_draft.DraftVersioningModuleStore + NAME: split + OPTIONS: + default_class: xmodule.hidden_block.HiddenBlock + fs_root: /edx/var/edxapp/data + render_template: common.djangoapps.edxmako.shortcuts.render_to_string + - DOC_STORE_CONFIG: + auth_source: null + collection: modulestore + connectTimeoutMS: 2000 + db: db + host: 127.0.0.1 + password: test_password + port: 27017 + read_preference: PRIMARY + replicaSet: rs0 + socketTimeoutMS: 3000 + ssl: true + user: user + ENGINE: xmodule.modulestore.mongo.DraftMongoModuleStore + NAME: draft + OPTIONS: + default_class: xmodule.hidden_block.HiddenBlock + fs_root: /edx/var/edxapp/data + render_template: common.djangoapps.edxmako.shortcuts.render_to_string +OPENEDX_TELEMETRY: +- edx_django_utils.monitoring.NewRelicBackend +- edx_django_utils.monitoring.DatadogBackend +OPENEDX_TELEMETRY_FRONTEND_SCRIPTS: "\n\n" +OPTIMIZELY_FULLSTACK_SDK_KEY: test_optimizely_key +ORA2_FILE_PREFIX: ora2 +ORA_GRADING_MICROFRONTEND_URL: https://ora-grading.localhost +ORA_MICROFRONTEND_URL: https://ora.localhost +ORGANIZATIONS_AUTOCREATE: true +PARSE_KEYS: + APPLICATION_ID: test_app_id + REST_API_KEY: test_rest_api_key +PARTNER_SUPPORT_EMAIL: partner-support@example.com +PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG: + ELEVATED_PRIVILEGE_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' + ENFORCE_COMPLIANCE_ON_LOGIN: true + GENERAL_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' + STAFF_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' +PASSWORD_RESET_SUPPORT_LINK: https://support.localhost/hc/en-us/articles/206212088-What-if-I-did-not-receive-a-password-reset-message +PAYMENT_SUPPORT_EMAIL: billing@example.com +PLATFORM_FACEBOOK_ACCOUNT: http://www.facebook.com/ +PLATFORM_NAME: Name +PLATFORM_TWITTER_ACCOUNT: '@name' +POLICY_CHANGE_GRADES_ROUTING_KEY: edx.lms.core.grades_policy_change +PRESS_EMAIL: press@example.com +PROCTORING_BACKENDS: + DEFAULT: proctortrack + proctortrack: + base_url: https://testing.verificient.com + client_id: test_client_id + client_secret: test_client_secret + integration_specific_email: proctortrack-support@example.com +PROCTORING_SETTINGS: + ALLOW_CALLBACK_SIMULATION: true + LINK_URLS: + contact_us: https://www.localhost/contact-us + course_authoring_faq: https://edx.readthedocs.io/projects/open-edx-building-and-running-a-course/en/latest/proctored_exams/overview.html + faq: https://help.localhost/edxlearner/s/article/How-do-proctored-exams-work + online_proctoring_rules: https://help.localhost/edxlearner/s/article/Proctored-exam-rules-and-requirements + tech_requirements: https://help.localhost/edxlearner/s/article/Proctored-exam-rules-and-requirements + SOFTWARE_SECURE_CLIENT_TIMEOUT: 5 +PROCTORING_USER_OBFUSCATION_KEY: test_user_obfuscation_key +REGISTRATION_EXTRA_FIELDS: + city: hidden + country: optional + gender: optional + goals: optional + honor_code: required + level_of_education: optional + mailing_address: hidden + year_of_birth: optional +REGISTRATION_RATELIMIT: 10 +REGISTRATION_VALIDATION_RATELIMIT: 10 +REST_FRAMEWORK: + NUM_PROXIES: 2 +RETIRED_USER_SALTS: +- test_retired_user_salts +- test_retired_user_salts_2 +RETIREMENT_SERVICE_WORKER_USERNAME: retirement_service_worker +RETIREMENT_STATES: +- PENDING +- RETIRING_FORUMS +- FORUMS_COMPLETE +- RETIRING_SALESFORCE_LEADS +- SALESFORCE_LEADS_COMPLETE +- RETIRING_SEGMENT +- SEGMENT_COMPLETE +- RETIRING_HUBSPOT +- HUBSPOT_COMPLETE +- RETIRING_BRAZE +- BRAZE_COMPLETE +- RETIRING_ENROLLMENTS +- ENROLLMENTS_COMPLETE +- RETIRING_NOTES +- NOTES_COMPLETE +- RETIRING_PROCTORING +- PROCTORING_COMPLETE +- RETIRING_LICENSE_MANAGER +- LICENSE_MANAGER_COMPLETE +- RETIRING_LMS_MISC +- LMS_MISC_COMPLETE +- RETIRING_LMS +- LMS_COMPLETE +- ADDING_TO_PARTNER_QUEUE +- PARTNER_QUEUE_COMPLETE +- ERRORED +- ABORTED +- COMPLETE +SECRET_KEY: test_secret_key +SEGMENT_KEY: test_segment_key +SEND_CATALOG_INFO_SIGNAL: true +SERVER_EMAIL: devops@example.com +SESSION_COOKIE_NAME: studio_sessionid +SESSION_COOKIE_SECURE: true +SGA_STORAGE_SETTINGS: + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + default_acl: private +SITE_NAME: studio.localhost +SKILLS_MICROFRONTEND_URL: https://skills.localhost +SOCIAL_AUTH_EDX_OAUTH2_KEY: test_social_auth +SOCIAL_AUTH_EDX_OAUTH2_PUBLIC_URL_ROOT: https://courses.localhost +SOCIAL_AUTH_EDX_OAUTH2_SECRET: test_oauth2_secret +SOCIAL_AUTH_EDX_OAUTH2_URL_ROOT: https://courses.localhost +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: test_saml_key +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT: + one: '[encrypted]' + another: '[encrypted]' +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: test_saml_public_cert +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT: + one: hello + another: hello +SOCIAL_MEDIA_FOOTER_URLS: + facebook: http://www.facebook.com/ + instagram: https://www.instagram.com + linkedin: http://www.linkedin.com/company/ + meetup: http://www.meetup.com/ + reddit: http://www.reddit.com + tumblr: http://tumblr.com/ + twitter: https://twitter.com/ + youtube: https://www.youtube.com/ +SOCIAL_SHARING_SETTINGS: + CERTIFICATE_FACEBOOK: true + CERTIFICATE_FACEBOOK_TEXT: 'I just earned a certifcate! Check it out: ' + CERTIFICATE_TWITTER: true + CERTIFICATE_TWITTER_TEXT: 'I just earned a Certificate !Check it out: ' + CUSTOM_COURSE_URLS: true + DASHBOARD_FACEBOOK: true + DASHBOARD_TWITTER: true +SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS: 5 +STATICFILES_STORAGE_KWARGS: + openedx.core.storage.ProductionS3Storage: + bucket_name: static + default_acl: null +STATIC_ROOT_BASE: /edx/var/edxapp/staticfiles +STATIC_URL_BASE: /static/ +STUDIO_NAME: Studio +SUPPORT_SITE_LINK: https://support.localhost +SURVEY_REPORT_ENABLE: true +SURVEY_REPORT_ENDPOINT: http://localhost:0 +SYSTEM_WIDE_ROLE_CLASSES: +- enterprise.SystemWideEnterpriseUserRoleAssignment +TECH_SUPPORT_EMAIL: technical@example.com +TIME_ZONE: America/New_York +UNIVERSITY_EMAIL: university@example.com +UPDATE_SEARCH_INDEX_JOB_QUEUE: cms.core.search_index +USERNAME_REPLACEMENT_WORKER: username_replacement_service_worker +VIDEO_CDN_URL: + CN: https://video.localhost + default: https://video.localhost +VIDEO_IMAGE_SETTINGS: + DIRECTORY_PREFIX: video-images/ + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: image + custom_domain: localhost + default_acl: public-read + object_parameters: + CacheControl: max-age-31536000 + VIDEO_IMAGE_MAX_BYTES: 2097152 + VIDEO_IMAGE_MIN_BYTES: 2048 +VIDEO_TRANSCRIPTS_SETTINGS: + DIRECTORY_PREFIX: video-transcripts/ + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: transcripts + custom_domain: localhost + default_acl: public-read + object_parameters: + CacheControl: max-age-31536000 + VIDEO_TRANSCRIPTS_MAX_BYTES: 3145728 +VIDEO_UPLOAD_PIPELINE: + BUCKET: upload + CONCURRENT_UPLOAD_LIMIT: 4 + ROOT_PATH: unprocessed + VEM_S3_BUCKET: vem +XBLOCK_FS_STORAGE_BUCKET: xblock +XBLOCK_FS_STORAGE_PREFIX: prefix +XBLOCK_HANDLER_TOKEN_KEYS: +- test_token_key +XBLOCK_SETTINGS: + AcclaimBadgeXBlock: + ORG: + api_key: '[encrypted]' + id: hello + ScormXBlock: + S3_BUCKET_NAME: scorm + STORAGE_FUNC: storage.s3 +XQUEUE_INTERFACE: + basic_auth: + - user + - pass + django_auth: + password: pass + username: user + url: https://xqueue.localhost +YOUTUBE_API_KEY: test_youtube +ZENDESK_API_KEY: test_zendesk +ZENDESK_CUSTOM_FIELDS: + course_id: 0 + enrollment_mode: 0 + enterprise_customer_name: 0 + referrer: 0 +ZENDESK_GROUP_ID_MAPPING: + Financial Assistance: fin_assistance +ZENDESK_OAUTH_ACCESS_TOKEN: test_zendesk_access +ZENDESK_URL: https://zendesk.com +ZENDESK_USER: daemon@example.com + diff --git a/cms/envs/production.py b/cms/envs/production.py index ad7667772f9..da5642b53c6 100644 --- a/cms/envs/production.py +++ b/cms/envs/production.py @@ -163,6 +163,7 @@ def get_env_setting(setting): CMS_BASE = ENV_TOKENS.get('CMS_BASE') LMS_BASE = ENV_TOKENS.get('LMS_BASE') LMS_ROOT_URL = ENV_TOKENS.get('LMS_ROOT_URL') +CMS_ROOT_URL = ENV_TOKENS.get('CMS_ROOT_URL') LMS_INTERNAL_ROOT_URL = ENV_TOKENS.get('LMS_INTERNAL_ROOT_URL', LMS_ROOT_URL) ENTERPRISE_API_URL = ENV_TOKENS.get('ENTERPRISE_API_URL', LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/') ENTERPRISE_CONSENT_API_URL = ENV_TOKENS.get('ENTERPRISE_CONSENT_API_URL', LMS_INTERNAL_ROOT_URL + '/consent/api/v1/') diff --git a/cms/envs/test.py b/cms/envs/test.py index 49db5060885..500c8d538d3 100644 --- a/cms/envs/test.py +++ b/cms/envs/test.py @@ -26,7 +26,8 @@ from .common import * # import settings from LMS for consistent behavior with CMS -from lms.envs.test import ( # pylint: disable=wrong-import-order +from lms.envs.test import ( # pylint: disable=wrong-import-order, disable=unused-import + ACCOUNT_MICROFRONTEND_URL, COMPREHENSIVE_THEME_DIRS, # unimport:skip DEFAULT_FILE_STORAGE, ECOMMERCE_API_URL, @@ -35,8 +36,10 @@ LOGIN_ISSUE_SUPPORT_LINK, MEDIA_ROOT, MEDIA_URL, + ORDER_HISTORY_MICROFRONTEND_URL, PLATFORM_DESCRIPTION, PLATFORM_NAME, + PROFILE_MICROFRONTEND_URL, REGISTRATION_EXTRA_FIELDS, GRADES_DOWNLOAD, SITE_NAME, @@ -51,28 +54,26 @@ STUDIO_SHORT_NAME = gettext_lazy("𝓢𝓽𝓾𝓭𝓲𝓸") # Allow all hosts during tests, we use a lot of different ones all over the codebase. -ALLOWED_HOSTS = [ - '*' -] +ALLOWED_HOSTS = ["*"] # mongo connection settings -MONGO_PORT_NUM = int(os.environ.get('EDXAPP_TEST_MONGO_PORT', '27017')) -MONGO_HOST = os.environ.get('EDXAPP_TEST_MONGO_HOST', 'localhost') +MONGO_PORT_NUM = int(os.environ.get("EDXAPP_TEST_MONGO_PORT", "27017")) +MONGO_HOST = os.environ.get("EDXAPP_TEST_MONGO_HOST", "localhost") THIS_UUID = uuid4().hex[:5] -TEST_ROOT = path('test_root') +TEST_ROOT = path("test_root") # Want static files in the same dir for running on jenkins. STATIC_ROOT = TEST_ROOT / "staticfiles" -WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json" +WEBPACK_LOADER["DEFAULT"]["STATS_FILE"] = STATIC_ROOT / "webpack-stats.json" GITHUB_REPO_ROOT = TEST_ROOT / "data" DATA_DIR = TEST_ROOT / "data" COMMON_TEST_DATA_ROOT = COMMON_ROOT / "test" / "data" # For testing "push to lms" -FEATURES['ENABLE_EXPORT_GIT'] = True +FEATURES["ENABLE_EXPORT_GIT"] = True GIT_REPO_EXPORT_DIR = TEST_ROOT / "export_course_repos" # TODO (cpennington): We need to figure out how envs/test.py can inject things into common.py so that we don't have to repeat this sort of thing # lint-amnesty, pylint: disable=line-too-long @@ -90,51 +91,50 @@ # If we don't add these settings, then Django templates that can't # find pipelined assets will raise a ValueError. # http://stackoverflow.com/questions/12816941/unit-testing-with-django-pipeline -STATICFILES_STORAGE = 'pipeline.storage.NonPackagingPipelineStorage' +STATICFILES_STORAGE = "pipeline.storage.NonPackagingPipelineStorage" STATIC_URL = "/static/" # Update module store settings per defaults for tests update_module_store_settings( MODULESTORE, module_store_options={ - 'default_class': 'xmodule.hidden_block.HiddenBlock', - 'fs_root': TEST_ROOT / "data", + "default_class": "xmodule.hidden_block.HiddenBlock", + "fs_root": TEST_ROOT / "data", }, doc_store_settings={ - 'db': f'test_xmodule_{THIS_UUID}', - 'host': MONGO_HOST, - 'port': MONGO_PORT_NUM, - 'collection': 'test_modulestore', + "db": f"test_xmodule_{THIS_UUID}", + "host": MONGO_HOST, + "port": MONGO_PORT_NUM, + "collection": "test_modulestore", }, ) CONTENTSTORE = { - 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', - 'DOC_STORE_CONFIG': { - 'host': MONGO_HOST, - 'db': f'test_xcontent_{THIS_UUID}', - 'port': MONGO_PORT_NUM, - 'collection': 'dont_trip', + "ENGINE": "xmodule.contentstore.mongo.MongoContentStore", + "DOC_STORE_CONFIG": { + "host": MONGO_HOST, + "db": f"test_xcontent_{THIS_UUID}", + "port": MONGO_PORT_NUM, + "collection": "dont_trip", }, # allow for additional options that can be keyed on a name, e.g. 'trashcan' - 'ADDITIONAL_OPTIONS': { - 'trashcan': { - 'bucket': 'trash_fs' - } - } + "ADDITIONAL_OPTIONS": {"trashcan": {"bucket": "trash_fs"}}, } DATABASES = { - 'default': { - 'ENGINE': 'django.db.backends.sqlite3', - 'NAME': TEST_ROOT / "db" / "cms.db", - 'ATOMIC_REQUESTS': True, + "default": { + "ENGINE": "django.db.backends.sqlite3", + "NAME": TEST_ROOT / "db" / "cms.db", + "ATOMIC_REQUESTS": True, }, } LMS_BASE = "localhost:8000" LMS_ROOT_URL = f"http://{LMS_BASE}" -FEATURES['PREVIEW_LMS_BASE'] = "preview.localhost" +FEATURES["PREVIEW_LMS_BASE"] = "preview.localhost" + +CMS_BASE = "localhost:8001" +CMS_ROOT_URL = f"http://{CMS_BASE}" COURSE_AUTHORING_MICROFRONTEND_URL = "http://course-authoring-mfe" DISCUSSIONS_MICROFRONTEND_URL = "http://discussions-mfe" @@ -142,49 +142,47 @@ CACHES = { # 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', - 'LOCATION': 'edx_loc_mem_cache', - 'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key', + "default": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "edx_loc_mem_cache", + "KEY_FUNCTION": "common.djangoapps.util.memcache.safe_key", }, - # The general cache is what you get if you use our util.cache. It's used for # things like caching the course.xml file for different A/B test groups. # We set it to be a DummyCache to force reloading of course.xml in dev. # In staging environments, we would grab VERSION from data uploaded by the # push process. - 'general': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', - 'KEY_PREFIX': 'general', - 'VERSION': 4, - 'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key', + "general": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", + "KEY_PREFIX": "general", + "VERSION": 4, + "KEY_FUNCTION": "common.djangoapps.util.memcache.safe_key", }, - - 'mongo_metadata_inheritance': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': os.path.join(tempfile.gettempdir(), 'mongo_metadata_inheritance'), - 'TIMEOUT': 300, - 'KEY_FUNCTION': 'common.djangoapps.util.memcache.safe_key', + "mongo_metadata_inheritance": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": os.path.join(tempfile.gettempdir(), "mongo_metadata_inheritance"), + "TIMEOUT": 300, + "KEY_FUNCTION": "common.djangoapps.util.memcache.safe_key", }, - 'loc_cache': { - 'BACKEND': 'django.core.cache.backends.locmem.LocMemCache', - 'LOCATION': 'edx_location_mem_cache', + "loc_cache": { + "BACKEND": "django.core.cache.backends.locmem.LocMemCache", + "LOCATION": "edx_location_mem_cache", }, - 'course_structure_cache': { - 'BACKEND': 'django.core.cache.backends.dummy.DummyCache', + "course_structure_cache": { + "BACKEND": "django.core.cache.backends.dummy.DummyCache", }, } ################################# CELERY ###################################### CELERY_ALWAYS_EAGER = True -CELERY_RESULT_BACKEND = 'django-cache' +CELERY_RESULT_BACKEND = "django-cache" CLEAR_REQUEST_CACHE_ON_TASK_COMPLETION = False # test_status_cancel in cms/cms_user_tasks/test.py is failing without this # @override_setting for BROKER_URL is not working in testcase, so updating here -BROKER_URL = 'memory://localhost/' +BROKER_URL = "memory://localhost/" ########################### Server Ports ################################### @@ -199,99 +197,99 @@ ################### Make tests faster # http://slacy.com/blog/2012/04/make-your-tests-faster-in-django-1-4/ PASSWORD_HASHERS = [ - 'django.contrib.auth.hashers.SHA1PasswordHasher', - 'django.contrib.auth.hashers.MD5PasswordHasher', + "django.contrib.auth.hashers.SHA1PasswordHasher", + "django.contrib.auth.hashers.MD5PasswordHasher", ] # No segment key CMS_SEGMENT_KEY = None -FEATURES['DISABLE_SET_JWT_COOKIES_FOR_TESTS'] = True +FEATURES["DISABLE_SET_JWT_COOKIES_FOR_TESTS"] = True -FEATURES['ENABLE_SERVICE_STATUS'] = True +FEATURES["ENABLE_SERVICE_STATUS"] = True # Toggles embargo on for testing -FEATURES['EMBARGO'] = True +FEATURES["EMBARGO"] = True TEST_THEME = COMMON_ROOT / "test" / "test-theme" # For consistency in user-experience, keep the value of this setting in sync with # the one in lms/envs/test.py -FEATURES['ENABLE_DISCUSSION_SERVICE'] = False +FEATURES["ENABLE_DISCUSSION_SERVICE"] = False # Enable a parental consent age limit for testing PARENTAL_CONSENT_AGE_LIMIT = 13 # Enable certificates for the tests -FEATURES['CERTIFICATES_HTML_VIEW'] = True +FEATURES["CERTIFICATES_HTML_VIEW"] = True # Enable content libraries code for the tests -FEATURES['ENABLE_CONTENT_LIBRARIES'] = True +FEATURES["ENABLE_CONTENT_LIBRARIES"] = True -FEATURES['ENABLE_EDXNOTES'] = True +FEATURES["ENABLE_EDXNOTES"] = True # MILESTONES -FEATURES['MILESTONES_APP'] = True +FEATURES["MILESTONES_APP"] = True # ENTRANCE EXAMS -FEATURES['ENTRANCE_EXAMS'] = True +FEATURES["ENTRANCE_EXAMS"] = True ENTRANCE_EXAM_MIN_SCORE_PCT = 50 -VIDEO_CDN_URL = { - 'CN': 'http://api.xuetangx.com/edx/video?s3_url=' -} +VIDEO_CDN_URL = {"CN": "http://api.xuetangx.com/edx/video?s3_url="} # Courseware Search Index -FEATURES['ENABLE_COURSEWARE_INDEX'] = True -FEATURES['ENABLE_LIBRARY_INDEX'] = True +FEATURES["ENABLE_COURSEWARE_INDEX"] = True +FEATURES["ENABLE_LIBRARY_INDEX"] = True SEARCH_ENGINE = "search.tests.mock_search_engine.MockSearchEngine" -FEATURES['ENABLE_ENROLLMENT_TRACK_USER_PARTITION'] = True +FEATURES["ENABLE_ENROLLMENT_TRACK_USER_PARTITION"] = True ########################## AUTHOR PERMISSION ####################### -FEATURES['ENABLE_CREATOR_GROUP'] = False +FEATURES["ENABLE_CREATOR_GROUP"] = False # teams feature -FEATURES['ENABLE_TEAMS'] = True +FEATURES["ENABLE_TEAMS"] = True # Dummy secret key for dev/test -SECRET_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' +SECRET_KEY = "85920908f28904ed733fe576320db18cabd7b6cd" ######### custom courses ######### INSTALLED_APPS += [ - 'openedx.core.djangoapps.ccxcon.apps.CCXConnectorConfig', - 'common.djangoapps.third_party_auth.apps.ThirdPartyAuthConfig', + "openedx.core.djangoapps.ccxcon.apps.CCXConnectorConfig", + "common.djangoapps.third_party_auth.apps.ThirdPartyAuthConfig", ] -FEATURES['CUSTOM_COURSES_EDX'] = True +FEATURES["CUSTOM_COURSES_EDX"] = True ########################## VIDEO IMAGE STORAGE ############################ VIDEO_IMAGE_SETTINGS = dict( - VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB - VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB + VIDEO_IMAGE_MAX_BYTES=2 * 1024 * 1024, # 2 MB + VIDEO_IMAGE_MIN_BYTES=2 * 1024, # 2 KB STORAGE_KWARGS=dict( location=MEDIA_ROOT, ), - DIRECTORY_PREFIX='video-images/', + DIRECTORY_PREFIX="video-images/", BASE_URL=MEDIA_URL, ) -VIDEO_IMAGE_DEFAULT_FILENAME = 'default_video_image.png' +VIDEO_IMAGE_DEFAULT_FILENAME = "default_video_image.png" ########################## VIDEO TRANSCRIPTS STORAGE ############################ VIDEO_TRANSCRIPTS_SETTINGS = dict( - VIDEO_TRANSCRIPTS_MAX_BYTES=3 * 1024 * 1024, # 3 MB + VIDEO_TRANSCRIPTS_MAX_BYTES=3 * 1024 * 1024, # 3 MB STORAGE_KWARGS=dict( location=MEDIA_ROOT, base_url=MEDIA_URL, ), - DIRECTORY_PREFIX='video-transcripts/', + DIRECTORY_PREFIX="video-transcripts/", ) ####################### Plugin Settings ########################## # pylint: disable=wrong-import-position, wrong-import-order from edx_django_utils.plugins import add_plugins + # pylint: disable=wrong-import-position, wrong-import-order from openedx.core.djangoapps.plugins.constants import ProjectType, SettingsType + add_plugins(__name__, ProjectType.CMS, SettingsType.TEST) ########################## Derive Any Derived Settings ####################### @@ -307,22 +305,22 @@ # Used in edx-proctoring for ID generation in lieu of SECRET_KEY - dummy value # (ref MST-637) -PROCTORING_USER_OBFUSCATION_KEY = '85920908f28904ed733fe576320db18cabd7b6cd' +PROCTORING_USER_OBFUSCATION_KEY = "85920908f28904ed733fe576320db18cabd7b6cd" ##### LOGISTRATION RATE LIMIT SETTINGS ##### -LOGISTRATION_RATELIMIT_RATE = '5/5m' -LOGISTRATION_PER_EMAIL_RATELIMIT_RATE = '6/5m' -LOGISTRATION_API_RATELIMIT = '5/m' +LOGISTRATION_RATELIMIT_RATE = "5/5m" +LOGISTRATION_PER_EMAIL_RATELIMIT_RATE = "6/5m" +LOGISTRATION_API_RATELIMIT = "5/m" -REGISTRATION_VALIDATION_RATELIMIT = '5/minute' -REGISTRATION_RATELIMIT = '5/minute' -OPTIONAL_FIELD_API_RATELIMIT = '5/m' +REGISTRATION_VALIDATION_RATELIMIT = "5/minute" +REGISTRATION_RATELIMIT = "5/minute" +OPTIONAL_FIELD_API_RATELIMIT = "5/m" -RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = '2/m' -RESET_PASSWORD_API_RATELIMIT = '2/m' +RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = "2/m" +RESET_PASSWORD_API_RATELIMIT = "2/m" ############### Settings for proctoring ############### -PROCTORING_USER_OBFUSCATION_KEY = 'test_key' +PROCTORING_USER_OBFUSCATION_KEY = "test_key" #################### Network configuration #################### # Tests are not behind any proxies @@ -336,10 +334,5 @@ ############## openedx-learning (Learning Core) config ############## OPENEDX_LEARNING = { - 'MEDIA': { - 'BACKEND': 'django.core.files.storage.InMemoryStorage', - 'OPTIONS': { - 'location': MEDIA_ROOT + "_private" - } - } + "MEDIA": {"BACKEND": "django.core.files.storage.InMemoryStorage", "OPTIONS": {"location": MEDIA_ROOT + "_private"}} } diff --git a/cms/lib/xblock/test/test_upstream_sync.py b/cms/lib/xblock/test/test_upstream_sync.py index cc3d661ca6e..2f8f77ab656 100644 --- a/cms/lib/xblock/test/test_upstream_sync.py +++ b/cms/lib/xblock/test/test_upstream_sync.py @@ -1,7 +1,9 @@ """ Test CMS's upstream->downstream syncing system """ +import datetime import ddt +from pytz import utc from organizations.api import ensure_organization from organizations.models import Organization @@ -42,13 +44,33 @@ def setUp(self): title="Test Upstream Library", ) self.upstream_key = libs.create_library_block(self.library.key, "html", "test-upstream").usage_key - libs.create_library_block(self.library.key, "video", "video-upstream") upstream = xblock.load_block(self.upstream_key, self.user) upstream.display_name = "Upstream Title V2" upstream.data = "Upstream content V2" upstream.save() + self.upstream_problem_key = libs.create_library_block(self.library.key, "problem", "problem-upstream").usage_key + libs.set_library_block_olx(self.upstream_problem_key, ( + '\n' + )) + libs.publish_changes(self.library.key, self.user.id) self.taxonomy_all_org = tagging_api.create_taxonomy( @@ -179,6 +201,100 @@ def test_sync_updates_happy_path(self): for object_tag in object_tags: assert object_tag.value in new_upstream_tags + # pylint: disable=too-many-statements + def test_sync_updates_to_downstream_only_fields(self): + """ + If we sync to modified content, will it preserve downstream-only fields, and overwrite the rest? + """ + downstream = BlockFactory.create(category='problem', parent=self.unit, upstream=str(self.upstream_problem_key)) + + # Initial sync + sync_from_upstream(downstream, self.user) + + # These fields are copied from upstream + assert downstream.upstream_display_name == "Upstream Problem Title V2" + assert downstream.display_name == "Upstream Problem Title V2" + assert downstream.rerandomize == '"always"' + assert downstream.matlab_api_key == 'abc' + assert not downstream.use_latex_compiler + + # These fields are "downstream only", so field defaults are preserved, and values are NOT copied from upstream + assert downstream.attempts_before_showanswer_button == 0 + assert downstream.due is None + assert not downstream.force_save_button + assert downstream.graceperiod is None + assert downstream.grading_method == 'last_score' + assert downstream.max_attempts is None + assert downstream.show_correctness == 'always' + assert not downstream.show_reset_button + assert downstream.showanswer == 'finished' + assert downstream.submission_wait_seconds == 0 + assert downstream.weight is None + + # Upstream updates + libs.set_library_block_olx(self.upstream_problem_key, ( + '\n' + )) + libs.publish_changes(self.library.key, self.user.id) + + # Modifing downstream-only fields are "safe" customizations + downstream.display_name = "Downstream Title Override" + downstream.attempts_before_showanswer_button = 2 + downstream.due = datetime.datetime(2025, 2, 2, tzinfo=utc) + downstream.force_save_button = True + downstream.graceperiod = '2d' + downstream.grading_method = 'last_score' + downstream.max_attempts = 100 + downstream.show_correctness = 'always' + downstream.show_reset_button = True + downstream.showanswer = 'on_expired' + downstream.submission_wait_seconds = 100 + downstream.weight = 3 + + # Modifying synchronized fields are "unsafe" customizations + downstream.rerandomize = '"onreset"' + downstream.matlab_api_key = 'hij' + downstream.save() + + # Follow-up sync. + sync_from_upstream(downstream, self.user) + + # "unsafe" customizations are overridden by upstream + assert downstream.upstream_display_name == "Upstream Problem Title V3" + assert downstream.rerandomize == '"per_student"' + assert downstream.matlab_api_key == 'def' + assert downstream.use_latex_compiler + + # but "safe" customizations survive + assert downstream.display_name == "Downstream Title Override" + assert downstream.attempts_before_showanswer_button == 2 + assert downstream.due == datetime.datetime(2025, 2, 2, tzinfo=utc) + assert downstream.force_save_button + assert downstream.graceperiod == '2d' + assert downstream.grading_method == 'last_score' + assert downstream.max_attempts == 100 + assert downstream.show_correctness == 'always' + assert downstream.show_reset_button + assert downstream.showanswer == 'on_expired' + assert downstream.submission_wait_seconds == 100 + assert downstream.weight == 3 + def test_sync_updates_to_modified_content(self): """ If we sync to modified content, will it preserve customizable fields, but overwrite the rest? diff --git a/cms/lib/xblock/upstream_sync.py b/cms/lib/xblock/upstream_sync.py index 0d95931ce29..8a12ea8fc04 100644 --- a/cms/lib/xblock/upstream_sync.py +++ b/cms/lib/xblock/upstream_sync.py @@ -186,7 +186,7 @@ def get_for_block(cls, downstream: XBlock) -> t.Self: ) -def sync_from_upstream(downstream: XBlock, user: User) -> None: +def sync_from_upstream(downstream: XBlock, user: User) -> XBlock: """ Update `downstream` with content+settings from the latest available version of its linked upstream content. @@ -200,6 +200,7 @@ def sync_from_upstream(downstream: XBlock, user: User) -> None: _update_non_customizable_fields(upstream=upstream, downstream=downstream) _update_tags(upstream=upstream, downstream=downstream) downstream.upstream_version = link.version_available + return upstream def fetch_customizable_fields(*, downstream: XBlock, user: User, upstream: XBlock | None = None) -> None: @@ -252,10 +253,6 @@ def _update_customizable_fields(*, upstream: XBlock, downstream: XBlock, only_fe * Set `course_problem.upstream_display_name = lib_problem.display_name` ("fetch"). * If `not only_fetch`, and `course_problem.display_name` wasn't customized, then: * Set `course_problem.display_name = lib_problem.display_name` ("sync"). - - * Set `course_problem.upstream_max_attempts = lib_problem.max_attempts` ("fetch"). - * If `not only_fetch`, and `course_problem.max_attempts` wasn't customized, then: - * Set `course_problem.max_attempts = lib_problem.max_attempts` ("sync"). """ syncable_field_names = _get_synchronizable_fields(upstream, downstream) @@ -264,6 +261,10 @@ def _update_customizable_fields(*, upstream: XBlock, downstream: XBlock, only_fe if field_name not in syncable_field_names: continue + # Downstream-only fields don't have an upstream fetch field + if fetch_field_name is None: + continue + # FETCH the upstream's value and save it on the downstream (ie, `downstream.upstream_$FIELD`). old_upstream_value = getattr(downstream, fetch_field_name) new_upstream_value = getattr(upstream, field_name) @@ -361,6 +362,9 @@ def sever_upstream_link(downstream: XBlock) -> None: downstream.upstream = None downstream.upstream_version = None for _, fetched_upstream_field in downstream.get_customizable_fields().items(): + # Downstream-only fields don't have an upstream fetch field + if fetched_upstream_field is None: + continue setattr(downstream, fetched_upstream_field, None) # Null out upstream_display_name, et al. @@ -414,21 +418,30 @@ class UpstreamSyncMixin(XBlockMixin): help=("The value of display_name on the linked upstream block."), default=None, scope=Scope.settings, hidden=True, enforce_type=True, ) - upstream_max_attempts = Integer( - help=("The value of max_attempts on the linked upstream block."), - default=None, scope=Scope.settings, hidden=True, enforce_type=True, - ) @classmethod - def get_customizable_fields(cls) -> dict[str, str]: + def get_customizable_fields(cls) -> dict[str, str | None]: """ Mapping from each customizable field to the field which can be used to restore its upstream value. + If the customizable field is mapped to None, then it is considered "downstream only", and cannot be restored + from the upstream value. + XBlocks outside of edx-platform can override this in order to set up their own customizable fields. """ return { "display_name": "upstream_display_name", - "max_attempts": "upstream_max_attempts", + "attempts_before_showanswer_button": None, + "due": None, + "force_save_button": None, + "graceperiod": None, + "grading_method": None, + "max_attempts": None, + "show_correctness": None, + "show_reset_button": None, + "showanswer": None, + "submission_wait_seconds": None, + "weight": None, } # PRESERVING DOWNSTREAM CUSTOMIZATIONS and RESTORING UPSTREAM VALUES @@ -485,6 +498,10 @@ def get_customizable_fields(cls) -> dict[str, str]: # if field_name in self.downstream_customized: # continue # + # # If there is no restore_field name, it's a downstream-only field + # if restore_field_name is None: + # continue + # # # If this field's value doesn't match the synced upstream value, then mark the field # # as customized so that we don't clobber it later when syncing. # # NOTE: Need to consider the performance impact of all these field lookups. diff --git a/cms/static/js/views/pages/container.js b/cms/static/js/views/pages/container.js index 7f5e2c257e2..a18045b8bd8 100644 --- a/cms/static/js/views/pages/container.js +++ b/cms/static/js/views/pages/container.js @@ -131,10 +131,40 @@ function($, _, Backbone, gettext, BasePage, if (this.options.isIframeEmbed) { window.addEventListener('message', (event) => { - if (event.data && event.data.type === 'refreshXBlock') { - this.render(); - } - }); + const { data } = event; + + if (!data) return; + + let xblockElement; + let xblockWrapper; + + if (data.payload && data.payload.locator) { + xblockElement = $(`[data-locator="${data.payload.locator}"]`); + xblockWrapper = $("li.studio-xblock-wrapper[data-locator='" + data.payload.locator + "']"); + } else { + xblockWrapper = $(); + } + + switch (data.type) { + case 'refreshXBlock': + this.render(); + break; + case 'completeManageXBlockAccess': + this.refreshXBlock(xblockElement, false); + break; + case 'completeXBlockMoving': + xblockWrapper.hide(); + break; + case 'rollbackMovedXBlock': + xblockWrapper.show(); + break; + case 'addXBlock': + this.createComponent(this, xblockElement, data); + break; + default: + console.warn('Unhandled message type:', data.type); + } + }); } this.listenTo(Backbone, 'move:onXBlockMoved', this.onXBlockMoved); @@ -199,6 +229,25 @@ function($, _, Backbone, gettext, BasePage, target.scrollIntoView({ behavior: 'smooth', inline: 'center' }); } + if (self.options.isIframeEmbed) { + const scrollOffsetString = localStorage.getItem('modalEditLastYPosition'); + const scrollOffset = scrollOffsetString ? parseInt(scrollOffsetString, 10) : 0; + + if (scrollOffset) { + try { + window.parent.postMessage( + { + type: 'scrollToXBlock', + message: 'Scroll to XBlock', + payload: { scrollOffset } + }, document.referrer + ); + localStorage.removeItem('modalEditLastYPosition'); + } catch (e) { + console.error(e); + } + } + } }, block_added: options && options.block_added }); @@ -395,8 +444,16 @@ function($, _, Backbone, gettext, BasePage, const isAccessButton = event.currentTarget.className === 'access-button'; const primaryHeader = $(event.target).closest('.xblock-header-primary, .nav-actions'); const usageId = encodeURI(primaryHeader.attr('data-usage-id')); + try { if (this.options.isIframeEmbed && isAccessButton) { + window.parent.postMessage( + { + type: 'toggleCourseXBlockDropdown', + message: 'Adjust the height of the dropdown menu', + payload: { courseXBlockDropdownHeight: 0 } + }, document.referrer + ); return window.parent.postMessage( { type: 'manageXBlockAccess', @@ -422,11 +479,12 @@ function($, _, Backbone, gettext, BasePage, || (useNewProblemEditor === 'True' && blockType === 'problem') ) { var destinationUrl = primaryHeader.attr('authoring_MFE_base_url') - + '/' + blockType - + '/' + encodeURI(primaryHeader.attr('data-usage-id')); + + '/' + blockType + + '/' + encodeURI(primaryHeader.attr('data-usage-id')); try { if (this.options.isIframeEmbed) { + localStorage.setItem('modalEditLastYPosition', event.clientY.toString()); return window.parent.postMessage( { type: 'newXBlockEditor', @@ -615,7 +673,7 @@ function($, _, Backbone, gettext, BasePage, type: 'toggleCourseXBlockDropdown', message: 'Adjust the height of the dropdown menu', payload: { - courseXBlockDropdownHeight: courseXBlockDropdownHeight / 2, + courseXBlockDropdownHeight: courseXBlockDropdownHeight / 2, }, }, document.referrer ); @@ -735,13 +793,26 @@ function($, _, Backbone, gettext, BasePage, const usageId = encodeURI(primaryHeader.attr('data-usage-id')); try { if (this.options.isIframeEmbed) { - return window.parent.postMessage( + window.parent.postMessage( { type: 'duplicateXBlock', message: 'Duplicate the XBlock', payload: { blockType, usageId } }, document.referrer ); + window.parent.postMessage( + { + type: 'toggleCourseXBlockDropdown', + message: 'Adjust the height of the dropdown menu', + payload: { courseXBlockDropdownHeight: 0 } + }, document.referrer + ); + // Saves the height of the XBlock during duplication with the new editor. + // After closing the editor, the page scrolls to the newly created copy of the XBlock. + if (['html', 'problem', 'video'].includes(blockType)) { + const scrollHeight = event.clientY + this.findXBlockElement(event.target).height(); + localStorage.setItem('modalEditLastYPosition', scrollHeight.toString()); + } } } catch (e) { console.error(e); @@ -768,17 +839,24 @@ function($, _, Backbone, gettext, BasePage, type: 'showMoveXBlockModal', payload: { sourceXBlockInfo: { - id: sourceXBlockInfo.attributes.id, - displayName: sourceXBlockInfo.attributes.display_name, + id: sourceXBlockInfo.attributes.id, + displayName: sourceXBlockInfo.attributes.display_name, }, sourceParentXBlockInfo: { - id: sourceParentXBlockInfo.attributes.id, - category: sourceParentXBlockInfo.attributes.category, - hasChildren: sourceParentXBlockInfo.attributes.has_children, + id: sourceParentXBlockInfo.attributes.id, + category: sourceParentXBlockInfo.attributes.category, + hasChildren: sourceParentXBlockInfo.attributes.has_children, }, }, }, document.referrer ); + window.parent.postMessage( + { + type: 'toggleCourseXBlockDropdown', + message: 'Adjust the height of the dropdown menu', + payload: { courseXBlockDropdownHeight: 0 } + }, document.referrer + ); return true; } } catch (e) { @@ -795,13 +873,20 @@ function($, _, Backbone, gettext, BasePage, const usageId = encodeURI(primaryHeader.attr('data-usage-id')); try { if (this.options.isIframeEmbed) { - return window.parent.postMessage( + window.parent.postMessage( { type: 'deleteXBlock', message: 'Delete the XBlock', payload: { usageId } }, document.referrer ); + window.parent.postMessage( + { + type: 'toggleCourseXBlockDropdown', + message: 'Adjust the height of the dropdown menu', + payload: { courseXBlockDropdownHeight: 0 } + }, document.referrer + ); } } catch (e) { console.error(e); @@ -809,21 +894,42 @@ function($, _, Backbone, gettext, BasePage, this.deleteComponent(this.findXBlockElement(event.target)); }, - createComponent: function(template, target) { + createPlaceholderElement: function() { + return $('
', {class: 'studio-xblock-wrapper'}); + }, + + createComponent: function(template, target, iframeMessageData) { // A placeholder element is created in the correct location for the new xblock // and then onNewXBlock will replace it with a rendering of the xblock. Note that // for xblocks that can't be replaced inline, the entire parent will be refreshed. var parentElement = this.findXBlockElement(target), parentLocator = parentElement.data('locator'), - buttonPanel = target.closest('.add-xblock-component'), - listPanel = buttonPanel.prev(), - scrollOffset = ViewUtils.getScrollOffset(buttonPanel), + buttonPanel = target?.closest('.add-xblock-component'), + listPanel = buttonPanel?.prev(), $placeholderEl = $(this.createPlaceholderElement()), requestData = _.extend(template, { parent_locator: parentLocator }), - placeholderElement; - placeholderElement = $placeholderEl.appendTo(listPanel); + scrollOffset, + placeholderElement, + $container; + + if (this.options.isIframeEmbed) { + $container = $('ol.reorderable-container.ui-sortable'); + scrollOffset = 0; + } else { + $container = listPanel; + scrollOffset = ViewUtils.getScrollOffset(buttonPanel); + } + + placeholderElement = $placeholderEl.appendTo($container); + + if (this.options.isIframeEmbed) { + if (iframeMessageData.payload.data && iframeMessageData.type === 'addXBlock') { + return this.onNewXBlock(placeholderElement, scrollOffset, false, iframeMessageData.payload.data); + } + } + return $.postJSON(this.getURLRoot() + '/', requestData, _.bind(this.onNewXBlock, this, placeholderElement, scrollOffset, false)) .fail(function() { @@ -843,6 +949,32 @@ function($, _, Backbone, gettext, BasePage, placeholderElement; placeholderElement = $placeholderEl.insertAfter(xblockElement); + + if (this.options.isIframeEmbed) { + try { + window.parent.postMessage( + { + type: 'scrollToXBlock', + message: 'Scroll to XBlock', + payload: { scrollOffset: xblockElement.height() } + }, document.referrer + ); + } catch (e) { + console.error(e); + } + + const messageHandler = ({ data }) => { + if (data && data.type === 'completeXBlockDuplicating') { + self.onNewXBlock(placeholderElement, null, true, data.payload); + window.removeEventListener('message', messageHandler); + } + }; + + window.addEventListener('message', messageHandler); + + return; + } + XBlockUtils.duplicateXBlock(xblockElement, parentElement) .done(function(data) { self.onNewXBlock(placeholderElement, scrollOffset, true, data); @@ -858,6 +990,21 @@ function($, _, Backbone, gettext, BasePage, xblockInfo = new XBlockInfo({ id: xblockElement.data('locator') }); + + if (this.options.isIframeEmbed) { + const messageHandler = ({ data }) => { + if (data && data.type === 'completeXBlockDeleting') { + const targetXBlockElement = $(`[data-locator="${data.payload.locator}"]`); + window.removeEventListener('message', messageHandler); + return self.onDelete(targetXBlockElement); + } + }; + + window.addEventListener('message', messageHandler); + + return; + } + XBlockUtils.deleteXBlock(xblockInfo).done(function() { self.onDelete(xblockElement); }); @@ -986,7 +1133,9 @@ function($, _, Backbone, gettext, BasePage, window.location.href = destinationUrl; return; } - ViewUtils.setScrollOffset(xblockElement, scrollOffset); + if (!this.options.isIframeEmbed) { + ViewUtils.setScrollOffset(xblockElement, scrollOffset); + } xblockElement.data('locator', data.locator); return this.refreshXBlock(xblockElement, true, is_duplicate); }, @@ -1003,7 +1152,9 @@ function($, _, Backbone, gettext, BasePage, parentElement = xblockElement.parent(), rootLocator = this.xblockView.model.id; if (xblockElement.length === 0 || xblockElement.data('locator') === rootLocator) { - this.render({refresh: true, block_added: block_added}); + if (block_added) { + this.render({refresh: true, block_added: block_added}); + } } else if (parentElement.hasClass('reorderable-container')) { this.refreshChildXBlock(xblockElement, block_added, is_duplicate); } else { diff --git a/cms/static/sass/course-unit-mfe-iframe-bundle.scss b/cms/static/sass/course-unit-mfe-iframe-bundle.scss index bc0c3901b14..c9b111b9128 100644 --- a/cms/static/sass/course-unit-mfe-iframe-bundle.scss +++ b/cms/static/sass/course-unit-mfe-iframe-bundle.scss @@ -200,7 +200,7 @@ body { } } - .modal-window.modal-editor { + .modal-window.modal-editor, &.xblock-iframe-content { background-color: $white; border-radius: 6px; @@ -316,6 +316,7 @@ body { .openassessment_save_button, .save-button, + .action-save, .continue-button { color: $white; background-color: $primary; @@ -347,6 +348,7 @@ body { } .openassessment_cancel_button, + .action-cancel, .cancel-button { color: $text-color; background-color: $transparent; @@ -381,6 +383,64 @@ body { .modal-lg.modal-window.confirm.openassessment_modal_window { height: 635px; } + + // Additions for the xblock editor on the Library Authoring + &.xblock-iframe-content { + height: 100%; + + // Reset the max-height to allow the settings list to grow + .wrapper-comp-settings .list-input.settings-list { + max-height: unset; + } + + // For Google Docs and Google Calendar xblock editor + .google-edit-wrapper .xblock-inputs { + position: unset; + overflow-y: unset; + } + + .xblock-actions { + padding: ($baseline*0.75) 2% ($baseline/2) 2%; + position: sticky; + bottom: 0; + + .action-item { + @extend %t-action3; + + display: inline-block; + margin-right: ($baseline*0.75); + + &:last-child { + margin-right: 0; + } + } + } + + .xblock-v1-studio_view { + height: 100%; + + .editor-with-buttons { + display: flex; + flex-direction: column; + justify-content: space-between; + height: 100%; + + .list-input { + height: 90vh; + } + } + + &.xmodule_DoneXBlock { + margin-top: 60px; + padding: 0 20px; + } + + .xblock-actions { + display: flex; + justify-content: flex-end; + } + } + } } .view-container .content-primary { diff --git a/cms/static/sass/studio-main-v1.scss b/cms/static/sass/studio-main-v1.scss index 5d0cdda2ea5..a325010a7c6 100644 --- a/cms/static/sass/studio-main-v1.scss +++ b/cms/static/sass/studio-main-v1.scss @@ -16,7 +16,7 @@ // +Libs and Resets - *do not edit* // ==================== -@import '_builtin-block-variables'; @import 'bourbon/bourbon'; // lib - bourbon @import 'vendor/bi-app/bi-app-ltr'; // set the layout for left to right languages @import 'build-v1'; // shared app style assets/rendering +@import '_builtin-block-variables'; diff --git a/cms/templates/widgets/header.html b/cms/templates/widgets/header.html index 8b14398fc37..941fe8d5e6a 100644 --- a/cms/templates/widgets/header.html +++ b/cms/templates/widgets/header.html @@ -8,7 +8,7 @@ from urllib.parse import quote_plus from common.djangoapps.student.auth import has_studio_advanced_settings_access from cms.djangoapps.contentstore import toggles - from cms.djangoapps.contentstore.utils import get_pages_and_resources_url, get_course_outline_url, get_updates_url, get_files_uploads_url, get_video_uploads_url, get_schedule_details_url, get_grading_url, get_advanced_settings_url, get_import_url, get_export_url, get_studio_home_url, get_course_team_url + from cms.djangoapps.contentstore.utils import get_pages_and_resources_url, get_course_outline_url, get_updates_url, get_files_uploads_url, get_video_uploads_url, get_schedule_details_url, get_grading_url, get_advanced_settings_url, get_import_url, get_export_url, get_studio_home_url, get_course_team_url, get_optimizer_url from openedx.core.djangoapps.discussions.config.waffle import ENABLE_PAGES_AND_RESOURCES_MICROFRONTEND from openedx.core.djangoapps.lang_pref.api import header_language_selector_is_enabled, released_languages %> @@ -66,6 +66,7 @@

advanced_settings_mfe_enabled = toggles.use_new_advanced_settings_page(context_course.id) import_mfe_enabled = toggles.use_new_import_page(context_course.id) export_mfe_enabled = toggles.use_new_export_page(context_course.id) + optimizer_enabled = toggles.enable_course_optimizer(context_course.id) %>

@@ -243,7 +244,7 @@

${_("Tools")} + % endif @@ -255,6 +256,11 @@

${_("Tools")} ${_("Checklists")} + % if optimizer_enabled: + + % endif

diff --git a/cms/urls.py b/cms/urls.py index 2e64d4bbeb7..58503f9ed92 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -168,7 +168,7 @@ contentstore_views.textbooks_detail_handler, name='textbooks_detail_handler'), re_path(fr'^videos/{settings.COURSE_KEY_PATTERN}(?:/(?P[-\w]+))?$', contentstore_views.videos_handler, name='videos_handler'), - re_path(fr'^generate_video_upload_link/{settings.COURSE_KEY_PATTERN}', + re_path(fr'^generate_video_upload_link/{settings.COURSE_KEY_PATTERN}$', contentstore_views.generate_video_upload_link_handler, name='generate_video_upload_link'), re_path(fr'^video_images/{settings.COURSE_KEY_PATTERN}(?:/(?P[-\w]+))?$', contentstore_views.video_images_handler, name='video_images_handler'), diff --git a/common/djangoapps/course_action_state/admin.py b/common/djangoapps/course_action_state/admin.py new file mode 100644 index 00000000000..8db98ab4206 --- /dev/null +++ b/common/djangoapps/course_action_state/admin.py @@ -0,0 +1,9 @@ +""" +Admin site bindings for CourseActionState +""" + +from django.contrib import admin + +from common.djangoapps.course_action_state.models import CourseRerunState + +admin.site.register(CourseRerunState) diff --git a/common/djangoapps/course_modes/tests/test_views.py b/common/djangoapps/course_modes/tests/test_views.py index d7b06986531..b4a777d2d67 100644 --- a/common/djangoapps/course_modes/tests/test_views.py +++ b/common/djangoapps/course_modes/tests/test_views.py @@ -21,7 +21,6 @@ from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from common.djangoapps.util.testing import UrlResetMixin from common.djangoapps.util.tests.mixins.discovery import CourseCatalogServiceMockMixin -from edx_toggles.toggles.testutils import override_waffle_flag # lint-amnesty, pylint: disable=wrong-import-order from lms.djangoapps.commerce.tests import test_utils as ecomm_test_utils from lms.djangoapps.commerce.tests.mocks import mock_payment_processors from lms.djangoapps.verify_student.services import IDVerificationService @@ -33,8 +32,6 @@ 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 -from ..views import VALUE_PROP_TRACK_SELECTION_FLAG - # Name of the method to mock for Content Type Gating. GATING_METHOD_NAME = 'openedx.features.content_type_gating.models.ContentTypeGatingConfig.enabled_for_enrollment' @@ -186,27 +183,6 @@ def test_suggested_prices(self, price_list): # TODO: Fix it so that response.templates works w/ mako templates, and then assert # that the right template rendered - @httpretty.activate - @ddt.data( - (['honor', 'verified', 'credit'], True), - (['honor', 'verified'], False), - ) - @ddt.unpack - def test_credit_upsell_message(self, available_modes, show_upsell): - # Create the course modes - for mode in available_modes: - CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) - - # Check whether credit upsell is shown on the page - # This should *only* be shown when a credit mode is available - url = reverse('course_modes_choose', args=[str(self.course.id)]) - response = self.client.get(url) - - if show_upsell: - self.assertContains(response, "Credit") - else: - self.assertNotContains(response, "Credit") - @httpretty.activate @patch('common.djangoapps.course_modes.views.enterprise_customer_for_request') @patch('common.djangoapps.course_modes.views.get_course_final_price') @@ -240,29 +216,6 @@ def test_display_after_discounted_price( self.assertContains(response, discounted_price) self.assertContains(response, verified_mode.min_price) - @httpretty.activate - @ddt.data(True, False) - def test_congrats_on_enrollment_message(self, create_enrollment): - # Create the course mode - CourseModeFactory.create(mode_slug='verified', course_id=self.course.id) - - if create_enrollment: - CourseEnrollmentFactory( - is_active=True, - course_id=self.course.id, - user=self.user - ) - - # Check whether congratulations message is shown on the page - # This should *only* be shown when an enrollment exists - url = reverse('course_modes_choose', args=[str(self.course.id)]) - response = self.client.get(url) - - if create_enrollment: - self.assertContains(response, "Congratulations! You are now enrolled in") - else: - self.assertNotContains(response, "Congratulations! You are now enrolled in") - @ddt.data('professional', 'no-id-professional') def test_professional_enrollment(self, mode): # The only course mode is professional ed @@ -529,26 +482,24 @@ def test_errors(self, has_perm, post_params, error_msg, status_code, mock_has_pe for mode in ('audit', 'honor', 'verified'): CourseModeFactory.create(mode_slug=mode, course_id=self.course.id) - # Value Prop TODO (REV-2378): remove waffle flag from tests once flag is removed. - with override_waffle_flag(VALUE_PROP_TRACK_SELECTION_FLAG, active=True): - mock_has_perm.return_value = has_perm - url = reverse('course_modes_choose', args=[str(self.course.id)]) + mock_has_perm.return_value = has_perm + url = reverse('course_modes_choose', args=[str(self.course.id)]) - # Choose mode (POST request) - response = self.client.post(url, post_params) - self.assertEqual(response.status_code, status_code) + # Choose mode (POST request) + response = self.client.post(url, post_params) + self.assertEqual(response.status_code, status_code) - if has_perm: - self.assertContains(response, error_msg) - self.assertContains(response, 'Sorry, we were unable to enroll you') + if has_perm: + self.assertContains(response, error_msg) + self.assertContains(response, 'Sorry, we were unable to enroll you') - # Check for CTA button on error page - marketing_root = settings.MKTG_URLS.get('ROOT') - search_courses_url = urljoin(marketing_root, '/search?tab=course') - self.assertContains(response, search_courses_url) - self.assertContains(response, 'Explore all courses') - else: - self.assertTrue(CourseEnrollment.is_enrollment_closed(self.user, self.course)) + # Check for CTA button on error page + marketing_root = settings.MKTG_URLS.get('ROOT') + search_courses_url = urljoin(marketing_root, '/search?tab=course') + self.assertContains(response, search_courses_url) + self.assertContains(response, 'Explore all courses') + else: + self.assertTrue(CourseEnrollment.is_enrollment_closed(self.user, self.course)) def _assert_fbe_page(self, response, min_price=None, **_): """ @@ -607,33 +558,19 @@ def _assert_unfbe_page(self, response, min_price=None, **_): # Check for the HTML element for courses with more than one mode self.assertContains(response, '
') - def _assert_legacy_page(self, response, **_): - """ - Assert choose.html was rendered. - """ - # Check for string unique to the legacy choose.html. - self.assertContains(response, "Choose Your Track") - # This string only occurs in lms/templates/course_modes/choose.html - # and related theme and translation files. - @override_settings(MKTG_URLS={'ROOT': 'https://www.example.edx.org'}) @ddt.data( - # gated_content_on, course_duration_limits_on, waffle_flag_on, expected_page_assertion_function - (True, True, True, _assert_fbe_page), - (True, False, True, _assert_unfbe_page), - (False, True, True, _assert_unfbe_page), - (False, False, True, _assert_unfbe_page), - (True, True, False, _assert_legacy_page), - (True, False, False, _assert_legacy_page), - (False, True, False, _assert_legacy_page), - (False, False, False, _assert_legacy_page), + # gated_content_on, course_duration_limits_on, expected_page_assertion_function + (True, True, _assert_fbe_page), + (True, False, _assert_unfbe_page), + (False, True, _assert_unfbe_page), + (False, False, _assert_unfbe_page), ) @ddt.unpack def test_track_selection_types( self, gated_content_on, course_duration_limits_on, - waffle_flag_on, expected_page_assertion_function ): """ @@ -644,7 +581,6 @@ def test_track_selection_types( verified course modes), the learner may view 3 different pages: 1. fbe.html - full FBE 2. unfbe.html - partial or no FBE - 3. choose.html - legacy track selection page This test checks that the right template is rendered. @@ -667,15 +603,11 @@ def test_track_selection_types( user=self.user ) - # Value Prop TODO (REV-2378): remove waffle flag from tests once the new Track Selection template is rolled out. - # Check whether new track selection template is rendered. - # This should *only* be shown when the waffle flag is on. - with override_waffle_flag(VALUE_PROP_TRACK_SELECTION_FLAG, active=waffle_flag_on): - with patch(GATING_METHOD_NAME, return_value=gated_content_on): - with patch(CDL_METHOD_NAME, return_value=course_duration_limits_on): - url = reverse('course_modes_choose', args=[str(self.course_that_started.id)]) - response = self.client.get(url) - expected_page_assertion_function(self, response, min_price=verified_mode.min_price) + with patch(GATING_METHOD_NAME, return_value=gated_content_on): + with patch(CDL_METHOD_NAME, return_value=course_duration_limits_on): + url = reverse('course_modes_choose', args=[str(self.course_that_started.id)]) + response = self.client.get(url) + expected_page_assertion_function(self, response, min_price=verified_mode.min_price) def test_verified_mode_only(self): # Create only the verified mode and enroll the user @@ -690,18 +622,16 @@ def test_verified_mode_only(self): user=self.user ) - # Value Prop TODO (REV-2378): remove waffle flag from tests once the new Track Selection template is rolled out. - with override_waffle_flag(VALUE_PROP_TRACK_SELECTION_FLAG, active=True): - with patch(GATING_METHOD_NAME, return_value=True): - with patch(CDL_METHOD_NAME, return_value=True): - url = reverse('course_modes_choose', args=[str(self.course_that_started.id)]) - response = self.client.get(url) - # Check that only the verified option is rendered - self.assertNotContains(response, "Choose a path for your course in") - self.assertContains(response, "Earn a certificate") - self.assertNotContains(response, "Access this course") - self.assertContains(response, '
') - self.assertNotContains(response, '
') + with patch(GATING_METHOD_NAME, return_value=True): + with patch(CDL_METHOD_NAME, return_value=True): + url = reverse('course_modes_choose', args=[str(self.course_that_started.id)]) + response = self.client.get(url) + # Check that only the verified option is rendered + self.assertNotContains(response, "Choose a path for your course in") + self.assertContains(response, "Earn a certificate") + self.assertNotContains(response, "Access this course") + self.assertContains(response, '
') + self.assertNotContains(response, '
') @skip_unless_lms diff --git a/common/djangoapps/course_modes/views.py b/common/djangoapps/course_modes/views.py index 759073a1358..09164dc40e7 100644 --- a/common/djangoapps/course_modes/views.py +++ b/common/djangoapps/course_modes/views.py @@ -29,7 +29,6 @@ from common.djangoapps.course_modes.helpers import get_course_final_price, get_verified_track_links from common.djangoapps.edxmako.shortcuts import render_to_response from common.djangoapps.util.date_utils import strftime_localized_html -from edx_toggles.toggles import WaffleFlag # lint-amnesty, pylint: disable=wrong-import-order from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.experiments.utils import get_experiment_user_metadata_context from lms.djangoapps.verify_student.services import IDVerificationService @@ -47,17 +46,6 @@ LOG = logging.getLogger(__name__) -# .. toggle_name: course_modes.use_new_track_selection -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: This flag enables the use of the new track selection template for testing purposes before full rollout -# .. toggle_use_cases: temporary -# .. toggle_creation_date: 2021-8-23 -# .. toggle_target_removal_date: None -# .. toggle_tickets: REV-2133 -# .. toggle_warning: This temporary feature toggle does not have a target removal date. -VALUE_PROP_TRACK_SELECTION_FLAG = WaffleFlag('course_modes.use_new_track_selection', __name__) - class ChooseModeView(View): """View used when the user is asked to pick a mode. @@ -158,18 +146,6 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= ) return redirect('{}?{}'.format(reverse('dashboard'), params)) - # When a credit mode is available, students will be given the option - # to upgrade from a verified mode to a credit mode at the end of the course. - # This allows students who have completed photo verification to be eligible - # for university credit. - # Since credit isn't one of the selectable options on the track selection page, - # we need to check *all* available course modes in order to determine whether - # a credit mode is available. If so, then we show slightly different messaging - # for the verified track. - has_credit_upsell = any( - CourseMode.is_credit_mode(mode) for mode - in CourseMode.modes_for_course(course_key, only_selectable=False) - ) course_id = str(course_key) gated_content = ContentTypeGatingConfig.enabled_for_enrollment( user=request.user, @@ -184,7 +160,6 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= ), "modes": modes, "is_single_mode": is_single_mode, - "has_credit_upsell": has_credit_upsell, "course_name": course.display_name_with_default, "course_org": course.display_org_with_default, "course_num": course.display_number_with_default, @@ -204,14 +179,6 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= ) ) - title_content = '' - if enrollment_mode: - title_content = _("Congratulations! You are now enrolled in {course_name}").format( - course_name=course.display_name_with_default - ) - - context["title_content"] = title_content - if "verified" in modes: verified_mode = modes["verified"] context["suggested_prices"] = [ @@ -266,19 +233,12 @@ def get(self, request, course_id, error=None): # lint-amnesty, pylint: disable= context['audit_access_deadline'] = formatted_audit_access_date fbe_is_on = deadline and gated_content - # Route to correct Track Selection page. - # REV-2378 TODO Value Prop: remove waffle flag after all edge cases for track selection are completed. - if VALUE_PROP_TRACK_SELECTION_FLAG.is_enabled(): - if not enterprise_customer_for_request(request): # TODO: Remove by executing REV-2342 - if error: - return render_to_response("course_modes/error.html", context) - if fbe_is_on: - return render_to_response("course_modes/fbe.html", context) - else: - return render_to_response("course_modes/unfbe.html", context) - - # If enterprise_customer, failover to old choose.html page - return render_to_response("course_modes/choose.html", context) + if error: + return render_to_response("course_modes/error.html", context) + if fbe_is_on: + return render_to_response("course_modes/fbe.html", context) + else: + return render_to_response("course_modes/unfbe.html", context) @method_decorator(transaction.non_atomic_requests) @method_decorator(login_required) diff --git a/common/djangoapps/edxmako/paths.py b/common/djangoapps/edxmako/paths.py index 3779e6b67cf..3da9844eedc 100644 --- a/common/djangoapps/edxmako/paths.py +++ b/common/djangoapps/edxmako/paths.py @@ -4,9 +4,8 @@ import contextlib import hashlib import os +import importlib.resources as resources -from importlib.resources import files -from pathlib import Path from django.conf import settings from mako.exceptions import TopLevelLookupException from mako.lookup import TemplateLookup @@ -137,11 +136,10 @@ def add_lookup(namespace, directory, package=None, prepend=False): encoding_errors='replace', ) if package: - package, module_path = package.split('.', 1) - module_dir = str(Path(module_path).parent) - directory = files(package).joinpath(module_dir, directory) + with resources.as_file(resources.files(package.rsplit('.', 1)[0]) / directory) as dir_path: + directory = str(dir_path) - templates.add_directory(str(directory), prepend=prepend) + templates.add_directory(directory, prepend=prepend) @request_cached() diff --git a/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py b/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py index a9c1d534985..69f2e35de44 100644 --- a/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py +++ b/common/djangoapps/student/management/commands/bulk_change_enrollment_csv.py @@ -81,7 +81,7 @@ def handle(self, *args, **options): self.change_enrollments(csv_file) else: - CommandError('No file is provided. File is required') + raise CommandError('No file is provided. File is required') def change_enrollments(self, csv_file): """ change the enrollments of the learners. """ diff --git a/common/djangoapps/student/models/user.py b/common/djangoapps/student/models/user.py index 9d979beb19c..7fe69bd678e 100644 --- a/common/djangoapps/student/models/user.py +++ b/common/djangoapps/student/models/user.py @@ -1028,7 +1028,7 @@ def clear_lockout_counter(cls, user): entry = cls._get_record_for_user(user) entry.delete() except ObjectDoesNotExist: - return + pass def __str__(self): """Str -> Username: count - date.""" diff --git a/common/djangoapps/student/tests/test_filters.py b/common/djangoapps/student/tests/test_filters.py index 376595a8507..bf79ed7ae40 100644 --- a/common/djangoapps/student/tests/test_filters.py +++ b/common/djangoapps/student/tests/test_filters.py @@ -1,6 +1,7 @@ """ Test that various filters are fired for models/views in the student app. """ +from django.conf import settings from django.http import HttpResponse from django.test import override_settings from django.urls import reverse @@ -421,7 +422,7 @@ def test_dashboard_redirect_account_settings(self): response = self.client.get(self.dashboard_url) self.assertEqual(status.HTTP_302_FOUND, response.status_code) - self.assertEqual(reverse("account_settings"), response.url) + self.assertEqual(settings.ACCOUNT_MICROFRONTEND_URL, response.url) @override_settings( OPEN_EDX_FILTERS_CONFIG={ diff --git a/common/djangoapps/student/tests/test_parental_controls.py b/common/djangoapps/student/tests/test_parental_controls.py index 4e49f88af99..62cd30707d5 100644 --- a/common/djangoapps/student/tests/test_parental_controls.py +++ b/common/djangoapps/student/tests/test_parental_controls.py @@ -59,7 +59,7 @@ def test_child_user(self): self.set_year_of_birth(current_year - 13) assert self.profile.requires_parental_consent() assert self.profile.requires_parental_consent(year=current_year) - assert not self.profile.requires_parental_consent(year=(current_year + 1)) + assert not self.profile.requires_parental_consent(year=current_year + 1) # Verify for a child born 14 years ago self.set_year_of_birth(current_year - 14) diff --git a/common/djangoapps/student/tests/test_views.py b/common/djangoapps/student/tests/test_views.py index 16bab13b90e..b63c522bbd0 100644 --- a/common/djangoapps/student/tests/test_views.py +++ b/common/djangoapps/student/tests/test_views.py @@ -51,8 +51,8 @@ TOMORROW = now() + timedelta(days=1) ONE_WEEK_AGO = now() - timedelta(weeks=1) -THREE_YEARS_FROM_NOW = now() + timedelta(days=(365 * 3)) -THREE_YEARS_AGO = now() - timedelta(days=(365 * 3)) +THREE_YEARS_FROM_NOW = now() + timedelta(days=365 * 3) +THREE_YEARS_AGO = now() - timedelta(days=365 * 3) # Name of the method to mock for Content Type Gating. GATING_METHOD_NAME = 'openedx.features.content_type_gating.models.ContentTypeGatingConfig.enabled_for_enrollment' @@ -233,7 +233,7 @@ def test_redirect_account_settings(self): """ UserProfile.objects.get(user=self.user).delete() response = self.client.get(self.path) - self.assertRedirects(response, reverse('account_settings')) + self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, target_status_code=302) @patch('common.djangoapps.student.views.dashboard.learner_home_mfe_enabled') def test_redirect_to_learner_home(self, mock_learner_home_mfe_enabled): diff --git a/common/djangoapps/student/views/dashboard.py b/common/djangoapps/student/views/dashboard.py index f729a2aee13..05279fe8cdd 100644 --- a/common/djangoapps/student/views/dashboard.py +++ b/common/djangoapps/student/views/dashboard.py @@ -518,7 +518,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem """ user = request.user if not UserProfile.objects.filter(user=user).exists(): - return redirect(reverse('account_settings')) + return redirect(settings.ACCOUNT_MICROFRONTEND_URL) if learner_home_mfe_enabled(): return redirect(settings.LEARNER_HOME_MICROFRONTEND_URL) @@ -623,7 +623,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem "Go to {link_start}your Account Settings{link_end}.") ).format( link_start=HTML("").format( - account_setting_page=reverse('account_settings'), + account_setting_page=settings.ACCOUNT_MICROFRONTEND_URL, ), link_end=HTML("") ) @@ -892,7 +892,7 @@ def student_dashboard(request): # lint-amnesty, pylint: disable=too-many-statem except DashboardRenderStarted.RenderInvalidDashboard as exc: response = render_to_response(exc.dashboard_template, exc.template_context) except DashboardRenderStarted.RedirectToPage as exc: - response = HttpResponseRedirect(exc.redirect_to or reverse('account_settings')) + response = HttpResponseRedirect(exc.redirect_to or settings.ACCOUNT_MICROFRONTEND_URL) except DashboardRenderStarted.RenderCustomResponse as exc: response = exc.response else: diff --git a/common/djangoapps/third_party_auth/api/tests/test_views.py b/common/djangoapps/third_party_auth/api/tests/test_views.py index aea4c18367e..670caf04c7f 100644 --- a/common/djangoapps/third_party_auth/api/tests/test_views.py +++ b/common/djangoapps/third_party_auth/api/tests/test_views.py @@ -2,10 +2,11 @@ Tests for the Third Party Auth REST API """ +import urllib from unittest.mock import patch import ddt -import six +from django.conf import settings from django.http import QueryDict from django.test.utils import override_settings from django.urls import reverse @@ -60,7 +61,7 @@ def setUp(self): # pylint: disable=arguments-differ # Create several users and link each user to Google and TestShib for username in LINKED_USERS: - make_superuser = (username == ADMIN_USERNAME) + make_superuser = username == ADMIN_USERNAME make_staff = (username == STAFF_USERNAME) or make_superuser user = UserFactory.create( username=username, @@ -219,7 +220,7 @@ def make_url(self, identifier): """ return '?'.join([ reverse('third_party_auth_users_api_v2'), - six.moves.urllib.parse.urlencode(identifier) + urllib.parse.urlencode(identifier) ]) @@ -377,11 +378,12 @@ def test_get(self): """ self.client.login(username=self.user.username, password=PASSWORD) response = self.client.get(self.url, content_type="application/json") + next_url = urllib.parse.quote(settings.ACCOUNT_MICROFRONTEND_URL, safe="") assert response.status_code == 200 assert (response.data == [{ 'accepts_logins': True, 'name': 'Google', 'disconnect_url': '/auth/disconnect/google-oauth2/?', - 'connect_url': '/auth/login/google-oauth2/?auth_entry=account_settings&next=%2Faccount%2Fsettings', + 'connect_url': f'/auth/login/google-oauth2/?auth_entry=account_settings&next={next_url}', 'connected': False, 'id': 'oa2-google-oauth2' }]) diff --git a/common/djangoapps/third_party_auth/api/views.py b/common/djangoapps/third_party_auth/api/views.py index 97d1a7d6dba..c1127f8e335 100644 --- a/common/djangoapps/third_party_auth/api/views.py +++ b/common/djangoapps/third_party_auth/api/views.py @@ -9,7 +9,6 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db.models import Q from django.http import Http404 -from django.urls import reverse from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser from rest_framework import exceptions, permissions, status, throttling @@ -425,7 +424,7 @@ def get(self, request): state.provider.provider_id, pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, # The url the user should be directed to after the auth process has completed. - redirect_url=reverse('account_settings'), + redirect_url=settings.ACCOUNT_MICROFRONTEND_URL, ), 'accepts_logins': state.provider.accepts_logins, # If the user is connected, sending a POST request to this url removes the connection diff --git a/common/djangoapps/third_party_auth/lti.py b/common/djangoapps/third_party_auth/lti.py index 3895c888661..496b7dcbbba 100644 --- a/common/djangoapps/third_party_auth/lti.py +++ b/common/djangoapps/third_party_auth/lti.py @@ -177,7 +177,7 @@ def safe_int(value): # As this must take constant time, do not use shortcutting operators such as 'and'. # Instead, use constant time operators such as '&', which is the bitwise and. - valid = (lti_consumer_valid) + valid = lti_consumer_valid valid = valid & (submitted_signature == computed_signature) valid = valid & (request.oauth_version == '1.0') valid = valid & (request.oauth_signature_method == 'HMAC-SHA1') diff --git a/common/djangoapps/third_party_auth/tests/specs/base.py b/common/djangoapps/third_party_auth/tests/specs/base.py index 8f96235017d..524cd64ff36 100644 --- a/common/djangoapps/third_party_auth/tests/specs/base.py +++ b/common/djangoapps/third_party_auth/tests/specs/base.py @@ -2,7 +2,6 @@ Base integration test for provider implementations. """ - import json import unittest from contextlib import contextmanager @@ -11,7 +10,7 @@ import pytest from django import test from django.conf import settings -from django.contrib import auth +from django.contrib import auth, messages from django.contrib.auth import models as auth_models from django.contrib.messages.storage import fallback from django.contrib.sessions.backends import cache @@ -28,7 +27,6 @@ from openedx.core.djangoapps.user_authn.views.register import RegistrationView from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory -from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context from common.djangoapps.student import models as student_models from common.djangoapps.student.tests.factories import UserFactory @@ -56,9 +54,9 @@ def _check_registration_form_username(self, form_data, test_username, expected): test_username (str): username to check the form initialization with. expected (str): expected cleaned username after the form initialization. """ - form_data['username'] = test_username + form_data["username"] = test_username form_field_data = self.provider.get_register_form_data(form_data) - assert form_field_data['username'] == expected + assert form_field_data["username"] == expected def assert_redirect_to_provider_looks_correct(self, response): """Asserts the redirect to the provider's site looks correct. @@ -70,9 +68,11 @@ def assert_redirect_to_provider_looks_correct(self, response): example, more details about the format of the Location header. """ assert 302 == response.status_code - assert response.has_header('Location') + assert response.has_header("Location") - def assert_register_response_in_pipeline_looks_correct(self, response, pipeline_kwargs, required_fields): # lint-amnesty, pylint: disable=invalid-name + def assert_register_response_in_pipeline_looks_correct( + self, response, pipeline_kwargs, required_fields + ): # lint-amnesty, pylint: disable=invalid-name """Performs spot checks of the rendered register.html page. When we display the new account registration form after the user signs @@ -84,10 +84,7 @@ def assert_register_response_in_pipeline_looks_correct(self, response, pipeline_ assertions in your test, override this method. """ # Check that the correct provider was selected. - self.assertContains( - response, - '"errorMessage": null' - ) + self.assertContains(response, '"errorMessage": null') self.assertContains( response, f'"currentProvider": "{self.provider.name}"', @@ -99,45 +96,67 @@ def assert_register_response_in_pipeline_looks_correct(self, response, pipeline_ if prepopulated_form_data in required_fields: self.assertContains(response, form_field_data[prepopulated_form_data]) - def assert_register_form_populates_unicode_username_correctly(self, request): # lint-amnesty, pylint: disable=invalid-name + def _get_user_providers_state(self, request): """ - Check the registration form username field behaviour with unicode values. - - The field could be empty or prefilled depending on whether ENABLE_UNICODE_USERNAME feature is disabled/enabled. + Return provider user states and duplicated providers. """ - unicode_username = 'Червона_Калина' - ascii_substring = 'untouchable' - partial_unicode_username = unicode_username + ascii_substring - pipeline_kwargs = pipeline.get(request)['kwargs'] - - assert settings.FEATURES['ENABLE_UNICODE_USERNAME'] is False - - self._check_registration_form_username(pipeline_kwargs, unicode_username, '') - self._check_registration_form_username(pipeline_kwargs, partial_unicode_username, ascii_substring) - - with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_UNICODE_USERNAME': True}): - self._check_registration_form_username(pipeline_kwargs, unicode_username, unicode_username) - - # pylint: disable=invalid-name - def assert_account_settings_context_looks_correct(self, context, duplicate=False, linked=None): - """Asserts the user's account settings page context is in the expected state. + data = { + "auth": {}, + } + data["duplicate_provider"] = pipeline.get_duplicate_provider(messages.get_messages(request)) + auth_states = pipeline.get_provider_user_states(request.user) + data["auth"]["providers"] = [ + { + "name": state.provider.name, + "connected": state.has_account, + } + for state in auth_states + if state.provider.display_for_login or state.has_account + ] + return data + + def assert_third_party_accounts_state(self, request, duplicate=False, linked=None): + """ + Asserts the user's third party account in the expected state. - If duplicate is True, we expect context['duplicate_provider'] to contain + If duplicate is True, we expect data['duplicate_provider'] to contain the duplicate provider backend name. If linked is passed, we conditionally - check that the provider is included in context['auth']['providers'] and + check that the provider is included in data['auth']['providers'] and its connected state is correct. """ + data = self._get_user_providers_state(request) if duplicate: - assert context['duplicate_provider'] == self.provider.backend_name + assert data["duplicate_provider"] == self.provider.backend_name else: - assert context['duplicate_provider'] is None + assert data["duplicate_provider"] is None if linked is not None: expected_provider = [ - provider for provider in context['auth']['providers'] if provider['name'] == self.provider.name + provider for provider in data["auth"]["providers"] if provider["name"] == self.provider.name ][0] assert expected_provider is not None - assert expected_provider['connected'] == linked + assert expected_provider["connected"] == linked + + def assert_register_form_populates_unicode_username_correctly( + self, request + ): # lint-amnesty, pylint: disable=invalid-name + """ + Check the registration form username field behaviour with unicode values. + + The field could be empty or prefilled depending on whether ENABLE_UNICODE_USERNAME feature is disabled/enabled. + """ + unicode_username = "Червона_Калина" + ascii_substring = "untouchable" + partial_unicode_username = unicode_username + ascii_substring + pipeline_kwargs = pipeline.get(request)["kwargs"] + + assert settings.FEATURES["ENABLE_UNICODE_USERNAME"] is False + + self._check_registration_form_username(pipeline_kwargs, unicode_username, "") + self._check_registration_form_username(pipeline_kwargs, partial_unicode_username, ascii_substring) + + with mock.patch.dict("django.conf.settings.FEATURES", {"ENABLE_UNICODE_USERNAME": True}): + self._check_registration_form_username(pipeline_kwargs, unicode_username, unicode_username) def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None): """Tests middleware conditional redirection. @@ -147,49 +166,48 @@ def assert_exception_redirect_looks_correct(self, expected_uri, auth_entry=None) """ exception_middleware = middleware.ExceptionMiddleware(get_response=lambda request: None) request, _ = self.get_request_and_strategy(auth_entry=auth_entry) - response = exception_middleware.process_exception( - request, exceptions.AuthCanceled(request.backend)) - location = response.get('Location') + response = exception_middleware.process_exception(request, exceptions.AuthCanceled(request.backend)) + location = response.get("Location") assert 302 == response.status_code - assert 'canceled' in location + assert "canceled" in location assert self.backend_name in location - assert location.startswith(expected_uri + '?') + assert location.startswith(expected_uri + "?") def assert_json_failure_response_is_inactive_account(self, response): """Asserts failure on /login for inactive account looks right.""" assert 400 == response.status_code - payload = json.loads(response.content.decode('utf-8')) + payload = json.loads(response.content.decode("utf-8")) context = { - 'platformName': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'supportLink': configuration_helpers.get_value('SUPPORT_SITE_LINK', settings.SUPPORT_SITE_LINK) + "platformName": configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME), + "supportLink": configuration_helpers.get_value("SUPPORT_SITE_LINK", settings.SUPPORT_SITE_LINK), } - assert not payload.get('success') - assert 'inactive-user' in payload.get('error_code') - assert context == payload.get('context') + assert not payload.get("success") + assert "inactive-user" in payload.get("error_code") + assert context == payload.get("context") def assert_json_failure_response_is_missing_social_auth(self, response): """Asserts failure on /login for missing social auth looks right.""" assert 403 == response.status_code - payload = json.loads(response.content.decode('utf-8')) - assert not payload.get('success') - assert payload.get('error_code') == 'third-party-auth-with-no-linked-account' + payload = json.loads(response.content.decode("utf-8")) + assert not payload.get("success") + assert payload.get("error_code") == "third-party-auth-with-no-linked-account" def assert_json_failure_response_is_username_collision(self, response): """Asserts the json response indicates a username collision.""" assert 409 == response.status_code - payload = json.loads(response.content.decode('utf-8')) - assert not payload.get('success') - assert 'It looks like this username is already taken' == payload['username'][0]['user_message'] + payload = json.loads(response.content.decode("utf-8")) + assert not payload.get("success") + assert "It looks like this username is already taken" == payload["username"][0]["user_message"] def assert_json_success_response_looks_correct(self, response, verify_redirect_url): """Asserts the json response indicates success and redirection.""" assert 200 == response.status_code - payload = json.loads(response.content.decode('utf-8')) - assert payload.get('success') + payload = json.loads(response.content.decode("utf-8")) + assert payload.get("success") if verify_redirect_url: - assert pipeline.get_complete_url(self.provider.backend_name) == payload.get('redirect_url') + assert pipeline.get_complete_url(self.provider.backend_name) == payload.get("redirect_url") def assert_login_response_before_pipeline_looks_correct(self, response): """Asserts a GET of /login not in the pipeline looks correct.""" @@ -218,19 +236,19 @@ def assert_redirect_after_pipeline_completes(self, response, expected_redirect_u assert 302 == response.status_code # NOTE: Ideally we should use assertRedirects(), however it errors out due to the hostname, testserver, # not being properly set. This may be an issue with the call made by PSA, but we are not certain. - assert response.get('Location').endswith( + assert response.get("Location").endswith( expected_redirect_url or django_settings.SOCIAL_AUTH_LOGIN_REDIRECT_URL ) def assert_redirect_to_login_looks_correct(self, response): """Asserts a response would redirect to /login.""" assert 302 == response.status_code - assert '/login' == response.get('Location') + assert "/login" == response.get("Location") def assert_redirect_to_register_looks_correct(self, response): """Asserts a response would redirect to /register.""" assert 302 == response.status_code - assert '/register' == response.get('Location') + assert "/register" == response.get("Location") def assert_register_response_before_pipeline_looks_correct(self, response): """Asserts a GET of /register not in the pipeline looks correct.""" @@ -241,43 +259,41 @@ def assert_register_response_before_pipeline_looks_correct(self, response): def assert_social_auth_does_not_exist_for_user(self, user, strategy): """Asserts a user does not have an auth with the expected provider.""" - social_auths = strategy.storage.user.get_social_auth_for_user( - user, provider=self.provider.backend_name) + social_auths = strategy.storage.user.get_social_auth_for_user(user, provider=self.provider.backend_name) assert 0 == len(social_auths) def assert_social_auth_exists_for_user(self, user, strategy): """Asserts a user has a social auth with the expected provider.""" - social_auths = strategy.storage.user.get_social_auth_for_user( - user, provider=self.provider.backend_name) + social_auths = strategy.storage.user.get_social_auth_for_user(user, provider=self.provider.backend_name) assert 1 == len(social_auths) assert self.backend_name == social_auths[0].provider def assert_logged_in_cookie_redirect(self, response): - """Verify that the user was redirected in order to set the logged in cookie. """ + """Verify that the user was redirected in order to set the logged in cookie.""" assert response.status_code == 302 - assert response['Location'] == pipeline.get_complete_url(self.provider.backend_name) - assert response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value == 'true' + assert response["Location"] == pipeline.get_complete_url(self.provider.backend_name) + assert response.cookies[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME].value == "true" assert django_settings.EDXMKTG_USER_INFO_COOKIE_NAME in response.cookies @property def backend_name(self): - """ Shortcut for the backend name """ + """Shortcut for the backend name""" return self.provider.backend_name def get_registration_post_vars(self, overrides=None): """POST vars generated by the registration form.""" defaults = { - 'username': 'username', - 'name': 'First Last', - 'gender': '', - 'year_of_birth': '', - 'level_of_education': '', - 'goals': '', - 'honor_code': 'true', - 'terms_of_service': 'true', - 'password': 'password', - 'mailing_address': '', - 'email': 'user@email.com', + "username": "username", + "name": "First Last", + "gender": "", + "year_of_birth": "", + "level_of_education": "", + "goals": "", + "honor_code": "true", + "terms_of_service": "true", + "password": "password", + "mailing_address": "", + "email": "user@email.com", } if overrides: @@ -294,12 +310,13 @@ def get_request_and_strategy(self, auth_entry=None, redirect_uri=None): social_django.utils.strategy(). """ request = self.request_factory.get( - pipeline.get_complete_url(self.backend_name) + - '?redirect_state=redirect_state_value&code=code_value&state=state_value') + pipeline.get_complete_url(self.backend_name) + + "?redirect_state=redirect_state_value&code=code_value&state=state_value" + ) request.site = SiteFactory.create() request.user = auth_models.AnonymousUser() request.session = cache.SessionStore() - request.session[self.backend_name + '_state'] = 'state_value' + request.session[self.backend_name + "_state"] = "state_value" if auth_entry: request.session[pipeline.AUTH_ENTRY_KEY] = auth_entry @@ -312,7 +329,7 @@ def get_request_and_strategy(self, auth_entry=None, redirect_uri=None): def _get_login_post_request(self, strategy): """Gets a fully-configured login POST request given a strategy and pipeline.""" - request = self.request_factory.post(reverse('login_api')) + request = self.request_factory.post(reverse("login_api")) # Note: The shared GET request can't be used for login, which is now POST-only, # so this POST request is given a copy of all configuration from the GET request @@ -329,7 +346,7 @@ def _get_login_post_request(self, strategy): def _patch_edxmako_current_request(self, request): """Make ``request`` be the current request for edxmako template rendering.""" - with mock.patch('common.djangoapps.edxmako.request_context.get_current_request', return_value=request): + with mock.patch("common.djangoapps.edxmako.request_context.get_current_request", return_value=request): yield def get_user_by_email(self, strategy, email): @@ -337,11 +354,13 @@ def get_user_by_email(self, strategy, email): return strategy.storage.user.user_model().objects.get(email=email) def set_logged_in_cookies(self, request): - """Simulate setting the marketing site cookie on the request. """ - request.COOKIES[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] = 'true' - request.COOKIES[django_settings.EDXMKTG_USER_INFO_COOKIE_NAME] = json.dumps({ - 'version': django_settings.EDXMKTG_USER_INFO_COOKIE_VERSION, - }) + """Simulate setting the marketing site cookie on the request.""" + request.COOKIES[django_settings.EDXMKTG_LOGGED_IN_COOKIE_NAME] = "true" + request.COOKIES[django_settings.EDXMKTG_USER_INFO_COOKIE_NAME] = json.dumps( + { + "version": django_settings.EDXMKTG_USER_INFO_COOKIE_VERSION, + } + ) def create_user_models_for_existing_account(self, strategy, email, password, username, skip_social_auth=False): """Creates user, profile, registration, and (usually) social auth. @@ -371,10 +390,10 @@ def fake_auth_complete(self, strategy): """ args = () kwargs = { - 'request': strategy.request, - 'backend': strategy.request.backend, - 'user': None, - 'response': self.get_response_data(), + "request": strategy.request, + "backend": strategy.request.backend, + "user": None, + "response": self.get_response_data(), } return strategy.authenticate(*args, **kwargs) @@ -386,6 +405,7 @@ class IntegrationTestMixin(testutil.TestCase, test.TestCase, HelperMixin): currently less comprehensive. Some providers are tested with this, others with IntegrationTest. """ + # Provider information: PROVIDER_NAME = "override" PROVIDER_BACKEND = "override" @@ -399,8 +419,8 @@ def setUp(self): super().setUp() self.request_factory = test.RequestFactory() - self.login_page_url = reverse('signin_user') - self.register_page_url = reverse('register_user') + self.login_page_url = reverse("signin_user") + self.register_page_url = reverse("register_user") patcher = testutil.patch_mako_templates() patcher.start() self.addCleanup(patcher.stop) @@ -415,47 +435,44 @@ def _test_register(self, **extra_defaults): try_login_response = self.client.get(provider_register_url) # The user should be redirected to the provider's login page: assert try_login_response.status_code == 302 - provider_response = self.do_provider_login(try_login_response['Location']) + provider_response = self.do_provider_login(try_login_response["Location"]) # We should be redirected to the register screen since this account is not linked to an edX account: assert provider_response.status_code == 302 - assert provider_response['Location'] == self.register_page_url + assert provider_response["Location"] == self.register_page_url register_response = self.client.get(self.register_page_url) tpa_context = register_response.context["data"]["third_party_auth"] - assert tpa_context['errorMessage'] is None + assert tpa_context["errorMessage"] is None # Check that the "You've successfully signed into [PROVIDER_NAME]" message is shown. - assert tpa_context['currentProvider'] == self.PROVIDER_NAME + assert tpa_context["currentProvider"] == self.PROVIDER_NAME # Check that the data (e.g. email) from the provider is displayed in the form: - form_data = register_response.context['data']['registration_form_desc'] - form_fields = {field['name']: field for field in form_data['fields']} - assert form_fields['email']['defaultValue'] == self.USER_EMAIL - assert form_fields['name']['defaultValue'] == self.USER_NAME - assert form_fields['username']['defaultValue'] == self.USER_USERNAME + form_data = register_response.context["data"]["registration_form_desc"] + form_fields = {field["name"]: field for field in form_data["fields"]} + assert form_fields["email"]["defaultValue"] == self.USER_EMAIL + assert form_fields["name"]["defaultValue"] == self.USER_NAME + assert form_fields["username"]["defaultValue"] == self.USER_USERNAME for field_name, value in extra_defaults.items(): - assert form_fields[field_name]['defaultValue'] == value + assert form_fields[field_name]["defaultValue"] == value registration_values = { - 'email': 'email-edited@tpa-test.none', - 'name': 'My Customized Name', - 'username': 'new_username', - 'honor_code': True, + "email": "email-edited@tpa-test.none", + "name": "My Customized Name", + "username": "new_username", + "honor_code": True, } # Now complete the form: - ajax_register_response = self.client.post( - reverse('user_api_registration'), - registration_values - ) + ajax_register_response = self.client.post(reverse("user_api_registration"), registration_values) assert ajax_register_response.status_code == 200 # Then the AJAX will finish the third party auth: continue_response = self.client.get(tpa_context["finishAuthUrl"]) # And we should be redirected to the dashboard: assert continue_response.status_code == 302 - assert continue_response['Location'] == reverse('dashboard') + assert continue_response["Location"] == reverse("dashboard") # Now check that we can login again, whether or not we have yet verified the account: self.client.logout() self._test_return_login(user_is_activated=False) self.client.logout() - self.verify_user_email('email-edited@tpa-test.none') + self.verify_user_email("email-edited@tpa-test.none") self._test_return_login(user_is_activated=True) def _test_login(self): @@ -468,27 +485,27 @@ def _test_login(self): try_login_response = self.client.get(provider_login_url) # The user should be redirected to the provider's login page: assert try_login_response.status_code == 302 - complete_response = self.do_provider_login(try_login_response['Location']) + complete_response = self.do_provider_login(try_login_response["Location"]) # We should be redirected to the login screen since this account is not linked to an edX account: assert complete_response.status_code == 302 - assert complete_response['Location'] == self.login_page_url + assert complete_response["Location"] == self.login_page_url login_response = self.client.get(self.login_page_url) tpa_context = login_response.context["data"]["third_party_auth"] - assert tpa_context['errorMessage'] is None + assert tpa_context["errorMessage"] is None # Check that the "You've successfully signed into [PROVIDER_NAME]" message is shown. - assert tpa_context['currentProvider'] == self.PROVIDER_NAME + assert tpa_context["currentProvider"] == self.PROVIDER_NAME # Now the user enters their username and password. # 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': 'Password1234'} + reverse("user_api_login_session", kwargs={"api_version": "v1"}), + {"email": self.user.email, "password": "Password1234"}, ) assert ajax_login_response.status_code == 200 # Then the AJAX will finish the third party auth: continue_response = self.client.get(tpa_context["finishAuthUrl"]) # And we should be redirected to the dashboard: assert continue_response.status_code == 302 - assert continue_response['Location'] == reverse('dashboard') + assert continue_response["Location"] == reverse("dashboard") # Now check that we can login again: self.client.logout() @@ -502,9 +519,9 @@ def do_provider_login(self, provider_redirect_url): raise NotImplementedError def _test_return_login(self, user_is_activated=True, previous_session_timed_out=False): - """ Test logging in to an account that is already linked. """ + """Test logging in to an account that is already linked.""" # Make sure we're not logged in: - dashboard_response = self.client.get(reverse('dashboard')) + dashboard_response = self.client.get(reverse("dashboard")) assert dashboard_response.status_code == 302 # The user goes to the login page, and sees a button to login with this provider: provider_login_url = self._check_login_page() @@ -512,22 +529,22 @@ def _test_return_login(self, user_is_activated=True, previous_session_timed_out= try_login_response = self.client.get(provider_login_url) # The user should be redirected to the provider: assert try_login_response.status_code == 302 - login_response = self.do_provider_login(try_login_response['Location']) + login_response = self.do_provider_login(try_login_response["Location"]) # If the previous session was manually logged out, there will be one weird redirect # required to set the login cookie (it sticks around if the main session times out): if not previous_session_timed_out: assert login_response.status_code == 302 - assert login_response['Location'] == (self.complete_url + '?') + assert login_response["Location"] == (self.complete_url + "?") # And then we should be redirected to the dashboard: - login_response = self.client.get(login_response['Location']) + login_response = self.client.get(login_response["Location"]) assert login_response.status_code == 302 if user_is_activated: - url_expected = reverse('dashboard') + url_expected = reverse("dashboard") else: - url_expected = reverse('third_party_inactive_redirect') + '?next=' + reverse('dashboard') - assert login_response['Location'] == url_expected + url_expected = reverse("third_party_inactive_redirect") + "?next=" + reverse("dashboard") + assert login_response["Location"] == url_expected # Now we are logged in: - dashboard_response = self.client.get(reverse('dashboard')) + dashboard_response = self.client.get(reverse("dashboard")) assert dashboard_response.status_code == 200 def _check_login_page(self): @@ -545,22 +562,23 @@ def _check_register_page(self): return self._check_login_or_register_page(self.register_page_url, "registerUrl") def _check_login_or_register_page(self, url, url_to_return): - """ Shared logic for _check_login_page() and _check_register_page() """ + """Shared logic for _check_login_page() and _check_register_page()""" response = self.client.get(url) self.assertContains(response, self.PROVIDER_NAME) - context_data = response.context['data']['third_party_auth'] - provider_urls = {provider['id']: provider[url_to_return] for provider in context_data['providers']} + context_data = response.context["data"]["third_party_auth"] + provider_urls = {provider["id"]: provider[url_to_return] for provider in context_data["providers"]} assert self.PROVIDER_ID in provider_urls return provider_urls[self.PROVIDER_ID] @property def complete_url(self): - """ Get the auth completion URL for this provider """ - return reverse('social:complete', kwargs={'backend': self.PROVIDER_BACKEND}) + """Get the auth completion URL for this provider""" + return reverse("social:complete", kwargs={"backend": self.PROVIDER_BACKEND}) @unittest.skipUnless( - testutil.AUTH_FEATURES_KEY in django_settings.FEATURES, testutil.AUTH_FEATURES_KEY + ' not in settings.FEATURES') + testutil.AUTH_FEATURES_KEY in django_settings.FEATURES, testutil.AUTH_FEATURES_KEY + " not in settings.FEATURES" +) @django_utils.override_settings() # For settings reversion on a method-by-method basis. class IntegrationTest(testutil.TestCase, test.TestCase, HelperMixin): """Abstract base class for provider integration tests.""" @@ -572,46 +590,51 @@ def setUp(self): # Actual tests, executed once per child. def test_canceling_authentication_redirects_to_login_when_auth_entry_login(self): - self.assert_exception_redirect_looks_correct('/login', auth_entry=pipeline.AUTH_ENTRY_LOGIN) + self.assert_exception_redirect_looks_correct("/login", auth_entry=pipeline.AUTH_ENTRY_LOGIN) def test_canceling_authentication_redirects_to_register_when_auth_entry_register(self): - self.assert_exception_redirect_looks_correct('/register', auth_entry=pipeline.AUTH_ENTRY_REGISTER) + self.assert_exception_redirect_looks_correct("/register", auth_entry=pipeline.AUTH_ENTRY_REGISTER) def test_canceling_authentication_redirects_to_account_settings_when_auth_entry_account_settings(self): self.assert_exception_redirect_looks_correct( - '/account/settings', auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS + "/account/settings", auth_entry=pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS ) def test_canceling_authentication_redirects_to_root_when_auth_entry_not_set(self): - self.assert_exception_redirect_looks_correct('/') + self.assert_exception_redirect_looks_correct("/") - @mock.patch('common.djangoapps.third_party_auth.pipeline.segment.track') + @mock.patch("common.djangoapps.third_party_auth.pipeline.segment.track") def test_full_pipeline_succeeds_for_linking_account(self, _mock_segment_track): # First, create, the GET request and strategy that store pipeline state, # configure the backend, and mock out wire traffic. get_request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) get_request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) get_request.user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) - partial_pipeline_token = strategy.session_get('partial_pipeline_token') + strategy, "user@example.com", "password", self.get_username(), skip_social_auth=True + ) + partial_pipeline_token = strategy.session_get("partial_pipeline_token") partial_data = strategy.storage.partial.load(partial_pipeline_token) # Instrument the pipeline to get to the dashboard with the full # expected state. - self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) - actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access - request=get_request) + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + actions.do_complete( + get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access + ) post_request = self._get_login_post_request(strategy) login_user(post_request) - actions.do_complete(post_request.backend, social_views._do_login, # pylint: disable=protected-access, no-member - request=post_request) + actions.do_complete( + post_request.backend, + social_views._do_login, # pylint: disable=protected-access, no-member + request=post_request, + ) # First we expect that we're in the unlinked state, and that there # really is no association in the backend. - self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=False) + self.assert_third_party_accounts_state(get_request, linked=False) self.assert_social_auth_does_not_exist_for_user(get_request.user, strategy) # We should be redirected back to the complete page, setting @@ -630,16 +653,18 @@ def test_full_pipeline_succeeds_for_linking_account(self, _mock_segment_track): # Now we expect to be in the linked state, with a backend entry. self.assert_social_auth_exists_for_user(get_request.user, strategy) - self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=True) + self.assert_third_party_accounts_state(get_request, linked=True) def test_full_pipeline_succeeds_for_unlinking_account(self): # First, create, the GET request and strategy that store pipeline state, # configure the backend, and mock out wire traffic. get_request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) get_request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username()) + strategy, "user@example.com", "password", self.get_username() + ) self.assert_social_auth_exists_for_user(user, strategy) # We're already logged in, so simulate that the cookie is set correctly @@ -647,36 +672,37 @@ def test_full_pipeline_succeeds_for_unlinking_account(self): # Instrument the pipeline to get to the dashboard with the full # expected state. - self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) - actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access - request=get_request) + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + actions.do_complete( + get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access + ) post_request = self._get_login_post_request(strategy) with self._patch_edxmako_current_request(post_request): login_user(post_request) - actions.do_complete(post_request.backend, social_views._do_login, user=user, # pylint: disable=protected-access, no-member - request=post_request) + actions.do_complete( + post_request.backend, + social_views._do_login, # pylint: disable=protected-access + user=user, # pylint: disable=no-member + request=post_request, + ) # Copy the user that was set on the post_request object back to the original get_request object. get_request.user = post_request.user # First we expect that we're in the linked state, with a backend entry. - self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=True) + self.assert_third_party_accounts_state(get_request, linked=True) self.assert_social_auth_exists_for_user(get_request.user, strategy) # Fire off the disconnect pipeline to unlink. self.assert_redirect_after_pipeline_completes( actions.do_disconnect( - get_request.backend, - get_request.user, - None, - redirect_field_name=auth.REDIRECT_FIELD_NAME + get_request.backend, get_request.user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME ) ) # Now we expect to be in the unlinked state, with no backend entry. - self.assert_account_settings_context_looks_correct(account_settings_context(get_request), linked=False) + self.assert_third_party_accounts_state(get_request, linked=False) self.assert_social_auth_does_not_exist_for_user(user, strategy) def test_linking_already_associated_account_raises_auth_already_associated(self): @@ -684,16 +710,18 @@ def test_linking_already_associated_account_raises_auth_already_associated(self) # test_already_associated_exception_populates_dashboard_with_error. It # verifies the exception gets raised when we expect; the latter test # covers exception handling. - email = 'user@example.com' - password = 'password' + email = "user@example.com" + password = "password" username = self.get_username() _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) backend = strategy.request.backend backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) linked_user = self.create_user_models_for_existing_account(strategy, email, password, username) unlinked_user = social_utils.Storage.user.create_user( - email='other_' + email, password=password, username='other_' + username) + email="other_" + email, password=password, username="other_" + username + ) self.assert_social_auth_exists_for_user(linked_user, strategy) self.assert_social_auth_does_not_exist_for_user(unlinked_user, strategy) @@ -711,42 +739,50 @@ def test_already_associated_exception_populates_dashboard_with_error(self): # covered in other tests. Using linked=True does, however, let us test # that the duplicate error has no effect on the state of the controls. get_request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username()) + strategy, "user@example.com", "password", self.get_username() + ) self.assert_social_auth_exists_for_user(user, strategy) - self.client.get('/login') + self.client.get("/login") self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) - actions.do_complete(get_request.backend, social_views._do_login, # pylint: disable=protected-access - request=get_request) + actions.do_complete( + get_request.backend, social_views._do_login, request=get_request # pylint: disable=protected-access + ) post_request = self._get_login_post_request(strategy) with self._patch_edxmako_current_request(post_request): login_user(post_request) - actions.do_complete(post_request.backend, social_views._do_login, # pylint: disable=protected-access, no-member - user=user, request=post_request) + actions.do_complete( + post_request.backend, + social_views._do_login, # pylint: disable=protected-access, no-member + user=user, + request=post_request, + ) # Monkey-patch storage for messaging; pylint: disable=protected-access post_request._messages = fallback.FallbackStorage(post_request) middleware.ExceptionMiddleware(get_response=lambda request: None).process_exception( - post_request, - exceptions.AuthAlreadyAssociated(self.provider.backend_name, 'account is already in use.')) + post_request, exceptions.AuthAlreadyAssociated(self.provider.backend_name, "account is already in use.") + ) - self.assert_account_settings_context_looks_correct( - account_settings_context(post_request), duplicate=True, linked=True) + self.assert_third_party_accounts_state(post_request, duplicate=True, linked=True) - @mock.patch('common.djangoapps.third_party_auth.pipeline.segment.track') + @mock.patch("common.djangoapps.third_party_auth.pipeline.segment.track") def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self, _mock_segment_track): # First, create, the GET request and strategy that store pipeline state, # configure the backend, and mock out wire traffic. get_request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username()) - partial_pipeline_token = strategy.session_get('partial_pipeline_token') + strategy, "user@example.com", "password", self.get_username() + ) + partial_pipeline_token = strategy.session_get("partial_pipeline_token") partial_data = strategy.storage.partial.load(partial_pipeline_token) self.assert_social_auth_exists_for_user(user, strategy) @@ -754,19 +790,21 @@ def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self, # Begin! Ensure that the login form contains expected controls before # the user starts the pipeline. - self.assert_login_response_before_pipeline_looks_correct(self.client.get('/login')) + self.assert_login_response_before_pipeline_looks_correct(self.client.get("/login")) # The pipeline starts by a user GETting /auth/login/. # Synthesize that request and check that it redirects to the correct # provider page. - self.assert_redirect_to_provider_looks_correct(self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))) + self.assert_redirect_to_provider_looks_correct( + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + ) # Next, the provider makes a request against /auth/complete/ # to resume the pipeline. # pylint: disable=protected-access - self.assert_redirect_to_login_looks_correct(actions.do_complete(get_request.backend, social_views._do_login, - request=get_request)) + self.assert_redirect_to_login_looks_correct( + actions.do_complete(get_request.backend, social_views._do_login, request=get_request) + ) # At this point we know the pipeline has resumed correctly. Next we # fire off the view that displays the login form and posts it via JS. @@ -781,10 +819,16 @@ def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self, # We should be redirected back to the complete page, setting # the "logged in" cookie for the marketing site. - self.assert_logged_in_cookie_redirect(actions.do_complete( - post_request.backend, social_views._do_login, post_request.user, None, # pylint: disable=protected-access, no-member - redirect_field_name=auth.REDIRECT_FIELD_NAME, request=post_request - )) + self.assert_logged_in_cookie_redirect( + actions.do_complete( + post_request.backend, + social_views._do_login, + post_request.user, + None, # pylint: disable=protected-access, no-member + redirect_field_name=auth.REDIRECT_FIELD_NAME, + request=post_request, + ) + ) # Set the cookie and try again self.set_logged_in_cookies(get_request) @@ -795,14 +839,16 @@ def test_full_pipeline_succeeds_for_signing_in_to_existing_active_account(self, self.assert_redirect_after_pipeline_completes( self.do_complete(strategy, get_request, partial_pipeline_token, partial_data, user) ) - self.assert_account_settings_context_looks_correct(account_settings_context(get_request)) + self.assert_third_party_accounts_state(get_request) def test_signin_fails_if_account_not_active(self): _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) - user = self.create_user_models_for_existing_account(strategy, 'user@example.com', 'password', - self.get_username()) + user = self.create_user_models_for_existing_account( + strategy, "user@example.com", "password", self.get_username() + ) user.is_active = False user.save() @@ -813,25 +859,28 @@ def test_signin_fails_if_account_not_active(self): def test_signin_fails_if_no_account_associated(self): _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username(), skip_social_auth=True) + strategy, "user@example.com", "password", self.get_username(), skip_social_auth=True + ) post_request = self._get_login_post_request(strategy) self.assert_json_failure_response_is_missing_social_auth(login_user(post_request)) def test_signin_associates_user_if_oauth_provider_and_tpa_is_required(self): - username, email, password = self.get_username(), 'user@example.com', 'password' + username, email, password = self.get_username(), "user@example.com", "password" _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) user = self.create_user_models_for_existing_account(strategy, email, password, username, skip_social_auth=True) with mock.patch( - 'common.djangoapps.third_party_auth.pipeline.get_associated_user_by_email_response', - return_value=[{'user': user}, True], + "common.djangoapps.third_party_auth.pipeline.get_associated_user_by_email_response", + return_value=[{"user": user}, True], ): strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) @@ -839,30 +888,37 @@ def test_signin_associates_user_if_oauth_provider_and_tpa_is_required(self): self.assert_json_success_response_looks_correct(login_user(post_request), verify_redirect_url=True) def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_email_in_request(self): - self.assert_first_party_auth_trumps_third_party_auth(email='user@example.com') + self.assert_first_party_auth_trumps_third_party_auth(email="user@example.com") def test_first_party_auth_trumps_third_party_auth_but_is_invalid_when_only_password_in_request(self): - self.assert_first_party_auth_trumps_third_party_auth(password='password') + self.assert_first_party_auth_trumps_third_party_auth(password="password") def test_first_party_auth_trumps_third_party_auth_and_fails_when_credentials_bad(self): self.assert_first_party_auth_trumps_third_party_auth( - email='user@example.com', password='password', success=False) + email="user@example.com", password="password", success=False + ) def test_first_party_auth_trumps_third_party_auth_and_succeeds_when_credentials_good(self): self.assert_first_party_auth_trumps_third_party_auth( - email='user@example.com', password='password', success=True) + email="user@example.com", password="password", success=True + ) def test_pipeline_redirects_to_requested_url(self): - requested_redirect_url = 'foo' # something different from '/dashboard' - request, strategy = self.get_request_and_strategy(redirect_uri='social:complete') + requested_redirect_url = "foo" # something different from '/dashboard' + request, strategy = self.get_request_and_strategy(redirect_uri="social:complete") strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) request.session[pipeline.AUTH_REDIRECT_KEY] = requested_redirect_url - user = self.create_user_models_for_existing_account(strategy, 'user@foo.com', 'password', self.get_username()) + user = self.create_user_models_for_existing_account(strategy, "user@foo.com", "password", self.get_username()) self.set_logged_in_cookies(request) self.assert_redirect_after_pipeline_completes( - actions.do_complete(request.backend, social_views._do_login, user=user, request=request), # pylint: disable=protected-access + actions.do_complete( + request.backend, + social_views._do_login, # pylint: disable=protected-access + user=user, + request=request, + ), requested_redirect_url, ) @@ -870,44 +926,47 @@ def test_full_pipeline_succeeds_registering_new_account(self): # First, create, the request and strategy that store pipeline state. # Mock out wire traffic. request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) - partial_pipeline_token = strategy.session_get('partial_pipeline_token') + partial_pipeline_token = strategy.session_get("partial_pipeline_token") partial_data = strategy.storage.partial.load(partial_pipeline_token) # Begin! Grab the registration page and check the login control on it. - self.assert_register_response_before_pipeline_looks_correct(self.client.get('/register')) + self.assert_register_response_before_pipeline_looks_correct(self.client.get("/register")) # The pipeline starts by a user GETting /auth/login/. # Synthesize that request and check that it redirects to the correct # provider page. - self.assert_redirect_to_provider_looks_correct(self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN))) + self.assert_redirect_to_provider_looks_correct( + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + ) # Next, the provider makes a request against /auth/complete/. # pylint: disable=protected-access - self.assert_redirect_to_register_looks_correct(actions.do_complete(request.backend, social_views._do_login, - request=request)) + self.assert_redirect_to_register_looks_correct( + actions.do_complete(request.backend, social_views._do_login, request=request) + ) # At this point we know the pipeline has resumed correctly. Next we # fire off the view that displays the registration form. with self._patch_edxmako_current_request(request): self.assert_register_form_populates_unicode_username_correctly(request) self.assert_register_response_in_pipeline_looks_correct( - login_and_registration_form(strategy.request, initial_mode='register'), - pipeline.get(request)['kwargs'], - ['name', 'username', 'email'] + login_and_registration_form(strategy.request, initial_mode="register"), + pipeline.get(request)["kwargs"], + ["name", "username", "email"], ) # Next, we invoke the view that handles the POST. Not all providers # supply email. Manually add it as the user would have to; this # also serves as a test of overriding provider values. Always provide a # password for us to check that we override it properly. - overridden_password = strategy.request.POST.get('password') - email = 'new@example.com' + overridden_password = strategy.request.POST.get("password") + email = "new@example.com" - if not strategy.request.POST.get('email'): - strategy.request.POST = self.get_registration_post_vars({'email': email}) + if not strategy.request.POST.get("email"): + strategy.request.POST = self.get_registration_post_vars({"email": email}) # The user must not exist yet... with pytest.raises(auth_models.User.DoesNotExist): @@ -935,41 +994,44 @@ def test_full_pipeline_succeeds_registering_new_account(self): self.assert_redirect_after_pipeline_completes( self.do_complete(strategy, request, partial_pipeline_token, partial_data, created_user) ) - # Now the user has been redirected to the dashboard. Their third party account should now be linked. + # Their third party account should now be linked. self.assert_social_auth_exists_for_user(created_user, strategy) - self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True) + self.assert_third_party_accounts_state(request, linked=True) def test_new_account_registration_assigns_distinct_username_on_collision(self): original_username = self.get_username() request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete" + ) # Create a colliding username in the backend, then proceed with # assignment via pipeline to make sure a distinct username is created. - strategy.storage.user.create_user(username=self.get_username(), email='user@email.com', password='password') + strategy.storage.user.create_user(username=self.get_username(), email="user@email.com", password="password") backend = strategy.request.backend backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) # pylint: disable=protected-access response = actions.do_complete(backend, social_views._do_login, request=request) assert response.status_code == 302 - response = json.loads(create_account(strategy.request).content.decode('utf-8')) - assert response['username'] != original_username + response = json.loads(create_account(strategy.request).content.decode("utf-8")) + assert response["username"] != original_username def test_new_account_registration_fails_if_email_exists(self): request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_REGISTER, redirect_uri="social:complete" + ) backend = strategy.request.backend backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) # pylint: disable=protected-access - self.assert_redirect_to_register_looks_correct(actions.do_complete(backend, social_views._do_login, - request=request)) + self.assert_redirect_to_register_looks_correct( + actions.do_complete(backend, social_views._do_login, request=request) + ) with self._patch_edxmako_current_request(request): self.assert_register_response_in_pipeline_looks_correct( - login_and_registration_form(strategy.request, initial_mode='register'), - pipeline.get(request)['kwargs'], - ['name', 'username', 'email'] + login_and_registration_form(strategy.request, initial_mode="register"), + pipeline.get(request)["kwargs"], + ["name", "username", "email"], ) with self._patch_edxmako_current_request(strategy.request): @@ -979,18 +1041,18 @@ def test_new_account_registration_fails_if_email_exists(self): self.assert_json_failure_response_is_username_collision(create_account(strategy.request)) def test_pipeline_raises_auth_entry_error_if_auth_entry_invalid(self): - auth_entry = 'invalid' + auth_entry = "invalid" assert auth_entry not in pipeline._AUTH_ENTRY_CHOICES # pylint: disable=protected-access - _, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri='social:complete') + _, strategy = self.get_request_and_strategy(auth_entry=auth_entry, redirect_uri="social:complete") with pytest.raises(pipeline.AuthEntryError): strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) def test_pipeline_assumes_login_if_auth_entry_missing(self): - _, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri='social:complete') + _, strategy = self.get_request_and_strategy(auth_entry=None, redirect_uri="social:complete") response = self.fake_auth_complete(strategy) - assert response.url == reverse('signin_user') + assert response.url == reverse("signin_user") def assert_first_party_auth_trumps_third_party_auth(self, email=None, password=None, success=None): """Asserts first party auth was used in place of third party auth. @@ -1004,33 +1066,35 @@ def assert_first_party_auth_trumps_third_party_auth(self, email=None, password=N one of username or password will be missing). """ _, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) strategy.request.backend.auth_complete = mock.MagicMock(return_value=self.fake_auth_complete(strategy)) self.create_user_models_for_existing_account( - strategy, email, password, self.get_username(), skip_social_auth=True) + strategy, email, password, self.get_username(), skip_social_auth=True + ) post_request = self._get_login_post_request(strategy) post_request.POST = dict(post_request.POST) if email: - post_request.POST['email'] = email + post_request.POST["email"] = email if password: - post_request.POST['password'] = 'bad_' + password if success is False else password + post_request.POST["password"] = "bad_" + password if success is False else password self.assert_pipeline_running(post_request) - payload = json.loads(login_user(post_request).content.decode('utf-8')) + payload = json.loads(login_user(post_request).content.decode("utf-8")) if success is None: # Request malformed -- just one of email/password given. - assert not payload.get('success') - assert 'There was an error receiving your login information' in payload.get('value') + assert not payload.get("success") + assert "There was an error receiving your login information" in payload.get("value") elif success: # Request well-formed and credentials good. - assert payload.get('success') + assert payload.get("success") else: # Request well-formed but credentials bad. - assert not payload.get('success') - assert 'incorrect' in payload.get('value') + assert not payload.get("success") + assert "incorrect" in payload.get("value") def get_response_data(self): """Gets a dict of response data of the form given by the provider. @@ -1064,8 +1128,13 @@ def do_complete(self, strategy, request, partial_pipeline_token, partial_data, u if not user: user = request.user return actions.do_complete( - request.backend, social_views._do_login, user, None, # pylint: disable=protected-access - redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request, partial_token=partial_pipeline_token + request.backend, + social_views._do_login, # pylint: disable=protected-access + user, + None, + redirect_field_name=auth.REDIRECT_FIELD_NAME, + request=request, + partial_token=partial_pipeline_token, ) diff --git a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py index ec3efd8286e..caddd325ba7 100644 --- a/common/djangoapps/third_party_auth/tests/specs/test_testshib.py +++ b/common/djangoapps/third_party_auth/tests/specs/test_testshib.py @@ -2,7 +2,6 @@ Third_party_auth integration tests using a mock version of the TestShib provider """ - import datetime import json import logging @@ -27,16 +26,15 @@ from common.djangoapps.third_party_auth.saml import log as saml_log from common.djangoapps.third_party_auth.tasks import fetch_saml_metadata from common.djangoapps.third_party_auth.tests import testutil, utils -from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context from openedx.core.djangoapps.user_authn.views.login import login_user from openedx.features.enterprise_support.tests.factories import EnterpriseCustomerFactory from .base import IntegrationTestMixin -TESTSHIB_ENTITY_ID = 'https://idp.testshib.org/idp/shibboleth' -TESTSHIB_METADATA_URL = 'https://mock.testshib.org/metadata/testshib-providers.xml' -TESTSHIB_METADATA_URL_WITH_CACHE_DURATION = 'https://mock.testshib.org/metadata/testshib-providers-cache.xml' -TESTSHIB_SSO_URL = 'https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO' +TESTSHIB_ENTITY_ID = "https://idp.testshib.org/idp/shibboleth" +TESTSHIB_METADATA_URL = "https://mock.testshib.org/metadata/testshib-providers.xml" +TESTSHIB_METADATA_URL_WITH_CACHE_DURATION = "https://mock.testshib.org/metadata/testshib-providers-cache.xml" +TESTSHIB_SSO_URL = "https://idp.testshib.org/idp/profile/SAML2/Redirect/SSO" class SamlIntegrationTestUtilities: @@ -44,6 +42,7 @@ class SamlIntegrationTestUtilities: Class contains methods particular to SAML integration testing so that they can be separated out from the actual test methods. """ + PROVIDER_ID = "saml-testshib" PROVIDER_NAME = "TestShib" PROVIDER_BACKEND = "tpa-saml" @@ -67,51 +66,59 @@ def setUp(self): self.addCleanup(httpretty.disable) # lint-amnesty, pylint: disable=no-member def metadata_callback(_request, _uri, headers): - """ Return a cached copy of TestShib's metadata by reading it from disk """ - return (200, headers, self.read_data_file('testshib_metadata.xml')) # lint-amnesty, pylint: disable=no-member + """Return a cached copy of TestShib's metadata by reading it from disk""" + return ( + 200, + headers, + self.read_data_file("testshib_metadata.xml"), + ) # lint-amnesty, pylint: disable=no-member - httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type='text/xml', body=metadata_callback) + httpretty.register_uri(httpretty.GET, TESTSHIB_METADATA_URL, content_type="text/xml", body=metadata_callback) def cache_duration_metadata_callback(_request, _uri, headers): """Return a cached copy of TestShib's metadata with a cacheDuration attribute""" - return (200, headers, self.read_data_file('testshib_metadata_with_cache_duration.xml')) # lint-amnesty, pylint: disable=no-member + return ( + 200, + headers, + self.read_data_file("testshib_metadata_with_cache_duration.xml"), + ) # lint-amnesty, pylint: disable=no-member httpretty.register_uri( httpretty.GET, TESTSHIB_METADATA_URL_WITH_CACHE_DURATION, - content_type='text/xml', - body=cache_duration_metadata_callback + content_type="text/xml", + body=cache_duration_metadata_callback, ) # Configure the SAML library to use the same request ID for every request. # Doing this and freezing the time allows us to play back recorded request/response pairs - uid_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id', return_value='TESTID') + uid_patch = patch("onelogin.saml2.utils.OneLogin_Saml2_Utils.generate_unique_id", return_value="TESTID") uid_patch.start() self.addCleanup(uid_patch.stop) # lint-amnesty, pylint: disable=no-member self._freeze_time(timestamp=1434326820) # This is the time when the saved request/response was recorded. def _freeze_time(self, timestamp): - """ Mock the current time for SAML, so we can replay canned requests/responses """ - now_patch = patch('onelogin.saml2.utils.OneLogin_Saml2_Utils.now', return_value=timestamp) + """Mock the current time for SAML, so we can replay canned requests/responses""" + now_patch = patch("onelogin.saml2.utils.OneLogin_Saml2_Utils.now", return_value=timestamp) now_patch.start() self.addCleanup(now_patch.stop) # lint-amnesty, pylint: disable=no-member def _configure_testshib_provider(self, **kwargs): - """ Enable and configure the TestShib SAML IdP as a third_party_auth provider """ - fetch_metadata = kwargs.pop('fetch_metadata', True) - assert_metadata_updates = kwargs.pop('assert_metadata_updates', True) - kwargs.setdefault('name', self.PROVIDER_NAME) - kwargs.setdefault('enabled', True) - kwargs.setdefault('visible', True) + """Enable and configure the TestShib SAML IdP as a third_party_auth provider""" + fetch_metadata = kwargs.pop("fetch_metadata", True) + assert_metadata_updates = kwargs.pop("assert_metadata_updates", True) + kwargs.setdefault("name", self.PROVIDER_NAME) + kwargs.setdefault("enabled", True) + kwargs.setdefault("visible", True) kwargs.setdefault("backend_name", "tpa-saml") - kwargs.setdefault('slug', self.PROVIDER_IDP_SLUG) - kwargs.setdefault('entity_id', TESTSHIB_ENTITY_ID) - kwargs.setdefault('metadata_source', TESTSHIB_METADATA_URL) - kwargs.setdefault('icon_class', 'fa-university') - kwargs.setdefault('attr_email', 'urn:oid:1.3.6.1.4.1.5923.1.1.1.6') # eduPersonPrincipalName - kwargs.setdefault('max_session_length', None) - kwargs.setdefault('send_to_registration_first', False) - kwargs.setdefault('skip_email_verification', False) + kwargs.setdefault("slug", self.PROVIDER_IDP_SLUG) + kwargs.setdefault("entity_id", TESTSHIB_ENTITY_ID) + kwargs.setdefault("metadata_source", TESTSHIB_METADATA_URL) + kwargs.setdefault("icon_class", "fa-university") + kwargs.setdefault("attr_email", "urn:oid:1.3.6.1.4.1.5923.1.1.1.6") # eduPersonPrincipalName + kwargs.setdefault("max_session_length", None) + kwargs.setdefault("send_to_registration_first", False) + kwargs.setdefault("skip_email_verification", False) saml_provider = self.configure_saml_provider(**kwargs) # pylint: disable=no-member if fetch_metadata: @@ -127,17 +134,17 @@ def _configure_testshib_provider(self, **kwargs): return saml_provider def do_provider_login(self, provider_redirect_url): - """ Mocked: the user logs in to TestShib and then gets redirected back """ + """Mocked: the user logs in to TestShib and then gets redirected back""" # The SAML provider (TestShib) will authenticate the user, then get the browser to POST a response: assert provider_redirect_url.startswith(TESTSHIB_SSO_URL) # lint-amnesty, pylint: disable=no-member saml_response_xml = utils.read_and_pre_process_xml( - os.path.join(os.path.dirname(os.path.dirname(__file__)), 'data', 'testshib_saml_response.xml') + os.path.join(os.path.dirname(os.path.dirname(__file__)), "data", "testshib_saml_response.xml") ) return self.client.post( # lint-amnesty, pylint: disable=no-member self.complete_url, # lint-amnesty, pylint: disable=no-member - content_type='application/x-www-form-urlencoded', + content_type="application/x-www-form-urlencoded", data=utils.prepare_saml_response_from_xml(saml_response_xml), ) @@ -150,16 +157,16 @@ class TestIndexExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin, """ TOKEN_RESPONSE_DATA = { - 'access_token': 'access_token_value', - 'expires_in': 'expires_in_value', + "access_token": "access_token_value", + "expires_in": "expires_in_value", } USER_RESPONSE_DATA = { - 'lastName': 'lastName_value', - 'id': 'id_value', - 'firstName': 'firstName_value', - 'idp_name': 'testshib', - 'attributes': {'urn:oid:0.9.2342.19200300.100.1.1': [], 'name_id': '1'}, - 'session_index': '1', + "lastName": "lastName_value", + "id": "id_value", + "firstName": "firstName_value", + "idp_name": "testshib", + "attributes": {"urn:oid:0.9.2342.19200300.100.1.1": [], "name_id": "1"}, + "session_index": "1", } def test_index_error_from_empty_list_saml_attribute(self): @@ -169,7 +176,8 @@ def test_index_error_from_empty_list_saml_attribute(self): """ self.provider = self._configure_testshib_provider() request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) with self.assertRaises(IncorrectConfigurationException): request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy)) @@ -188,16 +196,16 @@ class TestKeyExceptionTest(SamlIntegrationTestUtilities, IntegrationTestMixin, t """ TOKEN_RESPONSE_DATA = { - 'access_token': 'access_token_value', - 'expires_in': 'expires_in_value', + "access_token": "access_token_value", + "expires_in": "expires_in_value", } USER_RESPONSE_DATA = { - 'lastName': 'lastName_value', - 'id': 'id_value', - 'firstName': 'firstName_value', - 'idp_name': 'testshib', - 'attributes': {'name_id': '1'}, - 'session_index': '1', + "lastName": "lastName_value", + "id": "id_value", + "firstName": "firstName_value", + "idp_name": "testshib", + "attributes": {"name_id": "1"}, + "session_index": "1", } def test_key_error_from_missing_saml_attributes(self): @@ -207,7 +215,8 @@ def test_key_error_from_missing_saml_attributes(self): """ self.provider = self._configure_testshib_provider() request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) with self.assertRaises(IncorrectConfigurationException): request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy)) @@ -226,25 +235,23 @@ class TestShibIntegrationTest(SamlIntegrationTestUtilities, IntegrationTestMixin """ TOKEN_RESPONSE_DATA = { - 'access_token': 'access_token_value', - 'expires_in': 'expires_in_value', + "access_token": "access_token_value", + "expires_in": "expires_in_value", } USER_RESPONSE_DATA = { - 'lastName': 'lastName_value', - 'id': 'id_value', - 'firstName': 'firstName_value', - 'idp_name': 'testshib', - 'attributes': {'urn:oid:0.9.2342.19200300.100.1.1': ['myself'], 'name_id': '1'}, - 'session_index': '1', + "lastName": "lastName_value", + "id": "id_value", + "firstName": "firstName_value", + "idp_name": "testshib", + "attributes": {"urn:oid:0.9.2342.19200300.100.1.1": ["myself"], "name_id": "1"}, + "session_index": "1", } - @patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - @patch('openedx.core.djangoapps.user_api.accounts.settings_views.enterprise_customer_for_request') - @patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') + @patch("openedx.features.enterprise_support.api.enterprise_customer_for_request") + @patch("openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get") def test_full_pipeline_succeeds_for_unlinking_testshib_account( self, mock_auth_provider, - mock_enterprise_customer_for_request_settings_view, mock_enterprise_customer_for_request, ): @@ -252,10 +259,12 @@ def test_full_pipeline_succeeds_for_unlinking_testshib_account( # configure the backend, and mock out wire traffic. self.provider = self._configure_testshib_provider() request, strategy = self.get_request_and_strategy( - auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri='social:complete') + auth_entry=pipeline.AUTH_ENTRY_LOGIN, redirect_uri="social:complete" + ) request.backend.auth_complete = MagicMock(return_value=self.fake_auth_complete(strategy)) user = self.create_user_models_for_existing_account( - strategy, 'user@example.com', 'password', self.get_username()) + strategy, "user@example.com", "password", self.get_username() + ) self.assert_social_auth_exists_for_user(user, strategy) request.user = user @@ -267,70 +276,67 @@ def test_full_pipeline_succeeds_for_unlinking_testshib_account( enterprise_customer = EnterpriseCustomerFactory() assert EnterpriseCustomerUser.objects.count() == 0, "Precondition check: no link records should exist" EnterpriseCustomerUser.objects.link_user(enterprise_customer, user.email) - assert (EnterpriseCustomerUser.objects - .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 1) - EnterpriseCustomerIdentityProvider.objects.get_or_create(enterprise_customer=enterprise_customer, - provider_id=self.provider.provider_id) + assert ( + EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 1 + ) + EnterpriseCustomerIdentityProvider.objects.get_or_create( + enterprise_customer=enterprise_customer, provider_id=self.provider.provider_id + ) enterprise_customer_data = { - 'uuid': enterprise_customer.uuid, - 'name': enterprise_customer.name, - 'identity_provider': 'saml-default', - 'identity_providers': [ + "uuid": enterprise_customer.uuid, + "name": enterprise_customer.name, + "identity_provider": "saml-default", + "identity_providers": [ { "provider_id": "saml-default", } ], } - mock_auth_provider.return_value.backend_name = 'tpa-saml' + mock_auth_provider.return_value.backend_name = "tpa-saml" mock_enterprise_customer_for_request.return_value = enterprise_customer_data - mock_enterprise_customer_for_request_settings_view.return_value = enterprise_customer_data # Instrument the pipeline to get to the dashboard with the full expected state. - self.client.get( - pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) + self.client.get(pipeline.get_login_url(self.provider.provider_id, pipeline.AUTH_ENTRY_LOGIN)) - actions.do_complete(request.backend, social_views._do_login, # pylint: disable=protected-access - request=request) + actions.do_complete( + request.backend, social_views._do_login, request=request # pylint: disable=protected-access + ) with self._patch_edxmako_current_request(strategy.request): login_user(strategy.request) - actions.do_complete(request.backend, social_views._do_login, user=user, # pylint: disable=protected-access - request=request) + actions.do_complete( + request.backend, social_views._do_login, user=user, request=request # pylint: disable=protected-access + ) # First we expect that we're in the linked state, with a backend entry. - self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=True) self.assert_social_auth_exists_for_user(request.user, strategy) FEATURES_WITH_ENTERPRISE_ENABLED = settings.FEATURES.copy() - FEATURES_WITH_ENTERPRISE_ENABLED['ENABLE_ENTERPRISE_INTEGRATION'] = True + FEATURES_WITH_ENTERPRISE_ENABLED["ENABLE_ENTERPRISE_INTEGRATION"] = True with patch.dict("django.conf.settings.FEATURES", FEATURES_WITH_ENTERPRISE_ENABLED): # Fire off the disconnect pipeline without the user information. actions.do_disconnect( - request.backend, - None, - None, - redirect_field_name=auth.REDIRECT_FIELD_NAME, - request=request + request.backend, None, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request + ) + assert ( + EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() + != 0 ) - assert EnterpriseCustomerUser.objects\ - .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() != 0 # Fire off the disconnect pipeline to unlink. self.assert_redirect_after_pipeline_completes( actions.do_disconnect( - request.backend, - user, - None, - redirect_field_name=auth.REDIRECT_FIELD_NAME, - request=request + request.backend, user, None, redirect_field_name=auth.REDIRECT_FIELD_NAME, request=request ) ) # Now we expect to be in the unlinked state, with no backend entry. - self.assert_account_settings_context_looks_correct(account_settings_context(request), linked=False) + self.assert_third_party_accounts_state(request, linked=False) self.assert_social_auth_does_not_exist_for_user(user, strategy) - assert EnterpriseCustomerUser.objects\ - .filter(enterprise_customer=enterprise_customer, user_id=user.id).count() == 0 + assert ( + EnterpriseCustomerUser.objects.filter(enterprise_customer=enterprise_customer, user_id=user.id).count() + == 0 + ) def get_response_data(self): """Gets dict (string -> object) of merged data about the user.""" @@ -340,7 +346,7 @@ def get_response_data(self): def get_username(self): response_data = self.get_response_data() - return response_data.get('idp_name') + return response_data.get("idp_name") def test_login_before_metadata_fetched(self): self._configure_testshib_provider(fetch_metadata=False) @@ -350,18 +356,18 @@ def test_login_before_metadata_fetched(self): try_login_response = self.client.get(testshib_login_url) # The user should be redirected to back to the login page: assert try_login_response.status_code == 302 - assert try_login_response['Location'] == self.login_page_url + assert try_login_response["Location"] == self.login_page_url # When loading the login page, the user will see an error message: response = self.client.get(self.login_page_url) - self.assertContains(response, 'Authentication with TestShib is currently unavailable.') + self.assertContains(response, "Authentication with TestShib is currently unavailable.") def test_login(self): - """ Configure TestShib before running the login test """ + """Configure TestShib before running the login test""" self._configure_testshib_provider() self._test_login() def test_register(self): - """ Configure TestShib before running the register test """ + """Configure TestShib before running the register test""" self._configure_testshib_provider() self._test_register() @@ -374,17 +380,17 @@ def test_login_records_attributes(self): user=self.user, provider=self.PROVIDER_BACKEND, uid__startswith=self.PROVIDER_IDP_SLUG ) attributes = record.extra_data - assert attributes.get('urn:oid:1.3.6.1.4.1.5923.1.1.1.9') == ['Member@testshib.org', 'Staff@testshib.org'] - assert attributes.get('urn:oid:2.5.4.3') == ['Me Myself And I'] - assert attributes.get('urn:oid:0.9.2342.19200300.100.1.1') == ['myself'] - assert attributes.get('urn:oid:2.5.4.20') == ['555-5555'] + assert attributes.get("urn:oid:1.3.6.1.4.1.5923.1.1.1.9") == ["Member@testshib.org", "Staff@testshib.org"] + assert attributes.get("urn:oid:2.5.4.3") == ["Me Myself And I"] + assert attributes.get("urn:oid:0.9.2342.19200300.100.1.1") == ["myself"] + assert attributes.get("urn:oid:2.5.4.20") == ["555-5555"] # Phone number @ddt.data(True, False) def test_debug_mode_login(self, debug_mode_enabled): - """ Test SAML login logs with debug mode enabled or not """ + """Test SAML login logs with debug mode enabled or not""" self._configure_testshib_provider(debug_mode=debug_mode_enabled) - with patch.object(saml_log, 'info') as mock_log: + with patch.object(saml_log, "info") as mock_log: self._test_login() if debug_mode_enabled: # We expect that test_login() does two full logins, and each attempt generates two @@ -393,38 +399,37 @@ def test_debug_mode_login(self, debug_mode_enabled): expected_next_url = "/dashboard" (msg, action_type, idp_name, request_data, next_url, xml), _kwargs = mock_log.call_args_list[0] - assert msg.startswith('SAML login %s') - assert action_type == 'request' + assert msg.startswith("SAML login %s") + assert action_type == "request" assert idp_name == self.PROVIDER_IDP_SLUG self.assertDictContainsSubset( - {"idp": idp_name, "auth_entry": "login", "next": expected_next_url}, - request_data + {"idp": idp_name, "auth_entry": "login", "next": expected_next_url}, request_data ) assert next_url == expected_next_url - assert ' - + + @@ -81,8 +82,11 @@ href="https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/font-awesome.min.css"> - - + + + + + \n\n" +OPEN_EDX_FILTERS_CONFIG: + org.openedx.learning.course.homepage.url.creation.started.v1: + fail_silently: true + pipeline: + - federated_content_connector.filters.pipeline.CreateCustomUrlForCourseStep + org.openedx.learning.home.courserun.api.rendered.started.v1: + fail_silently: true + pipeline: + - federated_content_connector.filters.pipeline.CreateApiRenderCourseRunStep + org.openedx.learning.home.enrollment.api.rendered.v1: + fail_silently: true + pipeline: + - federated_content_connector.filters.pipeline.CreateApiRenderEnrollmentStep + org.openedx.learning.vertical_block.render.completed.v1: + fail_silently: true + pipeline: + - skill_tagging.pipeline.AddVerticalBlockSkillVerificationSection + org.openedx.learning.vertical_block_child.render.started.v1: + fail_silently: true + pipeline: + - skill_tagging.pipeline.AddVideoBlockSkillVerificationComponent + org.openedx.learning.xblock.render.started.v1: + fail_silently: true + pipeline: + - translatable_xblocks.filters.UpdateRequestLanguageCode +OPTIMIZELY_FULLSTACK_SDK_KEY: test_optimizely +OPTIMIZELY_PROJECT_ID: test_optimizely_project +ORA2_FILE_PREFIX: sandbox-edx/ora2 +ORA_GRADING_MICROFRONTEND_URL: https://ora-grading-deploy_host +ORA_MICROFRONTEND_URL: hello +ORA_WORKFLOW_UPDATE_ROUTING_KEY: hello +ORDER_HISTORY_MICROFRONTEND_URL: https://checkout.localhost/orders +OVERRIDE_GENERATE_OFFER_DATA: edx_ecommerce_extension.overrides.generate_offer_data +OVERRIDE_GET_ABSOLUTE_ECOMMERCE_URL: edx_ecommerce_extension.overrides.get_absolute_ecommerce_url +OVERRIDE_GET_CHECKOUT_PAGE_URL: edx_ecommerce_extension.overrides.get_checkout_page_url +OVERRIDE_REFUND_SEAT: edx_ecommerce_extension.overrides.refund_seat +PAID_COURSE_REGISTRATION_CURRENCY: +- usd +- $ +PARENTAL_CONSENT_AGE_LIMIT: 13 +PARTNER_SUPPORT_EMAIL: support@example.com +PASSWORD_POLICY_COMPLIANCE_ROLLOUT_CONFIG: + ELEVATED_PRIVILEGE_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' + ENFORCE_COMPLIANCE_ON_LOGIN: true + GENERAL_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' + STAFF_USER_COMPLIANCE_DEADLINE: '1970-01-01 00:00:00-00:00' +PASSWORD_RESET_SUPPORT_LINK: '' +PAYMENT_MICROFRONTEND_URL: https://payment-deploy_host +PAYMENT_SUPPORT_EMAIL: billing@example.com +PDF_RECEIPT_BILLING_ADDRESS: 'Enter your receipt billing + + address here. + + ' +PDF_RECEIPT_COBRAND_LOGO_PATH: '' +PDF_RECEIPT_DISCLAIMER_TEXT: 'ENTER YOUR RECEIPT DISCLAIMER TEXT HERE. + + ' +PDF_RECEIPT_FOOTER_TEXT: 'Enter your receipt footer text here. + + ' +PDF_RECEIPT_LOGO_PATH: '' +PDF_RECEIPT_TAX_ID: 00-0000000 +PDF_RECEIPT_TAX_ID_LABEL: fake Tax ID +PDF_RECEIPT_TERMS_AND_CONDITIONS: 'Enter your receipt terms and conditions here. + + ' +PLATFORM_DESCRIPTION: Your Platform Description Here +PLATFORM_FACEBOOK_ACCOUNT: http://www.facebook.com/YourPlatformFacebookAccount +PLATFORM_NAME: Your Platform Name Here +PLATFORM_TWITTER_ACCOUNT: '@YourPlatformTwitterAccount' +POLICY_CHANGE_GRADES_ROUTING_KEY: edx.lms.core.default +PRESS_EMAIL: press@example.com +PROCTORING_BACKENDS: + DEFAULT: 'null' + 'null': {} + proctortrack: + base_url: hello + client_id: '[encrypted]' + client_secret: '[encrypted]' + help_center_article_url: hello + integration_specific_email: hello +PROCTORING_SETTINGS: + ALLOW_CALLBACK_SIMULATION: true + LINK_URLS: + contact_us: hello + course_authoring_faq: hello + faq: hello + online_proctoring_rules: hello + tech_requirements: hello + SOFTWARE_SECURE_CLIENT_TIMEOUT: 5 + USE_ONBOARDING_PROFILE_API: true +PROCTORING_USER_OBFUSCATION_KEY: test_proctoring_user_obfuscation_key +PROFILE_IMAGE_BACKEND: + class: storages.backends.s3boto3.S3Boto3Storage + options: + bucket_name: profile-images + custom_domain: 12345.cloudfront.net + default_acl: public-read + location: LINTING + object_parameters: + CacheControl: max-age-31536000 +PROFILE_IMAGE_MAX_BYTES: 1048576 +PROFILE_IMAGE_MIN_BYTES: 100 +PROFILE_IMAGE_SECRET_KEY: secret +PROFILE_IMAGE_SIZES_MAP: + full: 500 + large: 120 + medium: 50 + small: 30 +PROFILE_MICROFRONTEND_URL: null +PROGRAM_CERTIFICATES_ROUTING_KEY: edx.lms.core.default +PROGRAM_CONSOLE_MICROFRONTEND_URL: https://program-console-deploy_host +RECALCULATE_GRADES_ROUTING_KEY: edx.lms.core.default +REGISTRATION_EXTRA_FIELDS: + city: hidden + confirm_email: hidden + country: required + gender: optional + goals: optional + honor_code: required + level_of_education: optional + mailing_address: hidden + terms_of_service: hidden + year_of_birth: optional +REGISTRATION_RATELIMIT: hello +REGISTRATION_VALIDATION_RATELIMIT: hello +REST_FRAMEWORK: + NUM_PROXIES: 1 +RETIRED_EMAIL_DOMAIN: retired.invalid +RETIRED_EMAIL_PREFIX: retired__user_ +RETIRED_USERNAME_PREFIX: retired__user_ +RETIRED_USER_SALTS: +- OVERRIDE ME WITH A RANDOM VALUE +- ROTATE SALTS BY APPENDING NEW VALUES +RETIREMENT_SERVICE_WORKER_USERNAME: OVERRIDE THIS WITH A VALID LMS USERNAME +RETIREMENT_STATES: +- PENDING +- RETIRING_FORUMS +- FORUMS_COMPLETE +- RETIRING_EMAIL_LISTS +- EMAIL_LISTS_COMPLETE +- RETIRING_ENROLLMENTS +- ENROLLMENTS_COMPLETE +- RETIRING_NOTES +- NOTES_COMPLETE +- RETIRING_LMS_MISC +- LMS_MISC_COMPLETE +- RETIRING_LMS +- LMS_COMPLETE +- ADDING_TO_PARTNER_QUEUE +- PARTNER_QUEUE_COMPLETE +- ERRORED +- ABORTED +- COMPLETE +SAFE_SESSIONS_DEBUG_PUBLIC_KEY: hello +SEARCH_COURSEWARE_CONTENT_LOG_PARAMS: true +SECRET_KEY: test_secret_key +SECURITY_PAGE_URL: hello +SEGMENT_KEY: null +SEND_CERTIFICATE_CREATED_SIGNAL: true +SEND_CERTIFICATE_REVOKED_SIGNAL: true +SERVER_EMAIL: devops@example.com +SESSION_COOKIE_DOMAIN: .sandbox.localhost +SESSION_COOKIE_NAME: sessionid +SESSION_COOKIE_SECURE: true +SESSION_SAVE_EVERY_REQUEST: true +SGA_STORAGE_SETTINGS: + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + default_acl: private +SHARED_COOKIE_DOMAIN: hello +SHOW_ACCOUNT_ACTIVATION_CTA: true +SHOW_SKILL_VERIFICATION_PROBABILITY: 9.7 +SINGLE_LEARNER_COURSE_REGRADE_ROUTING_KEY: hello +SITE_NAME: deploy_host +SKILLS_MICROFRONTEND_URL: hello +SNOWFLAKE_SERVICE_USER: ENTERPRISE_SERVICE_USER +SNOWFLAKE_SERVICE_USER_PASSWORD: null +SOCIAL_AUTH_OAUTH_SECRETS: '' +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY: '' +SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT: + one: '[encrypted]' + another: '[encrypted]' +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT: '' +SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT: + one: hello + another: hello +SOCIAL_MEDIA_FOOTER_URLS: + facebook: hello + instagram: hello + linkedin: hello + meetup: hello + reddit: hello + tumblr: hello + twitter: hello + youtube: hello +SOCIAL_SHARING_SETTINGS: + CERTIFICATE_FACEBOOK: true + CERTIFICATE_FACEBOOK_TEXT: hello + CERTIFICATE_TWITTER: true + CERTIFICATE_TWITTER_TEXT: hello + CUSTOM_COURSE_URLS: true + DASHBOARD_FACEBOOK: true + DASHBOARD_TWITTER: true +SOFTWARE_SECURE_RETRY_MAX_ATTEMPTS: 5 +SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY: hello +STATICFILES_STORAGE_KWARGS: + openedx.core.storage.ProductionS3Storage: + bucket_name: hello + default_acl: hello +STATIC_ROOT_BASE: /tmp/static +STATIC_URL_BASE: /static +STUDIO_NAME: Studio +STUDIO_SHORT_NAME: Studio +SUMMARY_HOOK_HOST: hello +SUMMARY_HOOK_JS_PATH: hello +SUMMARY_HOOK_MIN_SIZE: 5 +SUPPORT_HOW_TO_UNENROLL_LINK: hello +SUPPORT_SITE_LINK: '' +SURVEYMONKEY_ACCESS_TOKEN: '[encrypted]' +SURVEY_REPORT_ENABLE: true +SURVEY_REPORT_ENDPOINT: http://localhost:0 +SWIFT_AUTH_URL: null +SWIFT_AUTH_VERSION: null +SWIFT_KEY: null +SWIFT_REGION_NAME: null +SWIFT_TEMP_URL_DURATION: 1800 +SWIFT_TEMP_URL_KEY: null +SWIFT_TENANT_ID: null +SWIFT_TENANT_NAME: null +SWIFT_USERNAME: null +SWIFT_USE_TEMP_URLS: true +SYSLOG_SERVER: '' +SYSTEM_WIDE_ROLE_CLASSES: [] +TAXONOMY_API_BASE_URL: hello +TAXONOMY_API_SKILL_PAGE_SIZE: 5 +TECH_SUPPORT_EMAIL: technical@example.com +TIME_ZONE: America/New_York +TOKEN_SIGNING: + JWT_ISSUER: https://edx-exams.localhost + JWT_PUBLIC_SIGNING_JWK_SET: "{\n \"keys\": [\n {\n \"kty\": \"RSA\",\n\ + \ \"kid\": \"exam_token_key\",\n \"e\": \"AQAB\",\n \"n\"\ + : \"test_jwt\"\n }\n ]\n}\n" +TRACKING_SEGMENTIO_WEBHOOK_SECRET: '' +UNIVERSITY_EMAIL: university@example.com +UNUSUAL_COOKIE_HEADER_PUBLIC_KEY: hello +USERNAME_REPLACEMENT_WORKER: OVERRIDE THIS WITH A VALID USERNAME +VEDA_FERNET_KEYS: +- '[encrypted]' +VERIFY_STUDENT: + DAYS_GOOD_FOR: 730 + EXPIRING_SOON_WINDOW: 28 + PERSONA: + API_KEY: '[encrypted]' + API_ROOT: hello + INQUIRY_TEMPLATE_ID: hello + WEBHOOK_SECRET: '[encrypted]' + SOFTWARE_SECURE: + API_ACCESS_KEY: '[encrypted]' + API_SECRET_KEY: '[encrypted]' + API_URL: hello + AWS_ACCESS_KEY: '[encrypted]' + AWS_SECRET_KEY: '[encrypted]' + CERT_VERIFICATION_PATH: hello + FACE_IMAGE_AES_KEY: '[encrypted]' + RSA_PUBLIC_KEY: '[encrypted]' + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: hello + custom_domain: null + default_acl: hello + querystring_auth: true + querystring_expire: 5 + USE_DJANGO_MAIL: true +VIDEO_CDN_URL: + CN: hello + EXAMPLE_COUNTRY_CODE: http://example.com/edx/video?s3_url= + default: hello +VIDEO_IMAGE_MAX_AGE: 31536000 +VIDEO_IMAGE_SETTINGS: + DIRECTORY_PREFIX: video-images/ + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: video-images + custom_domain: 121212.cloudfront.net + default_acl: public-read + location: LINTING + object_parameters: + CacheControl: max-age-31536000 + VIDEO_IMAGE_MAX_BYTES: 2097152 + VIDEO_IMAGE_MIN_BYTES: 2048 +VIDEO_TRANSCRIPTS_MAX_AGE: 31536000 +VIDEO_TRANSCRIPTS_SETTINGS: + DIRECTORY_PREFIX: video-transcripts/ + STORAGE_CLASS: storages.backends.s3boto3.S3Boto3Storage + STORAGE_KWARGS: + bucket_name: video-transcripts + custom_domain: 123123.cloudfront.net + default_acl: public-read + location: LINTING + object_parameters: + CacheControl: max-age-31536000 + VIDEO_TRANSCRIPTS_MAX_BYTES: 3145728 +VIDEO_UPLOAD_PIPELINE: + BUCKET: uploads + CONCURRENT_UPLOAD_LIMIT: 4 + ROOT_PATH: video-upload-pipeline/unprocessed +WIKI_ENABLED: true +WRITABLE_GRADEBOOK_URL: null +XBLOCK_EXTRA_MIXINS: +- hello +XBLOCK_FS_STORAGE_BUCKET: storage +XBLOCK_FS_STORAGE_PREFIX: sandbox-edx/ +XBLOCK_HANDLER_TOKEN_KEYS: +- '[encrypted]' +XBLOCK_SETTINGS: + AcclaimBadgeXBlock: + ORG: + api_key: '[encrypted]' + id: hello + ScormXBlock: + S3_BUCKET_NAME: scorm + STORAGE_FUNC: openedxscorm.storage.s3 +XQUEUE_INTERFACE: + basic_auth: + - user + - pass + django_auth: + password: pass + username: user + url: http://localhost:18040 +X_FRAME_OPTIONS: DENY +YOUTUBE_API_KEY: test_youtube_api_key +ZENDESK_API_KEY: '' +ZENDESK_CUSTOM_FIELDS: + course_id: 5 + enrollment_mode: 5 + enterprise_customer_name: 5 + referrer: 5 +ZENDESK_GROUP_ID_MAPPING: + Financial Assistance: '9999999999' +ZENDESK_OAUTH_ACCESS_TOKEN: test_zendesk_oauth +ZENDESK_URL: https://12345.zendesk.com +ZENDESK_USER: daemon@example.com + diff --git a/lms/envs/production.py b/lms/envs/production.py index addcea0e602..4887804e7e0 100644 --- a/lms/envs/production.py +++ b/lms/envs/production.py @@ -17,19 +17,17 @@ import codecs -import copy import datetime import os import yaml -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 -from openedx.core.lib.derived import derive_settings +from openedx.core.lib.derived import Derived, derive_settings from openedx.core.lib.logsettings import get_logger_config from xmodule.modulestore.modulestore_settings import convert_module_store_setting_if_needed # lint-amnesty, pylint: disable=wrong-import-order @@ -44,7 +42,11 @@ def get_env_setting(setting): error_msg = "Set the %s env variable" % setting raise ImproperlyConfigured(error_msg) # lint-amnesty, pylint: disable=raise-missing-from -################################ ALWAYS THE SAME ############################## + +################################################# PRODUCTION DEFAULTS ################################################ +# We configure some defaults (beyond what has already been configured in common.py) before loading the YAML file below. +# DO NOT ADD NEW DEFAULTS HERE! Put any new setting defaults in common.py instead, along with a setting annotation. +# TODO: Move all these defaults into common.py. DEBUG = False DEFAULT_TEMPLATE_ENGINE['OPTIONS']['debug'] = False @@ -58,40 +60,178 @@ def get_env_setting(setting): # https://docs.djangoproject.com/en/dev/ref/settings/#secure-proxy-ssl-header # for other warnings. SECURE_PROXY_SSL_HEADER = ('HTTP_X_FORWARDED_PROTO', 'https') -################################ END ALWAYS THE SAME ############################## -# A file path to a YAML file from which to load all the configuration for the edx platform +CELERY_RESULT_BACKEND = 'django-cache' +BROKER_HEARTBEAT = 60.0 +BROKER_HEARTBEAT_CHECKRATE = 2 +STATIC_ROOT_BASE = None +STATIC_URL_BASE = None +EMAIL_HOST = 'localhost' +EMAIL_PORT = 25 +EMAIL_USE_TLS = False +SESSION_COOKIE_DOMAIN = None +SESSION_COOKIE_HTTPONLY = True +AWS_SES_REGION_NAME = 'us-east-1' +AWS_SES_REGION_ENDPOINT = 'email.us-east-1.amazonaws.com' +REGISTRATION_EMAIL_PATTERNS_ALLOWED = None +LMS_ROOT_URL = None +CMS_BASE = 'studio.edx.org' +CELERY_EVENT_QUEUE_TTL = None +COMPREHENSIVE_THEME_LOCALE_PATHS = [] +PREPEND_LOCALE_PATHS = [] +COURSE_LISTINGS = {} +COMMENTS_SERVICE_URL = '' +COMMENTS_SERVICE_KEY = '' +CERT_QUEUE = 'test-pull' +PYTHON_LIB_FILENAME = 'python_lib.zip' +VIDEO_CDN_URL = {} +HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = {} +AWS_STORAGE_BUCKET_NAME = 'edxuploads' +# Disabling querystring auth instructs Boto to exclude the querystring parameters (e.g. signature, access key) it +# normally appends to every returned URL. +AWS_QUERYSTRING_AUTH = True +AWS_S3_CUSTOM_DOMAIN = 'edxuploads.s3.amazonaws.com' +MONGODB_LOG = {} +ZENDESK_USER = None +ZENDESK_API_KEY = None +EDX_API_KEY = None +CELERY_BROKER_TRANSPORT = "" +CELERY_BROKER_HOSTNAME = "" +CELERY_BROKER_VHOST = "" +CELERY_BROKER_USER = "" +CELERY_BROKER_PASSWORD = "" +BROKER_USE_SSL = False +SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = None +ENABLE_REQUIRE_THIRD_PARTY_AUTH = False +GOOGLE_ANALYTICS_TRACKING_ID = None +GOOGLE_ANALYTICS_LINKEDIN = None +GOOGLE_SITE_VERIFICATION_ID = None +BRANCH_IO_KEY = None +REGISTRATION_CODE_LENGTH = 8 +FACEBOOK_API_VERSION = None +FACEBOOK_APP_SECRET = None +FACEBOOK_APP_ID = None +API_ACCESS_MANAGER_EMAIL = None +API_ACCESS_FROM_EMAIL = None +CHAT_COMPLETION_API = '' +CHAT_COMPLETION_API_KEY = '' +OPENAPI_CACHE_TIMEOUT = 60 * 60 +MAINTENANCE_BANNER_TEXT = None +DASHBOARD_COURSE_LIMIT = None + +# TODO: We believe these were part of the DEPR'd sysadmin dashboard, and can likely be removed. +SSL_AUTH_EMAIL_DOMAIN = "MIT.EDU" +SSL_AUTH_DN_FORMAT_STRING = ( + "/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}" +) + +CC_MERCHANT_NAME = Derived(lambda settings: settings.PLATFORM_NAME) +EMAIL_FILE_PATH = Derived(lambda settings: settings.DATA_DIR / "emails" / "lms") +LMS_INTERNAL_ROOT_URL = Derived(lambda settings: settings.LMS_ROOT_URL) + +# This is the domain that is used to set shared cookies between various sub-domains. +# By default, it's set to the same thing as the SESSION_COOKIE_DOMAIN +SHARED_COOKIE_DOMAIN = Derived(lambda settings: settings.SESSION_COOKIE_DOMAIN) + +# We want Bulk Email running on the high-priority queue, so we define the +# routing key that points to it. At the moment, the name is the same. +# We have to reset the value here, since we have changed the value of the queue name. +BULK_EMAIL_ROUTING_KEY = Derived(lambda settings: settings.HIGH_PRIORITY_QUEUE) + +# We can run smaller jobs on the low priority queue. See note above for why +# we have to reset the value here. +BULK_EMAIL_ROUTING_KEY_SMALL_JOBS = Derived(lambda settings: settings.DEFAULT_PRIORITY_QUEUE) + +# Queue to use for expiring old entitlements +ENTITLEMENTS_EXPIRATION_ROUTING_KEY = Derived(lambda settings: settings.DEFAULT_PRIORITY_QUEUE) + +# Intentional defaults. +ID_VERIFICATION_SUPPORT_LINK = Derived(lambda settings: settings.SUPPORT_SITE_LINK) +PASSWORD_RESET_SUPPORT_LINK = Derived(lambda settings: settings.SUPPORT_SITE_LINK) +ACTIVATION_EMAIL_SUPPORT_LINK = Derived(lambda settings: settings.SUPPORT_SITE_LINK) +LOGIN_ISSUE_SUPPORT_LINK = Derived(lambda settings: settings.SUPPORT_SITE_LINK) + +# Default queues for various routes +GRADES_DOWNLOAD_ROUTING_KEY = Derived(lambda settings: settings.HIGH_MEM_QUEUE) +CREDENTIALS_GENERATION_ROUTING_KEY = Derived(lambda settings: settings.DEFAULT_PRIORITY_QUEUE) +PROGRAM_CERTIFICATES_ROUTING_KEY = Derived(lambda settings: settings.DEFAULT_PRIORITY_QUEUE) +SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = Derived(lambda settings: settings.HIGH_PRIORITY_QUEUE) + +############## OPEN EDX ENTERPRISE SERVICE CONFIGURATION ###################### +# The Open edX Enterprise service is currently hosted via the LMS container/process. +# However, for all intents and purposes this service is treated as a standalone IDA. +# These configuration settings are specific to the Enterprise service and you should +# not find references to them within the edx-platform project. + +# Publicly-accessible enrollment URL, for use on the client side. +ENTERPRISE_PUBLIC_ENROLLMENT_API_URL = Derived( + lambda settings: (settings.LMS_ROOT_URL or '') + settings.LMS_ENROLLMENT_API_PATH +) + +# Enrollment URL used on the server-side. +ENTERPRISE_ENROLLMENT_API_URL = Derived( + lambda settings: (settings.LMS_INTERNAL_ROOT_URL or '') + settings.LMS_ENROLLMENT_API_PATH +) + +############## ENTERPRISE SERVICE API CLIENT CONFIGURATION ###################### +# The LMS communicates with the Enterprise service via the requests.Session() client +# The below environmental settings are utilized by the LMS when interacting with +# the service, and override the default parameters which are defined in common.py + + +DEFAULT_ENTERPRISE_API_URL = Derived( + lambda settings: ( + None if settings.LMS_INTERNAL_ROOT_URL is None + else settings.LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/' + ) +) +ENTERPRISE_API_URL = DEFAULT_ENTERPRISE_API_URL + +DEFAULT_ENTERPRISE_CONSENT_API_URL = Derived( + lambda settings: ( + None if settings.LMS_INTERNAL_ROOT_URL is None + else settings.LMS_INTERNAL_ROOT_URL + '/consent/api/v1/' + ) +) +ENTERPRISE_CONSENT_API_URL = DEFAULT_ENTERPRISE_CONSENT_API_URL + + +####################################################################################################################### + +# A file path to a YAML file from which to load configuration overrides for LMS. CONFIG_FILE = get_env_setting('LMS_CFG') with codecs.open(CONFIG_FILE, encoding='utf-8') as f: - __config__ = yaml.safe_load(f) - - # ENV_TOKENS and AUTH_TOKENS are included for reverse compatibility. - # Removing them may break plugins that rely on them. - ENV_TOKENS = __config__ - AUTH_TOKENS = __config__ - - # Add the key/values from config into the global namespace of this module. - # But don't override the FEATURES dict because we do that in an additive way. - __config_copy__ = copy.deepcopy(__config__) - - KEYS_WITH_MERGED_VALUES = [ - 'FEATURES', - 'TRACKING_BACKENDS', - 'EVENT_TRACKING_BACKENDS', - 'JWT_AUTH', - 'CELERY_QUEUES', - '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__: - del __config_copy__[key] - - vars().update(__config_copy__) + # _YAML_TOKENS starts out with the exact contents of the LMS_CFG YAML file. + # Please avoid adding new references to _YAML_TOKENS. Such references make our settings logic more complex. + # Instead, just reference the Django settings, which we define in the next step... + _YAML_TOKENS = yaml.safe_load(f) + + # Update the global namespace of this module with the key-value pairs from _YAML_TOKENS. + # In other words: For (almost) every YAML key-value pair, define/update a Django setting with that name and value. + vars().update({ + + # Note: If `value` is a mutable object (e.g., a dict), then it will be aliased between the global namespace and + # _YAML_TOKENS. In other words, updates to `value` will manifest in _YAML_TOKENS as well. This is intentional, + # in order to maintain backwards compatibility with old Django plugins which use _YAML_TOKENS. + key: value + for key, value in _YAML_TOKENS.items() + + # Do NOT define/update Django settings for these particular special keys. + # We handle each of these with its special logic (below, in this same module). + # For example, we need to *update* the default FEATURES dict rather than wholesale *override* it. + if key not in [ + 'FEATURES', + 'TRACKING_BACKENDS', + 'EVENT_TRACKING_BACKENDS', + 'JWT_AUTH', + 'CELERY_QUEUES', + 'MKTG_URL_LINK_MAP', + 'REST_FRAMEWORK', + 'EVENT_BUS_PRODUCER_CONFIG', + ] + }) try: # A file path to a YAML file from which to load all the code revisions currently deployed @@ -111,20 +251,11 @@ def get_env_setting(setting): BROKER_POOL_LIMIT = 0 BROKER_CONNECTION_TIMEOUT = 1 -# Allow env to configure celery result backend with default set to django-cache -CELERY_RESULT_BACKEND = ENV_TOKENS.get('CELERY_RESULT_BACKEND', 'django-cache') - -# When the broker is behind an ELB, use a heartbeat to refresh the -# connection and to detect if it has been dropped. -BROKER_HEARTBEAT = ENV_TOKENS.get('BROKER_HEARTBEAT', 60.0) -BROKER_HEARTBEAT_CHECKRATE = ENV_TOKENS.get('BROKER_HEARTBEAT_CHECKRATE', 2) - # Each worker should only fetch one message at a time CELERYD_PREFETCH_MULTIPLIER = 1 # STATIC_ROOT specifies the directory where static files are # collected -STATIC_ROOT_BASE = ENV_TOKENS.get('STATIC_ROOT_BASE', None) if STATIC_ROOT_BASE: STATIC_ROOT = path(STATIC_ROOT_BASE) WEBPACK_LOADER['DEFAULT']['STATS_FILE'] = STATIC_ROOT / "webpack-stats.json" @@ -132,83 +263,26 @@ def get_env_setting(setting): # STATIC_URL_BASE specifies the base url to use for static files -STATIC_URL_BASE = ENV_TOKENS.get('STATIC_URL_BASE', None) if STATIC_URL_BASE: STATIC_URL = STATIC_URL_BASE if not STATIC_URL.endswith("/"): STATIC_URL += "/" -# Allow overriding build profile used by RequireJS with one -# contained on a custom theme -REQUIRE_BUILD_PROFILE = ENV_TOKENS.get('REQUIRE_BUILD_PROFILE', REQUIRE_BUILD_PROFILE) - -# The following variables use (or) instead of the default value inside (get). This is to enforce using the Lazy Text -# values when the variable is an empty string. Therefore, setting these variable as empty text in related -# json files will make the system reads their values from django translation files -PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME') or PLATFORM_NAME -PLATFORM_DESCRIPTION = ENV_TOKENS.get('PLATFORM_DESCRIPTION') or PLATFORM_DESCRIPTION - -DATA_DIR = path(ENV_TOKENS.get('DATA_DIR', DATA_DIR)) -CC_MERCHANT_NAME = ENV_TOKENS.get('CC_MERCHANT_NAME', PLATFORM_NAME) -EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', DATA_DIR / "emails" / "lms") -EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost -EMAIL_PORT = ENV_TOKENS.get('EMAIL_PORT', 25) # django default is 25 -EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is False -SITE_NAME = ENV_TOKENS.get('SITE_NAME', SITE_NAME) -SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') -SESSION_COOKIE_HTTPONLY = ENV_TOKENS.get('SESSION_COOKIE_HTTPONLY', True) - -DCS_SESSION_COOKIE_SAMESITE = ENV_TOKENS.get('DCS_SESSION_COOKIE_SAMESITE', DCS_SESSION_COOKIE_SAMESITE) -DCS_SESSION_COOKIE_SAMESITE_FORCE_ALL = ENV_TOKENS.get('DCS_SESSION_COOKIE_SAMESITE_FORCE_ALL', DCS_SESSION_COOKIE_SAMESITE_FORCE_ALL) # lint-amnesty, pylint: disable=line-too-long - -# As django-cookies-samesite package is set to be removed from base requirements when we upgrade to Django 3.2, -# we should follow the settings name provided by Django. -# https://docs.djangoproject.com/en/3.2/ref/settings/#session-cookie-samesite -SESSION_COOKIE_SAMESITE = DCS_SESSION_COOKIE_SAMESITE - -AWS_SES_REGION_NAME = ENV_TOKENS.get('AWS_SES_REGION_NAME', 'us-east-1') -AWS_SES_REGION_ENDPOINT = ENV_TOKENS.get('AWS_SES_REGION_ENDPOINT', 'email.us-east-1.amazonaws.com') +DATA_DIR = path(DATA_DIR) -REGISTRATION_EMAIL_PATTERNS_ALLOWED = ENV_TOKENS.get('REGISTRATION_EMAIL_PATTERNS_ALLOWED') - -LMS_ROOT_URL = ENV_TOKENS.get('LMS_ROOT_URL') -LMS_INTERNAL_ROOT_URL = ENV_TOKENS.get('LMS_INTERNAL_ROOT_URL', LMS_ROOT_URL) - -# List of logout URIs for each IDA that the learner should be logged out of when they logout of the LMS. Only applies to -# IDA for which the social auth flow uses DOT (Django OAuth Toolkit). -IDA_LOGOUT_URI_LIST = ENV_TOKENS.get('IDA_LOGOUT_URI_LIST', []) +# TODO: This was for backwards compatibility back when installed django-cookie-samesite (not since 2022). +# The DCS_ version of the setting can be DEPR'd at this point. +SESSION_COOKIE_SAMESITE = DCS_SESSION_COOKIE_SAMESITE -ENV_FEATURES = ENV_TOKENS.get('FEATURES', {}) -for feature, value in ENV_FEATURES.items(): +for feature, value in _YAML_TOKENS.get('FEATURES', {}).items(): FEATURES[feature] = value -CMS_BASE = ENV_TOKENS.get('CMS_BASE', 'studio.edx.org') - ALLOWED_HOSTS = [ "*", - ENV_TOKENS.get('LMS_BASE'), + _YAML_TOKENS.get('LMS_BASE'), FEATURES['PREVIEW_LMS_BASE'], ] -# Sometimes, OAuth2 clients want the user to redirect back to their site after logout. But to determine if the given -# redirect URL/path is safe for redirection, the following variable is used by edX. -LOGIN_REDIRECT_WHITELIST = ENV_TOKENS.get( - 'LOGIN_REDIRECT_WHITELIST', - LOGIN_REDIRECT_WHITELIST -) - -# allow for environments to specify what cookie name our login subsystem should use -# this is to fix a bug regarding simultaneous logins between edx.org and edge.edx.org which can -# happen with some browsers (e.g. Firefox) -if ENV_TOKENS.get('SESSION_COOKIE_NAME', None): - # NOTE, there's a bug in Django (http://bugs.python.org/issue18012) which necessitates this being a str() - SESSION_COOKIE_NAME = str(ENV_TOKENS.get('SESSION_COOKIE_NAME')) - -# This is the domain that is used to set shared cookies between various sub-domains. -# By default, it's set to the same thing as the SESSION_COOKIE_DOMAIN, but we want to make it overrideable. -SHARED_COOKIE_DOMAIN = ENV_TOKENS.get('SHARED_COOKIE_DOMAIN', SESSION_COOKIE_DOMAIN) - -CACHES = ENV_TOKENS.get('CACHES', CACHES) # Cache used for location mapping -- called many times with the same key/value # in a given request. if 'loc_cache' not in CACHES: @@ -224,206 +298,59 @@ def get_env_setting(setting): # we need to run asset collection twice, once for local disk and once for S3. # Once we have migrated to service assets off S3, then we can convert this back to # managed by the yaml file contents -STATICFILES_STORAGE = os.environ.get('STATICFILES_STORAGE', ENV_TOKENS.get('STATICFILES_STORAGE', STATICFILES_STORAGE)) -# Load all AWS_ prefixed variables to allow an S3Boto3Storage to be configured -_locals = locals() -for key, value in ENV_TOKENS.items(): - if key.startswith('AWS_'): - _locals[key] = value -# Currency -PAID_COURSE_REGISTRATION_CURRENCY = ENV_TOKENS.get('PAID_COURSE_REGISTRATION_CURRENCY', - PAID_COURSE_REGISTRATION_CURRENCY) - -# We want Bulk Email running on the high-priority queue, so we define the -# routing key that points to it. At the moment, the name is the same. -# We have to reset the value here, since we have changed the value of the queue name. -BULK_EMAIL_ROUTING_KEY = ENV_TOKENS.get('BULK_EMAIL_ROUTING_KEY', HIGH_PRIORITY_QUEUE) - -# We can run smaller jobs on the low priority queue. See note above for why -# we have to reset the value here. -BULK_EMAIL_ROUTING_KEY_SMALL_JOBS = ENV_TOKENS.get('BULK_EMAIL_ROUTING_KEY_SMALL_JOBS', DEFAULT_PRIORITY_QUEUE) - -# Queue to use for expiring old entitlements -ENTITLEMENTS_EXPIRATION_ROUTING_KEY = ENV_TOKENS.get('ENTITLEMENTS_EXPIRATION_ROUTING_KEY', DEFAULT_PRIORITY_QUEUE) - -# Message expiry time in seconds -CELERY_EVENT_QUEUE_TTL = ENV_TOKENS.get('CELERY_EVENT_QUEUE_TTL', None) - -# Allow CELERY_QUEUES to be overwritten by ENV_TOKENS, -ENV_CELERY_QUEUES = ENV_TOKENS.get('CELERY_QUEUES', None) -if ENV_CELERY_QUEUES: - CELERY_QUEUES = {queue: {} for queue in ENV_CELERY_QUEUES} +# Build a CELERY_QUEUES dict the way that celery expects, based on a couple lists of queue names from the YAML. +_YAML_CELERY_QUEUES = _YAML_TOKENS.get('CELERY_QUEUES', None) +if _YAML_CELERY_QUEUES: + CELERY_QUEUES = {queue: {} for queue in _YAML_CELERY_QUEUES} # Then add alternate environment queues -ALTERNATE_QUEUE_ENVS = ENV_TOKENS.get('ALTERNATE_WORKER_QUEUES', '').split() +_YAML_ALTERNATE_WORKER_QUEUES = _YAML_TOKENS.get('ALTERNATE_WORKER_QUEUES', '').split() ALTERNATE_QUEUES = [ DEFAULT_PRIORITY_QUEUE.replace(QUEUE_VARIANT, alternate + '.') - for alternate in ALTERNATE_QUEUE_ENVS + for alternate in _YAML_ALTERNATE_WORKER_QUEUES ] + CELERY_QUEUES.update( { alternate: {} for alternate in ALTERNATE_QUEUES - if alternate not in list(CELERY_QUEUES.keys()) + if alternate not in CELERY_QUEUES.keys() } ) -# following setting is for backward compatibility -if ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR', None): - COMPREHENSIVE_THEME_DIR = ENV_TOKENS.get('COMPREHENSIVE_THEME_DIR') - - -# COMPREHENSIVE_THEME_LOCALE_PATHS contain the paths to themes locale directories e.g. -# "COMPREHENSIVE_THEME_LOCALE_PATHS" : [ -# "/edx/src/edx-themes/conf/locale" -# ], -COMPREHENSIVE_THEME_LOCALE_PATHS = ENV_TOKENS.get('COMPREHENSIVE_THEME_LOCALE_PATHS', []) - - -# PREPEND_LOCALE_PATHS contain the paths to locale directories to load first e.g. -# "PREPEND_LOCALE_PATHS" : [ -# "/edx/my-locale" -# ], -PREPEND_LOCALE_PATHS = ENV_TOKENS.get('PREPEND_LOCALE_PATHS', []) - - -MKTG_URL_LINK_MAP.update(ENV_TOKENS.get('MKTG_URL_LINK_MAP', {})) -ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = ENV_TOKENS.get( - 'ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS', - ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS -) -# Marketing link overrides -MKTG_URL_OVERRIDES.update(ENV_TOKENS.get('MKTG_URL_OVERRIDES', MKTG_URL_OVERRIDES)) - -# Intentional defaults. -ID_VERIFICATION_SUPPORT_LINK = ENV_TOKENS.get('ID_VERIFICATION_SUPPORT_LINK', SUPPORT_SITE_LINK) -PASSWORD_RESET_SUPPORT_LINK = ENV_TOKENS.get('PASSWORD_RESET_SUPPORT_LINK', SUPPORT_SITE_LINK) -ACTIVATION_EMAIL_SUPPORT_LINK = ENV_TOKENS.get('ACTIVATION_EMAIL_SUPPORT_LINK', SUPPORT_SITE_LINK) -LOGIN_ISSUE_SUPPORT_LINK = ENV_TOKENS.get('LOGIN_ISSUE_SUPPORT_LINK', SUPPORT_SITE_LINK) +MKTG_URL_LINK_MAP.update(_YAML_TOKENS.get('MKTG_URL_LINK_MAP', {})) # Timezone overrides -TIME_ZONE = ENV_TOKENS.get('CELERY_TIMEZONE', CELERY_TIMEZONE) +TIME_ZONE = CELERY_TIMEZONE # Translation overrides LANGUAGE_DICT = dict(LANGUAGES) -LANGUAGE_COOKIE_NAME = ENV_TOKENS.get('LANGUAGE_COOKIE', None) or ENV_TOKENS.get( - 'LANGUAGE_COOKIE_NAME', LANGUAGE_COOKIE_NAME) +LANGUAGE_COOKIE_NAME = _YAML_TOKENS.get('LANGUAGE_COOKIE') or LANGUAGE_COOKIE_NAME # Additional installed apps -for app in ENV_TOKENS.get('ADDL_INSTALLED_APPS', []): +for app in _YAML_TOKENS.get('ADDL_INSTALLED_APPS', []): INSTALLED_APPS.append(app) - -local_loglevel = ENV_TOKENS.get('LOCAL_LOGLEVEL', 'INFO') -LOG_DIR = ENV_TOKENS.get('LOG_DIR', LOG_DIR) - -LOGGING = get_logger_config(LOG_DIR, - logging_env=ENV_TOKENS.get('LOGGING_ENV', LOGGING_ENV), - local_loglevel=local_loglevel, - service_variant=SERVICE_VARIANT) - -COURSE_LISTINGS = ENV_TOKENS.get('COURSE_LISTINGS', {}) -COMMENTS_SERVICE_URL = ENV_TOKENS.get("COMMENTS_SERVICE_URL", '') -COMMENTS_SERVICE_KEY = ENV_TOKENS.get("COMMENTS_SERVICE_KEY", '') -CERT_QUEUE = ENV_TOKENS.get("CERT_QUEUE", 'test-pull') - -# Python lib settings -PYTHON_LIB_FILENAME = ENV_TOKENS.get('PYTHON_LIB_FILENAME', 'python_lib.zip') - -# Code jail settings -for name, value in ENV_TOKENS.get("CODE_JAIL", {}).items(): - oldvalue = CODE_JAIL.get(name) - if isinstance(oldvalue, dict): - for subname, subvalue in value.items(): - oldvalue[subname] = subvalue - else: - CODE_JAIL[name] = value - -COURSES_WITH_UNSAFE_CODE = ENV_TOKENS.get("COURSES_WITH_UNSAFE_CODE", []) - -# Event Tracking -if "TRACKING_IGNORE_URL_PATTERNS" in ENV_TOKENS: - TRACKING_IGNORE_URL_PATTERNS = ENV_TOKENS.get("TRACKING_IGNORE_URL_PATTERNS") - -# SSL external authentication settings -SSL_AUTH_EMAIL_DOMAIN = ENV_TOKENS.get("SSL_AUTH_EMAIL_DOMAIN", "MIT.EDU") -SSL_AUTH_DN_FORMAT_STRING = ENV_TOKENS.get( - "SSL_AUTH_DN_FORMAT_STRING", - "/C=US/ST=Massachusetts/O=Massachusetts Institute of Technology/OU=Client CA v1/CN={0}/emailAddress={1}" +LOGGING = get_logger_config( + LOG_DIR, + logging_env=LOGGING_ENV, + local_loglevel=LOCAL_LOGLEVEL, + service_variant=SERVICE_VARIANT, ) -# Video Caching. Pairing country codes with CDN URLs. -# Example: {'CN': 'http://api.xuetangx.com/edx/video?s3_url='} -VIDEO_CDN_URL = ENV_TOKENS.get('VIDEO_CDN_URL', {}) - -# Determines whether the CSRF token can be transported on -# unencrypted channels. It is set to False here for backward compatibility, -# but it is highly recommended that this is True for environments accessed -# by end users. -CSRF_COOKIE_SECURE = ENV_TOKENS.get('CSRF_COOKIE_SECURE', False) - # Determines which origins are trusted for unsafe requests eg. POST requests. -CSRF_TRUSTED_ORIGINS = ENV_TOKENS.get('CSRF_TRUSTED_ORIGINS', []) -# 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', []) - -############# CORS headers for cross-domain requests ################# +CSRF_TRUSTED_ORIGINS = _YAML_TOKENS.get('CSRF_TRUSTED_ORIGINS_WITH_SCHEME', []) -if FEATURES.get('ENABLE_CORS_HEADERS') or FEATURES.get('ENABLE_CROSS_DOMAIN_CSRF_COOKIE'): +if FEATURES['ENABLE_CORS_HEADERS'] or FEATURES.get('ENABLE_CROSS_DOMAIN_CSRF_COOKIE'): CORS_ALLOW_CREDENTIALS = True - CORS_ORIGIN_WHITELIST = ENV_TOKENS.get('CORS_ORIGIN_WHITELIST', ()) - - CORS_ORIGIN_ALLOW_ALL = ENV_TOKENS.get('CORS_ORIGIN_ALLOW_ALL', False) - CORS_ALLOW_INSECURE = ENV_TOKENS.get('CORS_ALLOW_INSECURE', False) - - # If setting a cross-domain cookie, it's really important to choose - # a name for the cookie that is DIFFERENT than the cookies used - # by each subdomain. For example, suppose the applications - # at these subdomains are configured to use the following cookie names: - # - # 1) foo.example.com --> "csrftoken" - # 2) baz.example.com --> "csrftoken" - # 3) bar.example.com --> "csrftoken" - # - # For the cross-domain version of the CSRF cookie, you need to choose - # a name DIFFERENT than "csrftoken"; otherwise, the new token configured - # for ".example.com" could conflict with the other cookies, - # non-deterministically causing 403 responses. - # - # Because of the way Django stores cookies, the cookie name MUST - # be a `str`, not unicode. Otherwise there will `TypeError`s will be raised - # when Django tries to call the unicode `translate()` method with the wrong - # number of parameters. - CROSS_DOMAIN_CSRF_COOKIE_NAME = str(ENV_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_NAME')) - - # When setting the domain for the "cross-domain" version of the CSRF - # cookie, you should choose something like: ".example.com" - # (note the leading dot), where both the referer and the host - # are subdomains of "example.com". - # - # Browser security rules require that - # the cookie domain matches the domain of the server; otherwise - # the cookie won't get set. And once the cookie gets set, the client - # needs to be on a domain that matches the cookie domain, otherwise - # the client won't be able to read the cookie. - CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = ENV_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_DOMAIN') - - -# Field overrides. To use the IDDE feature, add -# 'courseware.student_field_overrides.IndividualStudentOverrideProvider'. -FIELD_OVERRIDE_PROVIDERS = tuple(ENV_TOKENS.get('FIELD_OVERRIDE_PROVIDERS', [])) - -############### XBlock filesystem field config ########## -if 'DJFS' in AUTH_TOKENS and AUTH_TOKENS['DJFS'] is not None: - DJFS = AUTH_TOKENS['DJFS'] - -############### Module Store Items ########## -HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS = ENV_TOKENS.get('HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS', {}) + CORS_ORIGIN_WHITELIST = _YAML_TOKENS.get('CORS_ORIGIN_WHITELIST', ()) + CORS_ORIGIN_ALLOW_ALL = _YAML_TOKENS.get('CORS_ORIGIN_ALLOW_ALL', False) + CORS_ALLOW_INSECURE = _YAML_TOKENS.get('CORS_ALLOW_INSECURE', False) + CROSS_DOMAIN_CSRF_COOKIE_DOMAIN = _YAML_TOKENS.get('CROSS_DOMAIN_CSRF_COOKIE_DOMAIN') + # PREVIEW DOMAIN must be present in HOSTNAME_MODULESTORE_DEFAULT_MAPPINGS for the preview to show draft changes if 'PREVIEW_LMS_BASE' in FEATURES and FEATURES['PREVIEW_LMS_BASE'] != '': PREVIEW_DOMAIN = FEATURES['PREVIEW_LMS_BASE'].split(':')[0] @@ -432,26 +359,11 @@ def get_env_setting(setting): PREVIEW_DOMAIN: 'draft-preferred' }) -MODULESTORE_FIELD_OVERRIDE_PROVIDERS = ENV_TOKENS.get( - 'MODULESTORE_FIELD_OVERRIDE_PROVIDERS', - MODULESTORE_FIELD_OVERRIDE_PROVIDERS -) - -XBLOCK_FIELD_DATA_WRAPPERS = ENV_TOKENS.get( - 'XBLOCK_FIELD_DATA_WRAPPERS', - XBLOCK_FIELD_DATA_WRAPPERS -) - ############### Mixed Related(Secure/Not-Secure) Items ########## -LMS_SEGMENT_KEY = AUTH_TOKENS.get('SEGMENT_KEY') - -SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] +LMS_SEGMENT_KEY = _YAML_TOKENS.get('SEGMENT_KEY') -AWS_ACCESS_KEY_ID = AUTH_TOKENS.get("AWS_ACCESS_KEY_ID", AWS_ACCESS_KEY_ID) if AWS_ACCESS_KEY_ID == "": AWS_ACCESS_KEY_ID = None - -AWS_SECRET_ACCESS_KEY = AUTH_TOKENS.get("AWS_SECRET_ACCESS_KEY", AWS_SECRET_ACCESS_KEY) if AWS_SECRET_ACCESS_KEY == "": AWS_SECRET_ACCESS_KEY = None @@ -460,24 +372,10 @@ def get_env_setting(setting): # same with upcoming version setting it to `public-read`. AWS_DEFAULT_ACL = 'public-read' AWS_BUCKET_ACL = AWS_DEFAULT_ACL -AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME', 'edxuploads') -# Disabling querystring auth instructs Boto to exclude the querystring parameters (e.g. signature, access key) it -# normally appends to every returned URL. -AWS_QUERYSTRING_AUTH = AUTH_TOKENS.get('AWS_QUERYSTRING_AUTH', True) -AWS_S3_CUSTOM_DOMAIN = AUTH_TOKENS.get('AWS_S3_CUSTOM_DOMAIN', 'edxuploads.s3.amazonaws.com') - -if AUTH_TOKENS.get('DEFAULT_FILE_STORAGE'): - DEFAULT_FILE_STORAGE = AUTH_TOKENS.get('DEFAULT_FILE_STORAGE') -elif AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: +# Change to S3Boto3 if we haven't specified another default storage AND we have specified AWS creds. +if (not _YAML_TOKENS.get('DEFAULT_FILE_STORAGE')) and AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY: DEFAULT_FILE_STORAGE = 'storages.backends.s3boto3.S3Boto3Storage' -else: - DEFAULT_FILE_STORAGE = 'django.core.files.storage.FileSystemStorage' - - -# If there is a database called 'read_replica', you can use the use_read_replica_if_available -# function in util/query.py, which is useful for very large database reads -DATABASES = AUTH_TOKENS.get('DATABASES', DATABASES) # The normal database user does not have enough permissions to run migrations. # Migrations are run with separate credentials, given as DB_MIGRATION_* @@ -493,154 +391,35 @@ def get_env_setting(setting): 'PORT': os.environ.get('DB_MIGRATION_PORT', database['PORT']), }) -XQUEUE_INTERFACE = AUTH_TOKENS.get('XQUEUE_INTERFACE', XQUEUE_INTERFACE) - # Get the MODULESTORE from auth.json, but if it doesn't exist, # use the one from common.py -MODULESTORE = convert_module_store_setting_if_needed(AUTH_TOKENS.get('MODULESTORE', MODULESTORE)) - -# After conversion above, the modulestore will have a "stores" list with all defined stores, for all stores, add the -# fs_root entry to derived collection so that if it's a callable it can be resolved. We need to do this because the -# `derived_collection_entry` takes an exact index value but the config file might have overridden the number of stores -# and so we can't be sure that the 2 we define in common.py will be there when we try to derive settings. This could -# lead to exceptions being thrown when the `derive_settings` call later in this file tries to update settings. We call -# the derived_collection_entry function here to ensure that we update the fs_root for any callables that remain after -# we've updated the MODULESTORE setting from our config file. -for idx, store in enumerate(MODULESTORE['default']['OPTIONS']['stores']): - if 'OPTIONS' in store and 'fs_root' in store["OPTIONS"]: - derived_collection_entry('MODULESTORE', 'default', 'OPTIONS', 'stores', idx, 'OPTIONS', 'fs_root') - -MONGODB_LOG = AUTH_TOKENS.get('MONGODB_LOG', {}) - -EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', '') # django default is '' -EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', '') # django default is '' - -# Analytics API -ANALYTICS_API_KEY = AUTH_TOKENS.get("ANALYTICS_API_KEY", ANALYTICS_API_KEY) -ANALYTICS_API_URL = ENV_TOKENS.get("ANALYTICS_API_URL", ANALYTICS_API_URL) - -# Zendesk -ZENDESK_USER = AUTH_TOKENS.get("ZENDESK_USER") -ZENDESK_API_KEY = AUTH_TOKENS.get("ZENDESK_API_KEY") - -# API Key for inbound requests from Notifier service -EDX_API_KEY = AUTH_TOKENS.get("EDX_API_KEY") - -# Celery Broker -CELERY_BROKER_TRANSPORT = ENV_TOKENS.get("CELERY_BROKER_TRANSPORT", "") -CELERY_BROKER_HOSTNAME = ENV_TOKENS.get("CELERY_BROKER_HOSTNAME", "") -CELERY_BROKER_VHOST = ENV_TOKENS.get("CELERY_BROKER_VHOST", "") -CELERY_BROKER_USER = AUTH_TOKENS.get("CELERY_BROKER_USER", "") -CELERY_BROKER_PASSWORD = AUTH_TOKENS.get("CELERY_BROKER_PASSWORD", "") +MODULESTORE = convert_module_store_setting_if_needed(_YAML_TOKENS.get('MODULESTORE', MODULESTORE)) BROKER_URL = "{}://{}:{}@{}/{}".format(CELERY_BROKER_TRANSPORT, CELERY_BROKER_USER, CELERY_BROKER_PASSWORD, CELERY_BROKER_HOSTNAME, CELERY_BROKER_VHOST) -BROKER_USE_SSL = ENV_TOKENS.get('CELERY_BROKER_USE_SSL', False) - try: BROKER_TRANSPORT_OPTIONS = { 'fanout_patterns': True, 'fanout_prefix': True, - **ENV_TOKENS.get('CELERY_BROKER_TRANSPORT_OPTIONS', {}) + **_YAML_TOKENS.get('CELERY_BROKER_TRANSPORT_OPTIONS', {}) } except TypeError as exc: raise ImproperlyConfigured('CELERY_BROKER_TRANSPORT_OPTIONS must be a dict') from exc -# Block Structures - -# upload limits -STUDENT_FILEUPLOAD_MAX_SIZE = ENV_TOKENS.get("STUDENT_FILEUPLOAD_MAX_SIZE", STUDENT_FILEUPLOAD_MAX_SIZE) - # Event tracking -TRACKING_BACKENDS.update(AUTH_TOKENS.get("TRACKING_BACKENDS", {})) -EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update(AUTH_TOKENS.get("EVENT_TRACKING_BACKENDS", {})) -EVENT_TRACKING_BACKENDS['segmentio']['OPTIONS']['processors'][0]['OPTIONS']['whitelist'].extend( - AUTH_TOKENS.get("EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST", [])) -TRACKING_SEGMENTIO_WEBHOOK_SECRET = AUTH_TOKENS.get( - "TRACKING_SEGMENTIO_WEBHOOK_SECRET", - TRACKING_SEGMENTIO_WEBHOOK_SECRET -) -TRACKING_SEGMENTIO_ALLOWED_TYPES = ENV_TOKENS.get("TRACKING_SEGMENTIO_ALLOWED_TYPES", TRACKING_SEGMENTIO_ALLOWED_TYPES) -TRACKING_SEGMENTIO_DISALLOWED_SUBSTRING_NAMES = ENV_TOKENS.get( - "TRACKING_SEGMENTIO_DISALLOWED_SUBSTRING_NAMES", - TRACKING_SEGMENTIO_DISALLOWED_SUBSTRING_NAMES -) -TRACKING_SEGMENTIO_SOURCE_MAP = ENV_TOKENS.get("TRACKING_SEGMENTIO_SOURCE_MAP", TRACKING_SEGMENTIO_SOURCE_MAP) - -# Heartbeat -HEARTBEAT_CELERY_ROUTING_KEY = ENV_TOKENS.get('HEARTBEAT_CELERY_ROUTING_KEY', HEARTBEAT_CELERY_ROUTING_KEY) - -# Student identity verification settings -VERIFY_STUDENT = AUTH_TOKENS.get("VERIFY_STUDENT", VERIFY_STUDENT) -DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH = ENV_TOKENS.get( - "DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH", - DISABLE_ACCOUNT_ACTIVATION_REQUIREMENT_SWITCH -) - -# Grades download -GRADES_DOWNLOAD_ROUTING_KEY = ENV_TOKENS.get('GRADES_DOWNLOAD_ROUTING_KEY', HIGH_MEM_QUEUE) - -GRADES_DOWNLOAD = ENV_TOKENS.get("GRADES_DOWNLOAD", GRADES_DOWNLOAD) - -# Rate limit for regrading tasks that a grading policy change can kick off - -# financial reports -FINANCIAL_REPORTS = ENV_TOKENS.get("FINANCIAL_REPORTS", FINANCIAL_REPORTS) - -##### ORA2 ###### -# Prefix for uploads of example-based assessment AI classifiers -# This can be used to separate uploads for different environments -# within the same S3 bucket. -ORA2_FILE_PREFIX = ENV_TOKENS.get("ORA2_FILE_PREFIX", ORA2_FILE_PREFIX) - -##### ACCOUNT LOCKOUT DEFAULT PARAMETERS ##### -MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED = ENV_TOKENS.get( - "MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED", MAX_FAILED_LOGIN_ATTEMPTS_ALLOWED -) - -MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS = ENV_TOKENS.get( - "MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS", MAX_FAILED_LOGIN_ATTEMPTS_LOCKOUT_PERIOD_SECS +TRACKING_BACKENDS.update(_YAML_TOKENS.get("TRACKING_BACKENDS", {})) +EVENT_TRACKING_BACKENDS['tracking_logs']['OPTIONS']['backends'].update( + _YAML_TOKENS.get("EVENT_TRACKING_BACKENDS", {}) ) - -##### LOGISTRATION RATE LIMIT SETTINGS ##### -LOGISTRATION_RATELIMIT_RATE = ENV_TOKENS.get('LOGISTRATION_RATELIMIT_RATE', LOGISTRATION_RATELIMIT_RATE) -LOGISTRATION_API_RATELIMIT = ENV_TOKENS.get('LOGISTRATION_API_RATELIMIT', LOGISTRATION_API_RATELIMIT) -LOGIN_AND_REGISTER_FORM_RATELIMIT = ENV_TOKENS.get( - 'LOGIN_AND_REGISTER_FORM_RATELIMIT', LOGIN_AND_REGISTER_FORM_RATELIMIT -) -RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT = ENV_TOKENS.get( - 'RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT', RESET_PASSWORD_TOKEN_VALIDATE_API_RATELIMIT -) -RESET_PASSWORD_API_RATELIMIT = ENV_TOKENS.get('RESET_PASSWORD_API_RATELIMIT', RESET_PASSWORD_API_RATELIMIT) - -##### REGISTRATION RATE LIMIT SETTINGS ##### -REGISTRATION_VALIDATION_RATELIMIT = ENV_TOKENS.get( - 'REGISTRATION_VALIDATION_RATELIMIT', REGISTRATION_VALIDATION_RATELIMIT +EVENT_TRACKING_BACKENDS['segmentio']['OPTIONS']['processors'][0]['OPTIONS']['whitelist'].extend( + EVENT_TRACKING_SEGMENTIO_EMIT_WHITELIST ) -REGISTRATION_RATELIMIT = ENV_TOKENS.get('REGISTRATION_RATELIMIT', REGISTRATION_RATELIMIT) - -#### PASSWORD POLICY SETTINGS ##### -AUTH_PASSWORD_VALIDATORS = ENV_TOKENS.get("AUTH_PASSWORD_VALIDATORS", AUTH_PASSWORD_VALIDATORS) - -### INACTIVITY SETTINGS #### -SESSION_INACTIVITY_TIMEOUT_IN_SECONDS = AUTH_TOKENS.get("SESSION_INACTIVITY_TIMEOUT_IN_SECONDS") - -##### LMS DEADLINE DISPLAY TIME_ZONE ####### -TIME_ZONE_DISPLAYED_FOR_DEADLINES = ENV_TOKENS.get("TIME_ZONE_DISPLAYED_FOR_DEADLINES", - TIME_ZONE_DISPLAYED_FOR_DEADLINES) - -#### PROCTORED EXAM SETTINGS #### -PROCTORED_EXAM_VIEWABLE_PAST_DUE = ENV_TOKENS.get('PROCTORED_EXAM_VIEWABLE_PAST_DUE', False) - -##### Third-party auth options ################################################ -ENABLE_REQUIRE_THIRD_PARTY_AUTH = ENV_TOKENS.get('ENABLE_REQUIRE_THIRD_PARTY_AUTH', False) - if FEATURES.get('ENABLE_THIRD_PARTY_AUTH'): - tmp_backends = ENV_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [ + AUTHENTICATION_BACKENDS = _YAML_TOKENS.get('THIRD_PARTY_AUTH_BACKENDS', [ 'social_core.backends.google.GoogleOAuth2', 'social_core.backends.linkedin.LinkedinOAuth2', 'social_core.backends.facebook.FacebookOAuth2', @@ -649,136 +428,66 @@ def get_env_setting(setting): 'common.djangoapps.third_party_auth.identityserver3.IdentityServer3', 'common.djangoapps.third_party_auth.saml.SAMLAuthBackend', 'common.djangoapps.third_party_auth.lti.LTIAuthBackend', - ]) - - AUTHENTICATION_BACKENDS = list(tmp_backends) + list(AUTHENTICATION_BACKENDS) - del tmp_backends + ]) + list(AUTHENTICATION_BACKENDS) # The reduced session expiry time during the third party login pipeline. (Value in seconds) - SOCIAL_AUTH_PIPELINE_TIMEOUT = ENV_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) - - # Most provider configuration is done via ConfigurationModels but for a few sensitive values - # we allow configuration via AUTH_TOKENS instead (optionally). - # The SAML private/public key values do not need the delimiter lines (such as - # "-----BEGIN PRIVATE KEY-----", "-----END PRIVATE KEY-----" etc.) but they may be included - # if you want (though it's easier to format the key values as JSON without the delimiters). - SOCIAL_AUTH_SAML_SP_PRIVATE_KEY = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY', '') - SOCIAL_AUTH_SAML_SP_PUBLIC_CERT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT', '') - SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PRIVATE_KEY_DICT', {}) - SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT = AUTH_TOKENS.get('SOCIAL_AUTH_SAML_SP_PUBLIC_CERT_DICT', {}) - SOCIAL_AUTH_OAUTH_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_OAUTH_SECRETS', {}) - SOCIAL_AUTH_LTI_CONSUMER_SECRETS = AUTH_TOKENS.get('SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {}) + SOCIAL_AUTH_PIPELINE_TIMEOUT = _YAML_TOKENS.get('SOCIAL_AUTH_PIPELINE_TIMEOUT', 600) + + # TODO: Would it be safe to just set this default in common.py, even if ENABLE_THIRD_PARTY_AUTH is False? + SOCIAL_AUTH_LTI_CONSUMER_SECRETS = _YAML_TOKENS.get('SOCIAL_AUTH_LTI_CONSUMER_SECRETS', {}) # third_party_auth config moved to ConfigurationModels. This is for data migration only: - THIRD_PARTY_AUTH_OLD_CONFIG = AUTH_TOKENS.get('THIRD_PARTY_AUTH', None) + THIRD_PARTY_AUTH_OLD_CONFIG = _YAML_TOKENS.get('THIRD_PARTY_AUTH', None) - if ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24) is not None: + # TODO: This logic is somewhat insane. We're not sure if it's intentional or not. We've left it + # as-is for strict backwards compatibility, but it's worth revisiting. + if hours := _YAML_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24): + # If we didn't override the value in YAML, OR we overrode it to a truthy value, + # then update CELERYBEAT_SCHEDULE. CELERYBEAT_SCHEDULE['refresh-saml-metadata'] = { 'task': 'common.djangoapps.third_party_auth.fetch_saml_metadata', - 'schedule': datetime.timedelta(hours=ENV_TOKENS.get('THIRD_PARTY_AUTH_SAML_FETCH_PERIOD_HOURS', 24)), + 'schedule': datetime.timedelta(hours=hours), } # The following can be used to integrate a custom login form with third_party_auth. # It should be a dict where the key is a word passed via ?auth_entry=, and the value is a # dict with an arbitrary 'secret_key' and a 'url'. - THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS = AUTH_TOKENS.get('THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS', {}) + THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS = _YAML_TOKENS.get('THIRD_PARTY_AUTH_CUSTOM_AUTH_FORMS', {}) ##### OAUTH2 Provider ############## -if FEATURES.get('ENABLE_OAUTH2_PROVIDER'): - OAUTH_ENFORCE_SECURE = ENV_TOKENS.get('OAUTH_ENFORCE_SECURE', True) - OAUTH_ENFORCE_CLIENT_SECURE = ENV_TOKENS.get('OAUTH_ENFORCE_CLIENT_SECURE', True) +if FEATURES['ENABLE_OAUTH2_PROVIDER']: + OAUTH_ENFORCE_SECURE = True + OAUTH_ENFORCE_CLIENT_SECURE = True # Defaults for the following are defined in lms.envs.common - OAUTH_EXPIRE_DELTA = datetime.timedelta( - days=ENV_TOKENS.get('OAUTH_EXPIRE_CONFIDENTIAL_CLIENT_DAYS', OAUTH_EXPIRE_CONFIDENTIAL_CLIENT_DAYS) - ) - OAUTH_EXPIRE_DELTA_PUBLIC = datetime.timedelta( - days=ENV_TOKENS.get('OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS', OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS) - ) - - -##### GOOGLE ANALYTICS IDS ##### -GOOGLE_ANALYTICS_ACCOUNT = AUTH_TOKENS.get('GOOGLE_ANALYTICS_ACCOUNT') -GOOGLE_ANALYTICS_TRACKING_ID = AUTH_TOKENS.get('GOOGLE_ANALYTICS_TRACKING_ID') -GOOGLE_ANALYTICS_LINKEDIN = AUTH_TOKENS.get('GOOGLE_ANALYTICS_LINKEDIN') -GOOGLE_SITE_VERIFICATION_ID = ENV_TOKENS.get('GOOGLE_SITE_VERIFICATION_ID') -GOOGLE_ANALYTICS_4_ID = AUTH_TOKENS.get('GOOGLE_ANALYTICS_4_ID') - -##### BRANCH.IO KEY ##### -BRANCH_IO_KEY = AUTH_TOKENS.get('BRANCH_IO_KEY') - -#### Course Registration Code length #### -REGISTRATION_CODE_LENGTH = ENV_TOKENS.get('REGISTRATION_CODE_LENGTH', 8) - -# Which access.py permission names to check; -# We default this to the legacy permission 'see_exists'. -COURSE_CATALOG_VISIBILITY_PERMISSION = ENV_TOKENS.get( - 'COURSE_CATALOG_VISIBILITY_PERMISSION', - COURSE_CATALOG_VISIBILITY_PERMISSION -) -COURSE_ABOUT_VISIBILITY_PERMISSION = ENV_TOKENS.get( - 'COURSE_ABOUT_VISIBILITY_PERMISSION', - COURSE_ABOUT_VISIBILITY_PERMISSION -) - -DEFAULT_COURSE_VISIBILITY_IN_CATALOG = ENV_TOKENS.get( - 'DEFAULT_COURSE_VISIBILITY_IN_CATALOG', - DEFAULT_COURSE_VISIBILITY_IN_CATALOG -) - -DEFAULT_MOBILE_AVAILABLE = ENV_TOKENS.get( - 'DEFAULT_MOBILE_AVAILABLE', - DEFAULT_MOBILE_AVAILABLE -) - -# Enrollment API Cache Timeout -ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT = ENV_TOKENS.get('ENROLLMENT_COURSE_DETAILS_CACHE_TIMEOUT', 60) - -# Ecommerce Orders API Cache Timeout -ECOMMERCE_ORDERS_API_CACHE_TIMEOUT = ENV_TOKENS.get('ECOMMERCE_ORDERS_API_CACHE_TIMEOUT', 3600) - -if FEATURES.get('ENABLE_COURSEWARE_SEARCH') or \ - FEATURES.get('ENABLE_DASHBOARD_SEARCH') or \ - FEATURES.get('ENABLE_COURSE_DISCOVERY') or \ - FEATURES.get('ENABLE_TEAMS'): + OAUTH_EXPIRE_DELTA = datetime.timedelta(days=OAUTH_EXPIRE_CONFIDENTIAL_CLIENT_DAYS) + OAUTH_EXPIRE_DELTA_PUBLIC = datetime.timedelta(days=OAUTH_EXPIRE_PUBLIC_CLIENT_DAYS) + +if ( + FEATURES['ENABLE_COURSEWARE_SEARCH'] or + FEATURES['ENABLE_DASHBOARD_SEARCH'] or + FEATURES['ENABLE_COURSE_DISCOVERY'] or + FEATURES['ENABLE_TEAMS'] + ): # Use ElasticSearch as the search engine herein SEARCH_ENGINE = "search.elastic.ElasticSearchEngine" - SEARCH_FILTER_GENERATOR = ENV_TOKENS.get('SEARCH_FILTER_GENERATOR', SEARCH_FILTER_GENERATOR) - -SEARCH_SKIP_INVITATION_ONLY_FILTERING = ENV_TOKENS.get( - 'SEARCH_SKIP_INVITATION_ONLY_FILTERING', - SEARCH_SKIP_INVITATION_ONLY_FILTERING, -) -SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING = ENV_TOKENS.get( - 'SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING', - SEARCH_SKIP_SHOW_IN_CATALOG_FILTERING, -) - -SEARCH_COURSEWARE_CONTENT_LOG_PARAMS = ENV_TOKENS.get( - 'SEARCH_COURSEWARE_CONTENT_LOG_PARAMS', - SEARCH_COURSEWARE_CONTENT_LOG_PARAMS, -) # TODO: Once we have successfully upgraded to ES7, switch this back to ELASTIC_SEARCH_CONFIG. -ELASTIC_SEARCH_CONFIG = ENV_TOKENS.get('ELASTIC_SEARCH_CONFIG_ES7', [{}]) - -# Facebook app -FACEBOOK_API_VERSION = AUTH_TOKENS.get("FACEBOOK_API_VERSION") -FACEBOOK_APP_SECRET = AUTH_TOKENS.get("FACEBOOK_APP_SECRET") -FACEBOOK_APP_ID = AUTH_TOKENS.get("FACEBOOK_APP_ID") +ELASTIC_SEARCH_CONFIG = _YAML_TOKENS.get('ELASTIC_SEARCH_CONFIG_ES7', [{}]) -XBLOCK_SETTINGS = ENV_TOKENS.get('XBLOCK_SETTINGS', {}) -XBLOCK_SETTINGS.setdefault("VideoBlock", {})["licensing_enabled"] = FEATURES.get("LICENSING", False) -XBLOCK_SETTINGS.setdefault("VideoBlock", {})['YOUTUBE_API_KEY'] = AUTH_TOKENS.get('YOUTUBE_API_KEY', YOUTUBE_API_KEY) +XBLOCK_SETTINGS.setdefault("VideoBlock", {})["licensing_enabled"] = FEATURES["LICENSING"] +XBLOCK_SETTINGS.setdefault("VideoBlock", {})['YOUTUBE_API_KEY'] = YOUTUBE_API_KEY ##### Custom Courses for EdX ##### -if FEATURES.get('CUSTOM_COURSES_EDX'): +if FEATURES['CUSTOM_COURSES_EDX']: INSTALLED_APPS += ['lms.djangoapps.ccx', 'openedx.core.djangoapps.ccxcon.apps.CCXConnectorConfig'] MODULESTORE_FIELD_OVERRIDE_PROVIDERS += ( 'lms.djangoapps.ccx.overrides.CustomCoursesForEdxOverrideProvider', ) +FIELD_OVERRIDE_PROVIDERS = tuple(FIELD_OVERRIDE_PROVIDERS) + ##### Individual Due Date Extensions ##### -if FEATURES.get('INDIVIDUAL_DUE_DATES'): +if FEATURES['INDIVIDUAL_DUE_DATES']: FIELD_OVERRIDE_PROVIDERS += ( 'lms.djangoapps.courseware.student_field_overrides.IndividualStudentOverrideProvider', ) @@ -799,199 +508,31 @@ def get_env_setting(setting): # PROFILE IMAGE CONFIG PROFILE_IMAGE_DEFAULT_FILENAME = 'images/profiles/default' -PROFILE_IMAGE_SIZES_MAP = ENV_TOKENS.get( - 'PROFILE_IMAGE_SIZES_MAP', - PROFILE_IMAGE_SIZES_MAP -) ##### Credit Provider Integration ##### -CREDIT_PROVIDER_SECRET_KEYS = AUTH_TOKENS.get("CREDIT_PROVIDER_SECRET_KEYS", {}) - ##################### LTI Provider ##################### -if FEATURES.get('ENABLE_LTI_PROVIDER'): +if FEATURES['ENABLE_LTI_PROVIDER']: INSTALLED_APPS.append('lms.djangoapps.lti_provider.apps.LtiProviderConfig') AUTHENTICATION_BACKENDS.append('lms.djangoapps.lti_provider.users.LtiBackend') -LTI_USER_EMAIL_DOMAIN = ENV_TOKENS.get('LTI_USER_EMAIL_DOMAIN', 'lti.example.com') - -# For more info on this, see the notes in common.py -LTI_AGGREGATE_SCORE_PASSBACK_DELAY = ENV_TOKENS.get( - 'LTI_AGGREGATE_SCORE_PASSBACK_DELAY', LTI_AGGREGATE_SCORE_PASSBACK_DELAY -) - ##################### Credit Provider help link #################### #### JWT configuration #### -JWT_AUTH.update(ENV_TOKENS.get('JWT_AUTH', {})) -JWT_AUTH.update(AUTH_TOKENS.get('JWT_AUTH', {})) - -# Offset for pk of courseware.StudentModuleHistoryExtended -STUDENTMODULEHISTORYEXTENDED_OFFSET = ENV_TOKENS.get( - 'STUDENTMODULEHISTORYEXTENDED_OFFSET', STUDENTMODULEHISTORYEXTENDED_OFFSET -) - -################################ Settings for Credentials Service ################################ - -CREDENTIALS_GENERATION_ROUTING_KEY = ENV_TOKENS.get('CREDENTIALS_GENERATION_ROUTING_KEY', DEFAULT_PRIORITY_QUEUE) +JWT_AUTH.update(_YAML_TOKENS.get('JWT_AUTH', {})) -# Queue to use for award program certificates -PROGRAM_CERTIFICATES_ROUTING_KEY = ENV_TOKENS.get('PROGRAM_CERTIFICATES_ROUTING_KEY', DEFAULT_PRIORITY_QUEUE) -SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY = ENV_TOKENS.get( - 'SOFTWARE_SECURE_VERIFICATION_ROUTING_KEY', - HIGH_PRIORITY_QUEUE -) - -API_ACCESS_MANAGER_EMAIL = ENV_TOKENS.get('API_ACCESS_MANAGER_EMAIL') -API_ACCESS_FROM_EMAIL = ENV_TOKENS.get('API_ACCESS_FROM_EMAIL') - -############## OPEN EDX ENTERPRISE SERVICE CONFIGURATION ###################### -# The Open edX Enterprise service is currently hosted via the LMS container/process. -# However, for all intents and purposes this service is treated as a standalone IDA. -# These configuration settings are specific to the Enterprise service and you should -# not find references to them within the edx-platform project. - -# Publicly-accessible enrollment URL, for use on the client side. -ENTERPRISE_PUBLIC_ENROLLMENT_API_URL = ENV_TOKENS.get( - 'ENTERPRISE_PUBLIC_ENROLLMENT_API_URL', - (LMS_ROOT_URL or '') + LMS_ENROLLMENT_API_PATH -) - -# Enrollment URL used on the server-side. -ENTERPRISE_ENROLLMENT_API_URL = ENV_TOKENS.get( - 'ENTERPRISE_ENROLLMENT_API_URL', - (LMS_INTERNAL_ROOT_URL or '') + LMS_ENROLLMENT_API_PATH -) - -# Enterprise logo image size limit in KB's -ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE = ENV_TOKENS.get( - 'ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE', - ENTERPRISE_CUSTOMER_LOGO_IMAGE_SIZE -) - -# Course enrollment modes to be hidden in the Enterprise enrollment page -# if the "Hide audit track" flag is enabled for an EnterpriseCustomer -ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES = ENV_TOKENS.get( - 'ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES', - ENTERPRISE_COURSE_ENROLLMENT_AUDIT_MODES -) - -# A support URL used on Enterprise landing pages for when a warning -# message goes off. -ENTERPRISE_SUPPORT_URL = ENV_TOKENS.get( - 'ENTERPRISE_SUPPORT_URL', - ENTERPRISE_SUPPORT_URL -) - -# A default dictionary to be used for filtering out enterprise customer catalog. -ENTERPRISE_CUSTOMER_CATALOG_DEFAULT_CONTENT_FILTER = ENV_TOKENS.get( - 'ENTERPRISE_CUSTOMER_CATALOG_DEFAULT_CONTENT_FILTER', - ENTERPRISE_CUSTOMER_CATALOG_DEFAULT_CONTENT_FILTER -) -INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT = ENV_TOKENS.get( - 'INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT', - INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT -) - -############## ENTERPRISE SERVICE API CLIENT CONFIGURATION ###################### -# The LMS communicates with the Enterprise service via the requests.Session() client -# The below environmental settings are utilized by the LMS when interacting with -# the service, and override the default parameters which are defined in common.py - -DEFAULT_ENTERPRISE_API_URL = None -if LMS_INTERNAL_ROOT_URL is not None: - DEFAULT_ENTERPRISE_API_URL = LMS_INTERNAL_ROOT_URL + '/enterprise/api/v1/' -ENTERPRISE_API_URL = ENV_TOKENS.get('ENTERPRISE_API_URL', DEFAULT_ENTERPRISE_API_URL) - -DEFAULT_ENTERPRISE_CONSENT_API_URL = None -if LMS_INTERNAL_ROOT_URL is not None: - DEFAULT_ENTERPRISE_CONSENT_API_URL = LMS_INTERNAL_ROOT_URL + '/consent/api/v1/' -ENTERPRISE_CONSENT_API_URL = ENV_TOKENS.get('ENTERPRISE_CONSENT_API_URL', DEFAULT_ENTERPRISE_CONSENT_API_URL) - -ENTERPRISE_SERVICE_WORKER_USERNAME = ENV_TOKENS.get( - 'ENTERPRISE_SERVICE_WORKER_USERNAME', - ENTERPRISE_SERVICE_WORKER_USERNAME -) -ENTERPRISE_API_CACHE_TIMEOUT = ENV_TOKENS.get( - 'ENTERPRISE_API_CACHE_TIMEOUT', - ENTERPRISE_API_CACHE_TIMEOUT -) -ENTERPRISE_CATALOG_INTERNAL_ROOT_URL = ENV_TOKENS.get( - 'ENTERPRISE_CATALOG_INTERNAL_ROOT_URL', - ENTERPRISE_CATALOG_INTERNAL_ROOT_URL -) - -CHAT_COMPLETION_API = ENV_TOKENS.get('CHAT_COMPLETION_API', '') -CHAT_COMPLETION_API_KEY = ENV_TOKENS.get('CHAT_COMPLETION_API_KEY', '') -LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT = ENV_TOKENS.get('LEARNER_ENGAGEMENT_PROMPT_FOR_ACTIVE_CONTRACT', '') -LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT = ENV_TOKENS.get( - 'LEARNER_ENGAGEMENT_PROMPT_FOR_NON_ACTIVE_CONTRACT', - '' -) -LEARNER_PROGRESS_PROMPT_FOR_ACTIVE_CONTRACT = ENV_TOKENS.get('LEARNER_PROGRESS_PROMPT_FOR_ACTIVE_CONTRACT', '') -LEARNER_PROGRESS_PROMPT_FOR_NON_ACTIVE_CONTRACT = ENV_TOKENS.get('LEARNER_PROGRESS_PROMPT_FOR_NON_ACTIVE_CONTRACT', '') ############## ENTERPRISE SERVICE LMS CONFIGURATION ################################## # The LMS has some features embedded that are related to the Enterprise service, but # which are not provided by the Enterprise service. These settings override the # base values for the parameters as defined in common.py -ENTERPRISE_PLATFORM_WELCOME_TEMPLATE = ENV_TOKENS.get( - 'ENTERPRISE_PLATFORM_WELCOME_TEMPLATE', - ENTERPRISE_PLATFORM_WELCOME_TEMPLATE -) -ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE = ENV_TOKENS.get( - 'ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE', - ENTERPRISE_SPECIFIC_BRANDED_WELCOME_TEMPLATE -) -ENTERPRISE_TAGLINE = ENV_TOKENS.get( - 'ENTERPRISE_TAGLINE', - ENTERPRISE_TAGLINE -) -ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS = set( - ENV_TOKENS.get( - 'ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS', - ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS - ) -) -BASE_COOKIE_DOMAIN = ENV_TOKENS.get( - 'BASE_COOKIE_DOMAIN', - BASE_COOKIE_DOMAIN -) -SYSTEM_TO_FEATURE_ROLE_MAPPING = ENV_TOKENS.get( - 'SYSTEM_TO_FEATURE_ROLE_MAPPING', - SYSTEM_TO_FEATURE_ROLE_MAPPING -) - -# Add an ICP license for serving content in China if your organization is registered to do so -ICP_LICENSE = ENV_TOKENS.get('ICP_LICENSE', None) -ICP_LICENSE_INFO = ENV_TOKENS.get('ICP_LICENSE_INFO', {}) - -# How long to cache OpenAPI schemas and UI, in seconds. -OPENAPI_CACHE_TIMEOUT = ENV_TOKENS.get('OPENAPI_CACHE_TIMEOUT', 60 * 60) - -########################## Parental controls config ####################### - -# The age at which a learner no longer requires parental consent, or None -# if parental consent is never required. -PARENTAL_CONSENT_AGE_LIMIT = ENV_TOKENS.get( - 'PARENTAL_CONSENT_AGE_LIMIT', - PARENTAL_CONSENT_AGE_LIMIT -) +ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS = set(ENTERPRISE_EXCLUDED_REGISTRATION_FIELDS) ########################## Extra middleware classes ####################### # Allow extra middleware classes to be added to the app through configuration. -MIDDLEWARE.extend(ENV_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) - -################# Settings for the maintenance banner ################# -MAINTENANCE_BANNER_TEXT = ENV_TOKENS.get('MAINTENANCE_BANNER_TEXT', None) - -########################## limiting dashboard courses ###################### -DASHBOARD_COURSE_LIMIT = ENV_TOKENS.get('DASHBOARD_COURSE_LIMIT', None) - -######################## Setting for content libraries ######################## -MAX_BLOCKS_PER_CONTENT_LIBRARY = ENV_TOKENS.get('MAX_BLOCKS_PER_CONTENT_LIBRARY', MAX_BLOCKS_PER_CONTENT_LIBRARY) +MIDDLEWARE.extend(_YAML_TOKENS.get('EXTRA_MIDDLEWARE_CLASSES', [])) ########################## Derive Any Derived Settings ####################### @@ -1001,23 +542,20 @@ def get_env_setting(setting): # This is at the bottom because it is going to load more settings after base settings are loaded +# These dicts are defined solely for BACKWARDS COMPATIBILITY with existing plugins which may theoretically +# rely upon them. Please do not add new references to these dicts! +# - If you need to access the YAML values in this module, use _YAML_TOKENS. +# - If you need to access to these values elsewhere, use the corresponding rendered `settings.*` +# value rathering than diving into these dicts. +ENV_TOKENS = _YAML_TOKENS +AUTH_TOKENS = _YAML_TOKENS +ENV_FEATURES = _YAML_TOKENS.get("FEATURES", {}) +ENV_CELERY_QUEUES = _YAML_CELERY_QUEUES +ALTERNATE_QUEUE_ENVS = _YAML_ALTERNATE_WORKER_QUEUES + # Load production.py in plugins add_plugins(__name__, ProjectType.LMS, SettingsType.PRODUCTION) -############## Settings for Completion API ######################### - -# Once a user has watched this percentage of a video, mark it as complete: -# (0.0 = 0%, 1.0 = 100%) -COMPLETION_VIDEO_COMPLETE_PERCENTAGE = ENV_TOKENS.get('COMPLETION_VIDEO_COMPLETE_PERCENTAGE', - COMPLETION_VIDEO_COMPLETE_PERCENTAGE) -COMPLETION_BY_VIEWING_DELAY_MS = ENV_TOKENS.get('COMPLETION_BY_VIEWING_DELAY_MS', - COMPLETION_BY_VIEWING_DELAY_MS) - -################# Settings for brand logos. ################# -LOGO_URL = ENV_TOKENS.get('LOGO_URL', LOGO_URL) -LOGO_URL_PNG = ENV_TOKENS.get('LOGO_URL_PNG', LOGO_URL_PNG) -LOGO_TRADEMARK_URL = ENV_TOKENS.get('LOGO_TRADEMARK_URL', LOGO_TRADEMARK_URL) -FAVICON_URL = ENV_TOKENS.get('FAVICON_URL', FAVICON_URL) ######################## CELERY ROUTING ######################## @@ -1077,56 +615,32 @@ def get_env_setting(setting): } -LOGO_IMAGE_EXTRA_TEXT = ENV_TOKENS.get('LOGO_IMAGE_EXTRA_TEXT', '') - ############## XBlock extra mixins ############################ XBLOCK_MIXINS += tuple(XBLOCK_EXTRA_MIXINS) -############## Settings for course import olx validation ############################ -COURSE_OLX_VALIDATION_STAGE = ENV_TOKENS.get('COURSE_OLX_VALIDATION_STAGE', COURSE_OLX_VALIDATION_STAGE) -COURSE_OLX_VALIDATION_IGNORE_LIST = ENV_TOKENS.get( - 'COURSE_OLX_VALIDATION_IGNORE_LIST', - COURSE_OLX_VALIDATION_IGNORE_LIST -) - -################# show account activate cta after register ######################## -SHOW_ACCOUNT_ACTIVATION_CTA = ENV_TOKENS.get('SHOW_ACCOUNT_ACTIVATION_CTA', SHOW_ACCOUNT_ACTIVATION_CTA) - -################# Discussions micro frontend URL ######################## -DISCUSSIONS_MICROFRONTEND_URL = ENV_TOKENS.get('DISCUSSIONS_MICROFRONTEND_URL', DISCUSSIONS_MICROFRONTEND_URL) - -################### Discussions micro frontend Feedback URL################### -DISCUSSIONS_MFE_FEEDBACK_URL = ENV_TOKENS.get('DISCUSSIONS_MFE_FEEDBACK_URL', DISCUSSIONS_MFE_FEEDBACK_URL) - -############################ AI_TRANSLATIONS URL ################################## -AI_TRANSLATIONS_API_URL = ENV_TOKENS.get('AI_TRANSLATIONS_API_URL', AI_TRANSLATIONS_API_URL) - ############## DRF overrides ############## -REST_FRAMEWORK.update(ENV_TOKENS.get('REST_FRAMEWORK', {})) +REST_FRAMEWORK.update(_YAML_TOKENS.get('REST_FRAMEWORK', {})) ############################# CELERY ############################ -CELERY_IMPORTS.extend(ENV_TOKENS.get('CELERY_EXTRA_IMPORTS', [])) +CELERY_IMPORTS.extend(_YAML_TOKENS.get('CELERY_EXTRA_IMPORTS', [])) # keys for big blue button live provider +# TODO: This should not be in the core platform. If it has to stay for now, though, then we should move these +# defaults into common.py COURSE_LIVE_GLOBAL_CREDENTIALS["BIG_BLUE_BUTTON"] = { - "KEY": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_KEY', None), - "SECRET": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_SECRET', None), - "URL": ENV_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_URL', None), + "KEY": _YAML_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_KEY'), + "SECRET": _YAML_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_SECRET'), + "URL": _YAML_TOKENS.get('BIG_BLUE_BUTTON_GLOBAL_URL'), } -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', {})) -BEAMER_PRODUCT_ID = ENV_TOKENS.get('BEAMER_PRODUCT_ID', BEAMER_PRODUCT_ID) - -# .. setting_name: DISABLED_COUNTRIES -# .. setting_default: [] -# .. setting_description: List of country codes that should be disabled -# .. for now it wil impact country listing in auth flow and user profile. -# .. eg ['US', 'CA'] -DISABLED_COUNTRIES = ENV_TOKENS.get('DISABLED_COUNTRIES', []) +EVENT_BUS_PRODUCER_CONFIG = merge_producer_configs( + EVENT_BUS_PRODUCER_CONFIG, + _YAML_TOKENS.get('EVENT_BUS_PRODUCER_CONFIG', {}) +) + +##################################################################################################### +# HEY! Don't add anything to the end of this file. +# Add your defaults to common.py instead! +# If you really need to add post-YAML logic, add it above the "Derive Any Derived Settings" section. +###################################################################################################### diff --git a/lms/envs/test.py b/lms/envs/test.py index a9e8aaf9f2e..b49a71b4711 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -564,7 +564,7 @@ PDF_RECEIPT_TERMS_AND_CONDITIONS = 'add your own terms and conditions' PDF_RECEIPT_TAX_ID_LABEL = 'Tax ID' -PROFILE_MICROFRONTEND_URL = "http://profile-mfe/abc/" +PROFILE_MICROFRONTEND_URL = "http://profile-mfe" ORDER_HISTORY_MICROFRONTEND_URL = "http://order-history-mfe/" ACCOUNT_MICROFRONTEND_URL = "http://account-mfe" AUTHN_MICROFRONTEND_URL = "http://authn-mfe" @@ -657,3 +657,35 @@ # case of new django version these values will override. if django.VERSION[0] >= 4: # for greater than django 3.2 use with schemes. CSRF_TRUSTED_ORIGINS = CSRF_TRUSTED_ORIGINS_WITH_SCHEME + + +############## Settings for JWT token handling ############## +TOKEN_SIGNING = { + 'JWT_ISSUER': 'token-test-issuer', + 'JWT_SIGNING_ALGORITHM': 'RS512', + 'JWT_SUPPORTED_VERSION': '1.2.0', + 'JWT_PRIVATE_SIGNING_JWK': '''{ + "e": "AQAB", + "d": "HIiV7KNjcdhVbpn3KT-I9n3JPf5YbGXsCIedmPqDH1d4QhBofuAqZ9zebQuxkRUpmqtYMv0Zi6ECSUqH387GYQF_XvFUFcjQRPycISd8TH0DAKaDpGr-AYNshnKiEtQpINhcP44I1AYNPCwyoxXA1fGTtmkKChsuWea7o8kytwU5xSejvh5-jiqu2SF4GEl0BEXIAPZsgbzoPIWNxgO4_RzNnWs6nJZeszcaDD0CyezVSuH9QcI6g5QFzAC_YuykSsaaFJhZ05DocBsLczShJ9Omf6PnK9xlm26I84xrEh_7x4fVmNBg3xWTLh8qOnHqGko93A1diLRCrKHOvnpvgQ", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ", + "q": "3T3DEtBUka7hLGdIsDlC96Uadx_q_E4Vb1cxx_4Ss_wGp1Loz3N3ZngGyInsKlmbBgLo1Ykd6T9TRvRNEWEtFSOcm2INIBoVoXk7W5RuPa8Cgq2tjQj9ziGQ08JMejrPlj3Q1wmALJr5VTfvSYBu0WkljhKNCy1KB6fCby0C9WE", + "p": "vUqzWPZnDG4IXyo-k5F0bHV0BNL_pVhQoLW7eyFHnw74IOEfSbdsMspNcPSFIrtgPsn7981qv3lN_staZ6JflKfHayjB_lvltHyZxfl0dvruShZOx1N6ykEo7YrAskC_qxUyrIvqmJ64zPW3jkuOYrFs7Ykj3zFx3Zq1H5568G0", + "kid": "token-test-sign", "kty": "RSA" + }''', + 'JWT_PUBLIC_SIGNING_JWK_SET': '''{ + "keys": [ + { + "kid":"token-test-wrong-key", + "e": "AQAB", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dffgRQLD1qf5D6sprmYfWVokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + }, + { + "kid":"token-test-sign", + "e": "AQAB", + "kty": "RSA", + "n": "o5cn3ljSRi6FaDEKTn0PS-oL9EFyv1pI7dRgffQLD1qf5D6sprmYfWWokSsrWig8u2y0HChSygR6Jn5KXBqQn6FpM0dDJLnWQDRXHLl3Ey1iPYgDSmOIsIGrV9ZyNCQwk03wAgWbfdBTig3QSDYD-sTNOs3pc4UD_PqAvU2nz_1SS2ZiOwOn5F6gulE1L0iE3KEUEvOIagfHNVhz0oxa_VRZILkzV-zr6R_TW1m97h4H8jXl_VJyQGyhMGGypuDrQ9_vaY_RLEulLCyY0INglHWQ7pckxBtI5q55-Vio2wgewe2_qYcGsnBGaDNbySAsvYcWRrqDiFyzrJYivodqTQ" + } + ] + }''', +} diff --git a/lms/static/js/spec/student_account/account_settings_factory_spec.js b/lms/static/js/spec/student_account/account_settings_factory_spec.js deleted file mode 100644 index 075142c84ac..00000000000 --- a/lms/static/js/spec/student_account/account_settings_factory_spec.js +++ /dev/null @@ -1,334 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/views/fields_helpers', - 'js/spec/student_account/helpers', - 'js/spec/student_account/account_settings_fields_helpers', - 'js/student_account/views/account_settings_factory', - 'js/student_account/views/account_settings_view' -], -function(Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers, Helpers, - AccountSettingsFieldViewSpecHelpers, AccountSettingsPage) { - 'use strict'; - - describe('edx.user.AccountSettingsFactory', function() { - var createAccountSettingsPage = function() { - var context = AccountSettingsPage( - Helpers.FIELDS_DATA, - false, - [], - Helpers.AUTH_DATA, - Helpers.PASSWORD_RESET_SUPPORT_LINK, - Helpers.USER_ACCOUNTS_API_URL, - Helpers.USER_PREFERENCES_API_URL, - 1, - Helpers.PLATFORM_NAME, - Helpers.CONTACT_EMAIL, - true, - Helpers.ENABLE_COPPA_COMPLIANCE - ); - return context.accountSettingsView; - }; - - var requests; - - beforeEach(function() { - setFixtures(''); - }); - - it('shows loading error when UserAccountModel fails to load', function() { - requests = AjaxHelpers.requests(this); - - var accountSettingsView = createAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - var request = requests[0]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); - - AjaxHelpers.respondWithError(requests, 500); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('shows loading error when UserPreferencesModel fails to load', function() { - requests = AjaxHelpers.requests(this); - - var accountSettingsView = createAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - var request = requests[0]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - request = requests[1]; - expect(request.method).toBe('GET'); - expect(request.url).toBe('/api/user/v1/preferences/time_zones/?country_code=1'); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - - request = requests[2]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_PREFERENCES_API_URL); - - AjaxHelpers.respondWithError(requests, 500); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('renders fields after the models are successfully fetched', function() { - requests = AjaxHelpers.requests(this); - - var accountSettingsView = createAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); - - accountSettingsView.render(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView); - }); - - it('expects all fields to behave correctly', function() { - var i, view; - - requests = AjaxHelpers.requests(this); - - var accountSettingsView = createAccountSettingsPage(); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); - AjaxHelpers.respondWithJson(requests, {}); // Page viewed analytics event - - var sectionsData = accountSettingsView.options.tabSections.aboutTabSections; - - expect(sectionsData[0].fields.length).toBe(7); - - var textFields = [sectionsData[0].fields[1], sectionsData[0].fields[2]]; - for (i = 0; i < textFields.length; i++) { - view = textFields[i].view; - FieldViewsSpecHelpers.verifyTextField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: view.options.helpMessage, - validValue: 'My Name', - invalidValue1: '', - invalidValue2: '@', - validationError: 'Think again!', - defaultValue: '' - }, requests); - } - - expect(sectionsData[1].fields.length).toBe(4); - var dropdownFields = [ - sectionsData[1].fields[0], - sectionsData[1].fields[1], - sectionsData[1].fields[2] - ]; - _.each(dropdownFields, function(field) { - // eslint-disable-next-line no-shadow - var view = field.view; - FieldViewsSpecHelpers.verifyDropDownField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: '', - validValue: Helpers.FIELD_OPTIONS[1][0], - invalidValue1: Helpers.FIELD_OPTIONS[2][0], - invalidValue2: Helpers.FIELD_OPTIONS[3][0], - validationError: 'Nope, this will not do!', - defaultValue: null - }, requests); - }); - }); - }); - - describe('edx.user.AccountSettingsFactory', function() { - var createEnterpriseLearnerAccountSettingsPage = function() { - var context = AccountSettingsPage( - Helpers.FIELDS_DATA, - false, - [], - Helpers.AUTH_DATA, - Helpers.PASSWORD_RESET_SUPPORT_LINK, - Helpers.USER_ACCOUNTS_API_URL, - Helpers.USER_PREFERENCES_API_URL, - 1, - Helpers.PLATFORM_NAME, - Helpers.CONTACT_EMAIL, - true, - Helpers.ENABLE_COPPA_COMPLIANCE, - '', - - Helpers.SYNC_LEARNER_PROFILE_DATA, - Helpers.ENTERPRISE_NAME, - Helpers.ENTERPRISE_READ_ONLY_ACCOUNT_FIELDS, - Helpers.EDX_SUPPORT_URL - ); - return context.accountSettingsView; - }; - - var requests; - var accountInfoTab = { - BASIC_ACCOUNT_INFORMATION: 0, - ADDITIONAL_INFORMATION: 1 - }; - var basicAccountInfoFields = { - USERNAME: 0, - FULL_NAME: 1, - EMAIL_ADDRESS: 2, - PASSWORD: 3, - LANGUAGE: 4, - COUNTRY: 5, - TIMEZONE: 6 - }; - var additionalInfoFields = { - EDUCATION: 0, - GENDER: 1, - YEAR_OF_BIRTH: 2, - PREFERRED_LANGUAGE: 3 - }; - - beforeEach(function() { - setFixtures(''); - }); - - it('shows loading error when UserAccountModel fails to load for enterprise learners', function() { - var accountSettingsView, request; - requests = AjaxHelpers.requests(this); - - accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - request = requests[0]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); - - AjaxHelpers.respondWithError(requests, 500); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('shows loading error when UserPreferencesModel fails to load for enterprise learners', function() { - var accountSettingsView, request; - requests = AjaxHelpers.requests(this); - - accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - request = requests[0]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_ACCOUNTS_API_URL); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - request = requests[1]; - expect(request.method).toBe('GET'); - expect(request.url).toBe('/api/user/v1/preferences/time_zones/?country_code=1'); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - - request = requests[2]; - expect(request.method).toBe('GET'); - expect(request.url).toBe(Helpers.USER_PREFERENCES_API_URL); - - AjaxHelpers.respondWithError(requests, 500); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('renders fields after the models are successfully fetched for enterprise learners', function() { - var accountSettingsView; - requests = AjaxHelpers.requests(this); - - accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); - - accountSettingsView.render(); - - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - Helpers.expectSettingsSectionsAndFieldsToBeRenderedWithMessage(accountSettingsView); - }); - - it('expects all fields to behave correctly for enterprise learners', function() { - var accountSettingsView, i, view, sectionsData, textFields, dropdownFields; - requests = AjaxHelpers.requests(this); - - accountSettingsView = createEnterpriseLearnerAccountSettingsPage(); - - AjaxHelpers.respondWithJson(requests, Helpers.createAccountSettingsData()); - AjaxHelpers.respondWithJson(requests, Helpers.TIME_ZONE_RESPONSE); - AjaxHelpers.respondWithJson(requests, Helpers.createUserPreferencesData()); - AjaxHelpers.respondWithJson(requests, {}); // Page viewed analytics event - - sectionsData = accountSettingsView.options.tabSections.aboutTabSections; - - expect(sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields.length).toBe(7); - - // Verify that username, name and email fields are readonly - textFields = [ - sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.USERNAME], - sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.FULL_NAME], - sectionsData[accountInfoTab.BASIC_ACCOUNT_INFORMATION].fields[basicAccountInfoFields.EMAIL_ADDRESS] - ]; - for (i = 0; i < textFields.length; i++) { - view = textFields[i].view; - - FieldViewsSpecHelpers.verifyReadonlyTextField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: view.options.helpMessage, - validValue: 'My Name', - defaultValue: '' - }, requests); - } - - // Verify un-editable country dropdown field - view = sectionsData[ - accountInfoTab.BASIC_ACCOUNT_INFORMATION - ].fields[basicAccountInfoFields.COUNTRY].view; - - FieldViewsSpecHelpers.verifyReadonlyDropDownField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: '', - validValue: Helpers.FIELD_OPTIONS[1][0], - editable: 'never', - defaultValue: null - }); - - expect(sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields.length).toBe(4); - dropdownFields = [ - sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.EDUCATION], - sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.GENDER], - sectionsData[accountInfoTab.ADDITIONAL_INFORMATION].fields[additionalInfoFields.YEAR_OF_BIRTH] - ]; - _.each(dropdownFields, function(field) { - view = field.view; - FieldViewsSpecHelpers.verifyDropDownField(view, { - title: view.options.title, - valueAttribute: view.options.valueAttribute, - helpMessage: '', - validValue: Helpers.FIELD_OPTIONS[1][0], // dummy option for dropdown field - invalidValue1: Helpers.FIELD_OPTIONS[2][0], // dummy option for dropdown field - invalidValue2: Helpers.FIELD_OPTIONS[3][0], // dummy option for dropdown field - validationError: 'Nope, this will not do!', - defaultValue: null - }, requests); - }); - }); - }); -}); diff --git a/lms/static/js/spec/student_account/account_settings_fields_helpers.js b/lms/static/js/spec/student_account/account_settings_fields_helpers.js deleted file mode 100644 index 4aea86b235a..00000000000 --- a/lms/static/js/spec/student_account/account_settings_fields_helpers.js +++ /dev/null @@ -1,34 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/views/fields_helpers', - 'string_utils'], -function(Backbone, $, _, AjaxHelpers, TemplateHelpers, FieldViewsSpecHelpers) { - 'use strict'; - - var verifyAuthField = function(view, data, requests) { - var selector = '.u-field-value .u-field-link-title-' + view.options.valueAttribute; - - spyOn(view, 'redirect_to'); - - FieldViewsSpecHelpers.expectTitleAndMessageToContain(view, data.title, data.helpMessage); - expect(view.$(selector).text().trim()).toBe('Unlink This Account'); - view.$(selector).click(); - FieldViewsSpecHelpers.expectMessageContains(view, 'Unlinking'); - AjaxHelpers.expectRequest(requests, 'POST', data.disconnectUrl); - AjaxHelpers.respondWithNoContent(requests); - - expect(view.$(selector).text().trim()).toBe('Link Your Account'); - FieldViewsSpecHelpers.expectMessageContains(view, 'Successfully unlinked.'); - - view.$(selector).click(); - FieldViewsSpecHelpers.expectMessageContains(view, 'Linking'); - expect(view.redirect_to).toHaveBeenCalledWith(data.connectUrl); - }; - - return { - verifyAuthField: verifyAuthField - }; -}); diff --git a/lms/static/js/spec/student_account/account_settings_fields_spec.js b/lms/static/js/spec/student_account/account_settings_fields_spec.js deleted file mode 100644 index 76ea7c512b7..00000000000 --- a/lms/static/js/spec/student_account/account_settings_fields_spec.js +++ /dev/null @@ -1,216 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/student_account/models/user_account_model', - 'js/views/fields', - 'js/spec/views/fields_helpers', - 'js/spec/student_account/account_settings_fields_helpers', - 'js/student_account/views/account_settings_fields', - 'js/student_account/models/user_account_model', - 'string_utils'], -function(Backbone, $, _, AjaxHelpers, TemplateHelpers, UserAccountModel, FieldViews, FieldViewsSpecHelpers, - AccountSettingsFieldViewSpecHelpers, AccountSettingsFieldViews) { - 'use strict'; - - describe('edx.AccountSettingsFieldViews', function() { - var requests, - timerCallback, // eslint-disable-line no-unused-vars - data; - - beforeEach(function() { - timerCallback = jasmine.createSpy('timerCallback'); - jasmine.clock().install(); - }); - - afterEach(function() { - jasmine.clock().uninstall(); - }); - - it('sends request to reset password on clicking link in PasswordFieldView', function() { - requests = AjaxHelpers.requests(this); - - var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.PasswordFieldView, { - linkHref: '/password_reset', - emailAttribute: 'email', - valueAttribute: 'password' - }); - - var view = new AccountSettingsFieldViews.PasswordFieldView(fieldData).render(); - expect(view.$('.u-field-value > button').is(':disabled')).toBe(false); - view.$('.u-field-value > button').click(); - expect(view.$('.u-field-value > button').is(':disabled')).toBe(true); - AjaxHelpers.expectRequest(requests, 'POST', '/password_reset', 'email=legolas%40woodland.middlearth'); - AjaxHelpers.respondWithJson(requests, {success: 'true'}); - FieldViewsSpecHelpers.expectMessageContains( - view, - "We've sent a message to legolas@woodland.middlearth. " - + 'Click the link in the message to reset your password.' - ); - }); - - it('update time zone dropdown after country dropdown changes', function() { - var baseSelector = '.u-field-value > select'; - var groupsSelector = baseSelector + '> optgroup'; - var groupOptionsSelector = groupsSelector + '> option'; - - var timeZoneData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.TimeZoneFieldView, { - valueAttribute: 'time_zone', - groupOptions: [{ - groupTitle: gettext('All Time Zones'), - selectOptions: FieldViewsSpecHelpers.SELECT_OPTIONS, - nullValueOptionLabel: 'Default (Local Time Zone)' - }], - persistChanges: true, - required: true - }); - var countryData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { - valueAttribute: 'country', - options: [['KY', 'Cayman Islands'], ['CA', 'Canada'], ['GY', 'Guyana']], - persistChanges: true - }); - - var countryChange = {country: 'GY'}; - var timeZoneChange = {time_zone: 'Pacific/Kosrae'}; - - var timeZoneView = new AccountSettingsFieldViews.TimeZoneFieldView(timeZoneData).render(); - var countryView = new AccountSettingsFieldViews.DropdownFieldView(countryData).render(); - - requests = AjaxHelpers.requests(this); - - timeZoneView.listenToCountryView(countryView); - - // expect time zone dropdown to have single subheader ('All Time Zones') - expect(timeZoneView.$(groupsSelector).length).toBe(1); - expect(timeZoneView.$(groupOptionsSelector).length).toBe(3); - expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]); - - // change country - countryView.$(baseSelector).val(countryChange[countryData.valueAttribute]).change(); - countryView.$(baseSelector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, countryChange); - AjaxHelpers.respondWithJson(requests, {success: 'true'}); - - AjaxHelpers.expectRequest( - requests, - 'GET', - '/api/user/v1/preferences/time_zones/?country_code=GY' - ); - AjaxHelpers.respondWithJson(requests, [ - {time_zone: 'America/Guyana', description: 'America/Guyana (ECT, UTC-0500)'}, - {time_zone: 'Pacific/Kosrae', description: 'Pacific/Kosrae (KOST, UTC+1100)'} - ]); - - // expect time zone dropdown to have two subheaders (country/all time zone sub-headers) with new values - expect(timeZoneView.$(groupsSelector).length).toBe(2); - expect(timeZoneView.$(groupOptionsSelector).length).toBe(6); - expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe('America/Guyana'); - - // select time zone option from option - timeZoneView.$(baseSelector).val(timeZoneChange[timeZoneData.valueAttribute]).change(); - timeZoneView.$(baseSelector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, timeZoneChange); - AjaxHelpers.respondWithJson(requests, {success: 'true'}); - timeZoneView.render(); - - // expect time zone dropdown to have three subheaders (currently selected/country/all time zones) - expect(timeZoneView.$(groupsSelector).length).toBe(3); - expect(timeZoneView.$(groupOptionsSelector).length).toBe(6); - expect(timeZoneView.$(groupOptionsSelector)[0].value).toBe('Pacific/Kosrae'); - }); - - it('sends request to /i18n/setlang/ after changing language in LanguagePreferenceFieldView', function() { - requests = AjaxHelpers.requests(this); - - var selector = '.u-field-value > select'; - var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { - valueAttribute: 'language', - options: FieldViewsSpecHelpers.SELECT_OPTIONS, - persistChanges: true - }); - - var view = new AccountSettingsFieldViews.LanguagePreferenceFieldView(fieldData).render(); - - data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[2][0]}; - view.$(selector).val(data[fieldData.valueAttribute]).change(); - view.$(selector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); - AjaxHelpers.respondWithNoContent(requests); - - AjaxHelpers.expectRequest( - requests, - 'POST', - '/i18n/setlang/', - $.param({ - language: data[fieldData.valueAttribute], - next: window.location.href - }) - ); - // Django will actually respond with a 302 redirect, but that would cause a page load during these - // unittests. 204 should work fine for testing. - AjaxHelpers.respondWithNoContent(requests); - FieldViewsSpecHelpers.expectMessageContains(view, 'Your changes have been saved.'); - - data = {language: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}; - view.$(selector).val(data[fieldData.valueAttribute]).change(); - view.$(selector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); - AjaxHelpers.respondWithNoContent(requests); - - AjaxHelpers.expectRequest( - requests, - 'POST', - '/i18n/setlang/', - $.param({ - language: data[fieldData.valueAttribute], - next: window.location.href - }) - ); - AjaxHelpers.respondWithError(requests, 500); - FieldViewsSpecHelpers.expectMessageContains( - view, - 'You must sign out and sign back in before your language changes take effect.' - ); - }); - - it('reads and saves the value correctly for LanguageProficienciesFieldView', function() { - requests = AjaxHelpers.requests(this); - - var selector = '.u-field-value > select'; - var fieldData = FieldViewsSpecHelpers.createFieldData(AccountSettingsFieldViews.DropdownFieldView, { - valueAttribute: 'language_proficiencies', - options: FieldViewsSpecHelpers.SELECT_OPTIONS, - persistChanges: true - }); - fieldData.model.set({language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]}]}); - - var view = new AccountSettingsFieldViews.LanguageProficienciesFieldView(fieldData).render(); - - expect(view.modelValue()).toBe(FieldViewsSpecHelpers.SELECT_OPTIONS[0][0]); - - data = {language_proficiencies: [{code: FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]}]}; - view.$(selector).val(FieldViewsSpecHelpers.SELECT_OPTIONS[1][0]).change(); - view.$(selector).focusout(); - FieldViewsSpecHelpers.expectAjaxRequestWithData(requests, data); - AjaxHelpers.respondWithNoContent(requests); - }); - - it('correctly links and unlinks from AuthFieldView', function() { - requests = AjaxHelpers.requests(this); - - var fieldData = FieldViewsSpecHelpers.createFieldData(FieldViews.LinkFieldView, { - title: 'Yet another social network', - helpMessage: '', - valueAttribute: 'auth-yet-another', - connected: true, - acceptsLogins: 'true', - connectUrl: 'yetanother.com/auth/connect', - disconnectUrl: 'yetanother.com/auth/disconnect' - }); - var view = new AccountSettingsFieldViews.AuthFieldView(fieldData).render(); - - AccountSettingsFieldViewSpecHelpers.verifyAuthField(view, fieldData, requests); - }); - }); -}); diff --git a/lms/static/js/spec/student_account/account_settings_view_spec.js b/lms/static/js/spec/student_account/account_settings_view_spec.js deleted file mode 100644 index c0c213cf3c5..00000000000 --- a/lms/static/js/spec/student_account/account_settings_view_spec.js +++ /dev/null @@ -1,91 +0,0 @@ -define(['backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'js/views/fields', - 'js/student_account/models/user_account_model', - 'js/student_account/views/account_settings_view' -], -function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, FieldViews, UserAccountModel, - AccountSettingsView) { - 'use strict'; - - describe('edx.user.AccountSettingsView', function() { - var createAccountSettingsView = function() { - var model = new UserAccountModel(); - model.set(Helpers.createAccountSettingsData()); - - var aboutSectionsData = [ - { - title: 'Basic Account Information', - messageType: 'info', - message: 'Your profile settings are managed by Test Enterprise. ' - + 'Contact your administrator or edX Support for help.', - fields: [ - { - view: new FieldViews.ReadonlyFieldView({ - model: model, - title: 'Username', - valueAttribute: 'username' - }) - }, - { - view: new FieldViews.TextFieldView({ - model: model, - title: 'Full Name', - valueAttribute: 'name' - }) - } - ] - }, - { - title: 'Additional Information', - fields: [ - { - view: new FieldViews.DropdownFieldView({ - model: model, - title: 'Education Completed', - valueAttribute: 'level_of_education', - options: Helpers.FIELD_OPTIONS - }) - } - ] - } - ]; - - var accountSettingsView = new AccountSettingsView({ - el: $('.wrapper-account-settings'), - model: model, - tabSections: { - aboutTabSections: aboutSectionsData - } - }); - - return accountSettingsView; - }; - - beforeEach(function() { - setFixtures(''); - }); - - it('shows loading error correctly', function() { - var accountSettingsView = createAccountSettingsView(); - - accountSettingsView.render(); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - - accountSettingsView.showLoadingError(); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, true); - }); - - it('renders all fields as expected', function() { - var accountSettingsView = createAccountSettingsView(); - - accountSettingsView.render(); - Helpers.expectLoadingErrorIsVisible(accountSettingsView, false); - Helpers.expectSettingsSectionsAndFieldsToBeRendered(accountSettingsView); - }); - }); -}); diff --git a/lms/static/js/student_account/views/account_section_view.js b/lms/static/js/student_account/views/account_section_view.js deleted file mode 100644 index 70cd217477a..00000000000 --- a/lms/static/js/student_account/views/account_section_view.js +++ /dev/null @@ -1,48 +0,0 @@ -// eslint-disable-next-line no-shadow-restricted-names -(function(define, undefined) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'backbone', - 'edx-ui-toolkit/js/utils/html-utils', - 'text!templates/student_account/account_settings_section.underscore' - ], function(gettext, $, _, Backbone, HtmlUtils, sectionTemplate) { - var AccountSectionView = Backbone.View.extend({ - - initialize: function(options) { - this.options = options; - _.bindAll(this, 'render', 'renderFields'); - }, - - render: function() { - HtmlUtils.setHtml( - this.$el, - HtmlUtils.template(sectionTemplate)({ - HtmlUtils: HtmlUtils, - sections: this.options.sections, - tabName: this.options.tabName, - tabLabel: this.options.tabLabel - }) - ); - - this.renderFields(); - }, - - renderFields: function() { - var view = this; - - _.each(view.$('.' + view.options.tabName + '-section-body'), function(sectionEl, index) { - _.each(view.options.sections[index].fields, function(field) { - $(sectionEl).append(field.view.render().el); - }); - }); - return this; - } - }); - - return AccountSectionView; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_factory.js b/lms/static/js/student_account/views/account_settings_factory.js deleted file mode 100644 index 70d3ad205c1..00000000000 --- a/lms/static/js/student_account/views/account_settings_factory.js +++ /dev/null @@ -1,495 +0,0 @@ -// eslint-disable-next-line no-shadow-restricted-names -(function(define, undefined) { - 'use strict'; - - define([ - 'gettext', 'jquery', 'underscore', 'backbone', 'logger', - 'js/student_account/models/user_account_model', - 'js/student_account/models/user_preferences_model', - 'js/student_account/views/account_settings_fields', - 'js/student_account/views/account_settings_view', - 'edx-ui-toolkit/js/utils/string-utils', - 'edx-ui-toolkit/js/utils/html-utils' - ], function(gettext, $, _, Backbone, Logger, UserAccountModel, UserPreferencesModel, - AccountSettingsFieldViews, AccountSettingsView, StringUtils, HtmlUtils) { - return function( - fieldsData, - disableOrderHistoryTab, - ordersHistoryData, - authData, - passwordResetSupportUrl, - userAccountsApiUrl, - userPreferencesApiUrl, - accountUserId, - platformName, - contactEmail, - allowEmailChange, - enableCoppaCompliance, - socialPlatforms, - syncLearnerProfileData, - enterpriseName, - enterpriseReadonlyAccountFields, - edxSupportUrl, - extendedProfileFields, - displayAccountDeletion, - isSecondaryEmailFeatureEnabled, - betaLanguage - ) { - var $accountSettingsElement, userAccountModel, userPreferencesModel, aboutSectionsData, - accountsSectionData, ordersSectionData, accountSettingsView, showAccountSettingsPage, - showLoadingError, orderNumber, getUserField, userFields, timeZoneDropdownField, countryDropdownField, - emailFieldView, secondaryEmailFieldView, socialFields, accountDeletionFields, platformData, - aboutSectionMessageType, aboutSectionMessage, fullnameFieldView, countryFieldView, - fullNameFieldData, emailFieldData, secondaryEmailFieldData, countryFieldData, additionalFields, - fieldItem, emailFieldViewIndex, focusId, yearOfBirthViewIndex, levelOfEducationFieldData, - tabIndex = 0; - - $accountSettingsElement = $('.wrapper-account-settings'); - - userAccountModel = new UserAccountModel(); - userAccountModel.url = userAccountsApiUrl; - - userPreferencesModel = new UserPreferencesModel(); - userPreferencesModel.url = userPreferencesApiUrl; - - if (syncLearnerProfileData && enterpriseName) { - aboutSectionMessageType = 'info'; - aboutSectionMessage = HtmlUtils.interpolateHtml( - gettext('Your profile settings are managed by {enterprise_name}. Contact your administrator or {link_start}edX Support{link_end} for help.'), // eslint-disable-line max-len - { - enterprise_name: enterpriseName, - link_start: HtmlUtils.HTML( - StringUtils.interpolate( - '', { - edx_support_url: edxSupportUrl - } - ) - ), - link_end: HtmlUtils.HTML('') - } - ); - } - - emailFieldData = { - model: userAccountModel, - title: gettext('Email Address (Sign In)'), - valueAttribute: 'email', - helpMessage: StringUtils.interpolate( - gettext('You receive messages from {platform_name} and course teams at this address.'), // eslint-disable-line max-len - {platform_name: platformName} - ), - persistChanges: true - }; - if (!allowEmailChange || (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('email') !== -1)) { // eslint-disable-line max-len - emailFieldView = { - view: new AccountSettingsFieldViews.ReadonlyFieldView(emailFieldData) - }; - } else { - emailFieldView = { - view: new AccountSettingsFieldViews.EmailFieldView(emailFieldData) - }; - } - - secondaryEmailFieldData = { - model: userAccountModel, - title: gettext('Recovery Email Address'), - valueAttribute: 'secondary_email', - helpMessage: gettext('You may access your account with this address if single-sign on or access to your primary email is not available.'), // eslint-disable-line max-len - persistChanges: true - }; - - fullNameFieldData = { - model: userAccountModel, - title: gettext('Full Name'), - valueAttribute: 'name', - helpMessage: gettext('The name that is used for ID verification and that appears on your certificates.'), // eslint-disable-line max-len, - persistChanges: true - }; - if (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('name') !== -1) { - fullnameFieldView = { - view: new AccountSettingsFieldViews.ReadonlyFieldView(fullNameFieldData) - }; - } else { - fullnameFieldView = { - view: new AccountSettingsFieldViews.TextFieldView(fullNameFieldData) - }; - } - - countryFieldData = { - model: userAccountModel, - required: true, - title: gettext('Country or Region of Residence'), - valueAttribute: 'country', - options: fieldsData.country.options, - persistChanges: true, - helpMessage: gettext('The country or region where you live.') - }; - if (syncLearnerProfileData && enterpriseReadonlyAccountFields.fields.indexOf('country') !== -1) { - countryFieldData.editable = 'never'; - countryFieldView = { - view: new AccountSettingsFieldViews.DropdownFieldView( - countryFieldData - ) - }; - } else { - countryFieldView = { - view: new AccountSettingsFieldViews.DropdownFieldView(countryFieldData) - }; - } - - levelOfEducationFieldData = fieldsData.level_of_education.options; - if (enableCoppaCompliance) { - levelOfEducationFieldData = levelOfEducationFieldData.filter(option => option[0] !== 'el'); - } - - aboutSectionsData = [ - { - title: gettext('Basic Account Information'), - subtitle: gettext('These settings include basic information about your account.'), - - messageType: aboutSectionMessageType, - message: aboutSectionMessage, - - fields: [ - { - view: new AccountSettingsFieldViews.ReadonlyFieldView({ - model: userAccountModel, - title: gettext('Username'), - valueAttribute: 'username', - helpMessage: StringUtils.interpolate( - gettext('The name that identifies you on {platform_name}. You cannot change your username.'), // eslint-disable-line max-len - {platform_name: platformName} - ) - }) - }, - fullnameFieldView, - emailFieldView, - { - view: new AccountSettingsFieldViews.PasswordFieldView({ - model: userAccountModel, - title: gettext('Password'), - screenReaderTitle: gettext('Reset Your Password'), - valueAttribute: 'password', - emailAttribute: 'email', - passwordResetSupportUrl: passwordResetSupportUrl, - linkTitle: gettext('Reset Your Password'), - linkHref: fieldsData.password.url, - helpMessage: gettext('Check your email account for instructions to reset your password.') // eslint-disable-line max-len - }) - }, - { - view: new AccountSettingsFieldViews.LanguagePreferenceFieldView({ - model: userPreferencesModel, - title: gettext('Language'), - valueAttribute: 'pref-lang', - required: true, - refreshPageOnSave: true, - helpMessage: StringUtils.interpolate( - gettext('The language used throughout this site. This site is currently available in a limited number of languages. Changing the value of this field will cause the page to refresh.'), // eslint-disable-line max-len - {platform_name: platformName} - ), - options: fieldsData.language.options, - persistChanges: true, - focusNextID: '#u-field-select-country' - }) - }, - countryFieldView, - { - view: new AccountSettingsFieldViews.TimeZoneFieldView({ - model: userPreferencesModel, - required: true, - title: gettext('Time Zone'), - valueAttribute: 'time_zone', - helpMessage: gettext('Select the time zone for displaying course dates. If you do not specify a time zone, course dates, including assignment deadlines, will be displayed in your browser\'s local time zone.'), // eslint-disable-line max-len - groupOptions: [{ - groupTitle: gettext('All Time Zones'), - selectOptions: fieldsData.time_zone.options, - nullValueOptionLabel: gettext('Default (Local Time Zone)') - }], - persistChanges: true - }) - } - ] - }, - { - title: gettext('Additional Information'), - fields: [ - { - view: new AccountSettingsFieldViews.DropdownFieldView({ - model: userAccountModel, - title: gettext('Education Completed'), - valueAttribute: 'level_of_education', - options: levelOfEducationFieldData, - persistChanges: true - }) - }, - { - view: new AccountSettingsFieldViews.DropdownFieldView({ - model: userAccountModel, - title: gettext('Gender'), - valueAttribute: 'gender', - options: fieldsData.gender.options, - persistChanges: true - }) - }, - { - view: new AccountSettingsFieldViews.DropdownFieldView({ - model: userAccountModel, - title: gettext('Year of Birth'), - valueAttribute: 'year_of_birth', - options: fieldsData.year_of_birth.options, - persistChanges: true - }) - }, - { - view: new AccountSettingsFieldViews.LanguageProficienciesFieldView({ - model: userAccountModel, - title: gettext('Preferred Language'), - valueAttribute: 'language_proficiencies', - options: fieldsData.preferred_language.options, - persistChanges: true - }) - } - ] - } - ]; - - if (enableCoppaCompliance) { - yearOfBirthViewIndex = aboutSectionsData[1].fields.findIndex(function(field) { - return field.view.options.valueAttribute === 'year_of_birth'; - }); - aboutSectionsData[1].fields.splice(yearOfBirthViewIndex, 1); - } - - // Secondary email address - if (isSecondaryEmailFeatureEnabled) { - secondaryEmailFieldView = { - view: new AccountSettingsFieldViews.EmailFieldView(secondaryEmailFieldData), - successMessage: function() { - return HtmlUtils.joinHtml( - this.indicators.success, - StringUtils.interpolate( - gettext('We\'ve sent a confirmation message to {new_secondary_email_address}. Click the link in the message to update your secondary email address.'), // eslint-disable-line max-len - { - new_secondary_email_address: this.fieldValue() - } - ) - ); - } - }; - emailFieldViewIndex = aboutSectionsData[0].fields.indexOf(emailFieldView); - - // Insert secondary email address after email address field. - aboutSectionsData[0].fields.splice( - emailFieldViewIndex + 1, 0, secondaryEmailFieldView - ); - } - - // Add the extended profile fields - additionalFields = aboutSectionsData[1]; - for (var field in extendedProfileFields) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len - fieldItem = extendedProfileFields[field]; - if (fieldItem.field_type === 'TextField') { - additionalFields.fields.push({ - view: new AccountSettingsFieldViews.ExtendedFieldTextFieldView({ - model: userAccountModel, - title: fieldItem.field_label, - fieldName: fieldItem.field_name, - valueAttribute: 'extended_profile', - persistChanges: true - }) - }); - } else { - if (fieldItem.field_type === 'ListField') { - additionalFields.fields.push({ - view: new AccountSettingsFieldViews.ExtendedFieldListFieldView({ - model: userAccountModel, - title: fieldItem.field_label, - fieldName: fieldItem.field_name, - options: fieldItem.field_options, - valueAttribute: 'extended_profile', - persistChanges: true - }) - }); - } - } - } - - // Add the social link fields - socialFields = { - title: gettext('Social Media Links'), - subtitle: gettext('Optionally, link your personal accounts to the social media icons on your edX profile.'), // eslint-disable-line max-len - fields: [] - }; - - for (var socialPlatform in socialPlatforms) { // eslint-disable-line guard-for-in, no-restricted-syntax, vars-on-top, max-len - platformData = socialPlatforms[socialPlatform]; - socialFields.fields.push( - { - view: new AccountSettingsFieldViews.SocialLinkTextFieldView({ - model: userAccountModel, - title: StringUtils.interpolate( - gettext('{platform_display_name} Link'), - {platform_display_name: platformData.display_name} - ), - valueAttribute: 'social_links', - helpMessage: StringUtils.interpolate( - gettext('Enter your {platform_display_name} username or the URL to your {platform_display_name} page. Delete the URL to remove the link.'), // eslint-disable-line max-len - {platform_display_name: platformData.display_name} - ), - platform: socialPlatform, - persistChanges: true, - placeholder: platformData.example - }) - } - ); - } - aboutSectionsData.push(socialFields); - - // Add account deletion fields - if (displayAccountDeletion) { - accountDeletionFields = { - title: gettext('Delete My Account'), - fields: [], - // Used so content can be rendered external to Backbone - domHookId: 'account-deletion-container' - }; - aboutSectionsData.push(accountDeletionFields); - } - - // set TimeZoneField to listen to CountryField - - getUserField = function(list, search) { - // eslint-disable-next-line no-shadow - return _.find(list, function(field) { - return field.view.options.valueAttribute === search; - }).view; - }; - userFields = _.find(aboutSectionsData, function(section) { - return section.title === gettext('Basic Account Information'); - }).fields; - timeZoneDropdownField = getUserField(userFields, 'time_zone'); - countryDropdownField = getUserField(userFields, 'country'); - timeZoneDropdownField.listenToCountryView(countryDropdownField); - - accountsSectionData = [ - { - title: gettext('Linked Accounts'), - subtitle: StringUtils.interpolate( - gettext('You can link your social media accounts to simplify signing in to {platform_name}.'), - {platform_name: platformName} - ), - fields: _.map(authData.providers, function(provider) { - return { - view: new AccountSettingsFieldViews.AuthFieldView({ - title: provider.name, - valueAttribute: 'auth-' + provider.id, - helpMessage: '', - connected: provider.connected, - connectUrl: provider.connect_url, - acceptsLogins: provider.accepts_logins, - disconnectUrl: provider.disconnect_url, - platformName: platformName - }) - }; - }) - } - ]; - - ordersHistoryData.unshift( - { - title: gettext('ORDER NAME'), - order_date: gettext('ORDER PLACED'), - price: gettext('TOTAL'), - number: gettext('ORDER NUMBER') - } - ); - - ordersSectionData = [ - { - title: gettext('My Orders'), - subtitle: StringUtils.interpolate( - gettext('This page contains information about orders that you have placed with {platform_name}.'), // eslint-disable-line max-len - {platform_name: platformName} - ), - fields: _.map(ordersHistoryData, function(order) { - orderNumber = order.number; - if (orderNumber === 'ORDER NUMBER') { - orderNumber = 'orderId'; - } - return { - view: new AccountSettingsFieldViews.OrderHistoryFieldView({ - totalPrice: order.price, - orderId: order.number, - orderDate: order.order_date, - receiptUrl: order.receipt_url, - valueAttribute: 'order-' + orderNumber, - lines: order.lines - }) - }; - }) - } - ]; - - accountSettingsView = new AccountSettingsView({ - model: userAccountModel, - accountUserId: accountUserId, - el: $accountSettingsElement, - tabSections: { - aboutTabSections: aboutSectionsData, - accountsTabSections: accountsSectionData, - ordersTabSections: ordersSectionData - }, - userPreferencesModel: userPreferencesModel, - disableOrderHistoryTab: disableOrderHistoryTab, - betaLanguage: betaLanguage - }); - - accountSettingsView.render(); - focusId = $.cookie('focus_id'); - if (focusId) { - // eslint-disable-next-line no-bitwise - if (~focusId.indexOf('beta-language')) { - tabIndex = -1; - - // Scroll to top of selected element - $('html, body').animate({ - scrollTop: $(focusId).offset().top - }, 'slow'); - } - $(focusId).attr({tabindex: tabIndex}).focus(); - // Deleting the cookie - document.cookie = 'focus_id=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/account;'; - } - showAccountSettingsPage = function() { - // Record that the account settings page was viewed. - Logger.log('edx.user.settings.viewed', { - page: 'account', - visibility: null, - user_id: accountUserId - }); - }; - - showLoadingError = function() { - accountSettingsView.showLoadingError(); - }; - - userAccountModel.fetch({ - success: function() { - // Fetch the user preferences model - userPreferencesModel.fetch({ - success: showAccountSettingsPage, - error: showLoadingError - }); - }, - error: showLoadingError - }); - - return { - userAccountModel: userAccountModel, - userPreferencesModel: userPreferencesModel, - accountSettingsView: accountSettingsView - }; - }; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_fields.js b/lms/static/js/student_account/views/account_settings_fields.js deleted file mode 100644 index 1fc174f9358..00000000000 --- a/lms/static/js/student_account/views/account_settings_fields.js +++ /dev/null @@ -1,466 +0,0 @@ -// eslint-disable-next-line no-shadow-restricted-names -(function(define, undefined) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'backbone', - 'js/views/fields', - 'text!templates/fields/field_text_account.underscore', - 'text!templates/fields/field_readonly_account.underscore', - 'text!templates/fields/field_link_account.underscore', - 'text!templates/fields/field_dropdown_account.underscore', - 'text!templates/fields/field_social_link_account.underscore', - 'text!templates/fields/field_order_history.underscore', - 'edx-ui-toolkit/js/utils/string-utils', - 'edx-ui-toolkit/js/utils/html-utils' - ], function( - gettext, $, _, Backbone, - FieldViews, - field_text_account_template, - field_readonly_account_template, - field_link_account_template, - field_dropdown_account_template, - field_social_link_template, - field_order_history_template, - StringUtils, - HtmlUtils - ) { - var AccountSettingsFieldViews = { - ReadonlyFieldView: FieldViews.ReadonlyFieldView.extend({ - fieldTemplate: field_readonly_account_template - }), - TextFieldView: FieldViews.TextFieldView.extend({ - fieldTemplate: field_text_account_template - }), - DropdownFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template - }), - EmailFieldView: FieldViews.TextFieldView.extend({ - fieldTemplate: field_text_account_template, - successMessage: function() { - return HtmlUtils.joinHtml( - this.indicators.success, - StringUtils.interpolate( - gettext('We\'ve sent a confirmation message to {new_email_address}. Click the link in the message to update your email address.'), // eslint-disable-line max-len - {new_email_address: this.fieldValue()} - ) - ); - } - }), - LanguagePreferenceFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template, - - initialize: function(options) { - this._super(options); // eslint-disable-line no-underscore-dangle - this.listenTo(this.model, 'revertValue', this.revertValue); - }, - - revertValue: function(event) { - var attributes = {}, - oldPrefLang = $(event.target).data('old-lang-code'); - - if (oldPrefLang) { - attributes['pref-lang'] = oldPrefLang; - this.saveAttributes(attributes); - } - }, - - saveSucceeded: function() { - var data = { - language: this.modelValue(), - next: window.location.href - }; - - var view = this; - $.ajax({ - type: 'POST', - url: '/i18n/setlang/', - data: data, - dataType: 'html', - success: function() { - view.showSuccessMessage(); - }, - error: function() { - view.showNotificationMessage( - HtmlUtils.joinHtml( - view.indicators.error, - gettext('You must sign out and sign back in before your language changes take effect.') // eslint-disable-line max-len - ) - ); - } - }); - } - - }), - TimeZoneFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template, - - initialize: function(options) { - this.options = _.extend({}, options); - _.bindAll(this, 'listenToCountryView', 'updateCountrySubheader', 'replaceOrAddGroupOption'); - this._super(options); // eslint-disable-line no-underscore-dangle - }, - - listenToCountryView: function(view) { - this.listenTo(view.model, 'change:country', this.updateCountrySubheader); - }, - - updateCountrySubheader: function(user) { - var view = this; - $.ajax({ - type: 'GET', - url: '/api/user/v1/preferences/time_zones/', - data: {country_code: user.attributes.country}, - success: function(data) { - var countryTimeZones = $.map(data, function(timeZoneInfo) { - return [[timeZoneInfo.time_zone, timeZoneInfo.description]]; - }); - view.replaceOrAddGroupOption( - 'Country Time Zones', - countryTimeZones - ); - view.render(); - } - }); - }, - - updateValueInField: function() { - var options; - if (this.modelValue()) { - options = [[this.modelValue(), this.displayValue(this.modelValue())]]; - this.replaceOrAddGroupOption( - 'Currently Selected Time Zone', - options - ); - } - this._super(); // eslint-disable-line no-underscore-dangle - }, - - replaceOrAddGroupOption: function(title, options) { - var groupOption = { - groupTitle: gettext(title), - selectOptions: options - }; - - var index = _.findIndex(this.options.groupOptions, function(group) { - return group.groupTitle === gettext(title); - }); - if (index >= 0) { - this.options.groupOptions[index] = groupOption; - } else { - this.options.groupOptions.unshift(groupOption); - } - } - - }), - PasswordFieldView: FieldViews.LinkFieldView.extend({ - fieldType: 'button', - fieldTemplate: field_link_account_template, - events: { - 'click button': 'linkClicked' - }, - initialize: function(options) { - this.options = _.extend({}, options); - this._super(options); - _.bindAll(this, 'resetPassword'); - }, - linkClicked: function(event) { - event.preventDefault(); - this.toggleDisableButton(true); - this.resetPassword(event); - }, - resetPassword: function() { - var data = {}; - data[this.options.emailAttribute] = this.model.get(this.options.emailAttribute); - - var view = this; - $.ajax({ - type: 'POST', - url: view.options.linkHref, - data: data, - success: function() { - view.showSuccessMessage(); - view.setMessageTimeout(); - }, - error: function(xhr) { - view.showErrorMessage(xhr); - view.setMessageTimeout(); - view.toggleDisableButton(false); - } - }); - }, - toggleDisableButton: function(disabled) { - var button = this.$('#u-field-link-' + this.options.valueAttribute); - if (button) { - button.prop('disabled', disabled); - } - }, - setMessageTimeout: function() { - var view = this; - setTimeout(function() { - view.showHelpMessage(); - }, 6000); - }, - successMessage: function() { - return HtmlUtils.joinHtml( - this.indicators.success, - HtmlUtils.interpolateHtml( - gettext('We\'ve sent a message to {email}. Click the link in the message to reset your password. Didn\'t receive the message? Contact {anchorStart}technical support{anchorEnd}.'), // eslint-disable-line max-len - { - email: this.model.get(this.options.emailAttribute), - anchorStart: HtmlUtils.HTML( - StringUtils.interpolate( - '', { - passwordResetSupportUrl: this.options.passwordResetSupportUrl - } - ) - ), - anchorEnd: HtmlUtils.HTML('') - } - ) - ); - } - }), - LanguageProficienciesFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template, - modelValue: function() { - var modelValue = this.model.get(this.options.valueAttribute); - if (_.isArray(modelValue) && modelValue.length > 0) { - return modelValue[0].code; - } else { - return null; - } - }, - saveValue: function() { - var attributes = {}, - value = ''; - if (this.persistChanges === true) { - value = this.fieldValue() ? [{code: this.fieldValue()}] : []; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } - }), - SocialLinkTextFieldView: FieldViews.TextFieldView.extend({ - render: function() { - HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_text_account_template)({ - id: this.options.valueAttribute + '_' + this.options.platform, - title: this.options.title, - value: this.modelValue(), - message: this.options.helpMessage, - placeholder: this.options.placeholder || '' - })); - this.delegateEvents(); - return this; - }, - - modelValue: function() { - var socialLinks = this.model.get(this.options.valueAttribute); - for (var i = 0; i < socialLinks.length; i++) { // eslint-disable-line vars-on-top - if (socialLinks[i].platform === this.options.platform) { - return socialLinks[i].social_link; - } - } - return null; - }, - saveValue: function() { - var attributes, value; - if (this.persistChanges === true) { - attributes = {}; - value = this.fieldValue() != null ? [{ - platform: this.options.platform, - social_link: this.fieldValue() - }] : []; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } - }), - ExtendedFieldTextFieldView: FieldViews.TextFieldView.extend({ - render: function() { - HtmlUtils.setHtml(this.$el, HtmlUtils.template(field_text_account_template)({ - id: this.options.valueAttribute + '_' + this.options.field_name, - title: this.options.title, - value: this.modelValue(), - message: this.options.helpMessage, - placeholder: this.options.placeholder || '' - })); - this.delegateEvents(); - return this; - }, - - modelValue: function() { - var extendedProfileFields = this.model.get(this.options.valueAttribute); - for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top - if (extendedProfileFields[i].field_name === this.options.fieldName) { - return extendedProfileFields[i].field_value; - } - } - return null; - }, - saveValue: function() { - var attributes, value; - if (this.persistChanges === true) { - attributes = {}; - value = this.fieldValue() != null ? [{ - field_name: this.options.fieldName, - field_value: this.fieldValue() - }] : []; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } - }), - ExtendedFieldListFieldView: FieldViews.DropdownFieldView.extend({ - fieldTemplate: field_dropdown_account_template, - modelValue: function() { - var extendedProfileFields = this.model.get(this.options.valueAttribute); - for (var i = 0; i < extendedProfileFields.length; i++) { // eslint-disable-line vars-on-top - if (extendedProfileFields[i].field_name === this.options.fieldName) { - return extendedProfileFields[i].field_value; - } - } - return null; - }, - saveValue: function() { - var attributes = {}, - value; - if (this.persistChanges === true) { - value = this.fieldValue() ? [{ - field_name: this.options.fieldName, - field_value: this.fieldValue() - }] : []; - attributes[this.options.valueAttribute] = value; - this.saveAttributes(attributes); - } - } - }), - AuthFieldView: FieldViews.LinkFieldView.extend({ - fieldTemplate: field_social_link_template, - className: function() { - return 'u-field u-field-social u-field-' + this.options.valueAttribute; - }, - initialize: function(options) { - this.options = _.extend({}, options); - this._super(options); - _.bindAll(this, 'redirect_to', 'disconnect', 'successMessage', 'inProgressMessage'); - }, - render: function() { - var linkTitle = '', - linkClass = '', - subTitle = '', - screenReaderTitle = StringUtils.interpolate( - gettext('Link your {accountName} account'), - {accountName: this.options.title} - ); - if (this.options.connected) { - linkTitle = gettext('Unlink This Account'); - linkClass = 'social-field-linked'; - subTitle = StringUtils.interpolate( - gettext('You can use your {accountName} account to sign in to your {platformName} account.'), // eslint-disable-line max-len - {accountName: this.options.title, platformName: this.options.platformName} - ); - screenReaderTitle = StringUtils.interpolate( - gettext('Unlink your {accountName} account'), - {accountName: this.options.title} - ); - } else if (this.options.acceptsLogins) { - linkTitle = gettext('Link Your Account'); - linkClass = 'social-field-unlinked'; - subTitle = StringUtils.interpolate( - gettext('Link your {accountName} account to your {platformName} account and use {accountName} to sign in to {platformName}.'), // eslint-disable-line max-len - {accountName: this.options.title, platformName: this.options.platformName} - ); - } - - HtmlUtils.setHtml(this.$el, HtmlUtils.template(this.fieldTemplate)({ - id: this.options.valueAttribute, - title: this.options.title, - screenReaderTitle: screenReaderTitle, - linkTitle: linkTitle, - subTitle: subTitle, - linkClass: linkClass, - linkHref: '#', - message: this.helpMessage - })); - this.delegateEvents(); - return this; - }, - linkClicked: function(event) { - event.preventDefault(); - - this.showInProgressMessage(); - - if (this.options.connected) { - this.disconnect(); - } else { - // Direct the user to the providers site to start the authentication process. - // See python-social-auth docs for more information. - this.redirect_to(this.options.connectUrl); - } - }, - redirect_to: function(url) { - window.location.href = url; - }, - disconnect: function() { - var data = {}; - - // Disconnects the provider from the user's edX account. - // See python-social-auth docs for more information. - var view = this; - $.ajax({ - type: 'POST', - url: this.options.disconnectUrl, - data: data, - dataType: 'html', - success: function() { - view.options.connected = false; - view.render(); - view.showSuccessMessage(); - }, - error: function(xhr) { - view.showErrorMessage(xhr); - } - }); - }, - inProgressMessage: function() { - return HtmlUtils.joinHtml(this.indicators.inProgress, ( - this.options.connected ? gettext('Unlinking') : gettext('Linking') - )); - }, - successMessage: function() { - return HtmlUtils.joinHtml(this.indicators.success, gettext('Successfully unlinked.')); - } - }), - - OrderHistoryFieldView: FieldViews.ReadonlyFieldView.extend({ - fieldType: 'orderHistory', - fieldTemplate: field_order_history_template, - - initialize: function(options) { - this.options = options; - this._super(options); - this.template = HtmlUtils.template(this.fieldTemplate); - }, - - render: function() { - HtmlUtils.setHtml(this.$el, this.template({ - totalPrice: this.options.totalPrice, - orderId: this.options.orderId, - orderDate: this.options.orderDate, - receiptUrl: this.options.receiptUrl, - valueAttribute: this.options.valueAttribute, - lines: this.options.lines - })); - this.delegateEvents(); - return this; - } - }) - }; - - return AccountSettingsFieldViews; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/js/student_account/views/account_settings_view.js b/lms/static/js/student_account/views/account_settings_view.js deleted file mode 100644 index 6ee9c9101d6..00000000000 --- a/lms/static/js/student_account/views/account_settings_view.js +++ /dev/null @@ -1,157 +0,0 @@ -// eslint-disable-next-line no-shadow-restricted-names -(function(define, undefined) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'common/js/components/views/tabbed_view', - 'edx-ui-toolkit/js/utils/html-utils', - 'js/student_account/views/account_section_view', - 'text!templates/student_account/account_settings.underscore' - ], function(gettext, $, _, TabbedView, HtmlUtils, AccountSectionView, accountSettingsTemplate) { - var AccountSettingsView = TabbedView.extend({ - - navLink: '.account-nav-link', - activeTab: 'aboutTabSections', - events: { - 'click .account-nav-link': 'switchTab', - 'keydown .account-nav-link': 'keydownHandler', - 'click .btn-alert-primary': 'revertValue' - }, - - initialize: function(options) { - this.options = options; - _.bindAll(this, 'render', 'switchTab', 'setActiveTab', 'showLoadingError'); - }, - - render: function() { - var tabName, betaLangMessage, helpTranslateText, helpTranslateLink, betaLangCode, oldLangCode, - view = this; - var accountSettingsTabs = [ - { - name: 'aboutTabSections', - id: 'about-tab', - label: gettext('Account Information'), - class: 'active', - tabindex: 0, - selected: true, - expanded: true - }, - { - name: 'accountsTabSections', - id: 'accounts-tab', - label: gettext('Linked Accounts'), - tabindex: -1, - selected: false, - expanded: false - } - ]; - if (!view.options.disableOrderHistoryTab) { - accountSettingsTabs.push({ - name: 'ordersTabSections', - id: 'orders-tab', - label: gettext('Order History'), - tabindex: -1, - selected: false, - expanded: false - }); - } - - if (!_.isEmpty(view.options.betaLanguage) && $.cookie('old-pref-lang')) { - betaLangMessage = HtmlUtils.interpolateHtml( - gettext('You have set your language to {beta_language}, which is currently not fully translated. You can help us translate this language fully by joining the Transifex community and adding translations from English for learners that speak {beta_language}.'), // eslint-disable-line max-len - { - beta_language: view.options.betaLanguage.name - } - ); - helpTranslateText = HtmlUtils.interpolateHtml( - gettext('Help Translate into {beta_language}'), - { - beta_language: view.options.betaLanguage.name - } - ); - betaLangCode = this.options.betaLanguage.code.split('-'); - if (betaLangCode.length > 1) { - betaLangCode = betaLangCode[0] + '_' + betaLangCode[1].toUpperCase(); - } else { - betaLangCode = betaLangCode[0]; - } - helpTranslateLink = 'https://www.transifex.com/open-edx/edx-platform/translate/#' + betaLangCode; - oldLangCode = $.cookie('old-pref-lang'); - // Deleting the cookie - document.cookie = 'old-pref-lang=; expires=Thu, 01 Jan 1970 00:00:00 UTC; path=/account;'; - - $.cookie('focus_id', '#beta-language-message'); - } - HtmlUtils.setHtml(this.$el, HtmlUtils.template(accountSettingsTemplate)({ - accountSettingsTabs: accountSettingsTabs, - HtmlUtils: HtmlUtils, - message: betaLangMessage, - helpTranslateText: helpTranslateText, - helpTranslateLink: helpTranslateLink, - oldLangCode: oldLangCode - })); - _.each(accountSettingsTabs, function(tab) { - tabName = tab.name; - view.renderSection(view.options.tabSections[tabName], tabName, tab.label); - }); - return this; - }, - - switchTab: function(e) { - var $currentTab, - $accountNavLink = $('.account-nav-link'); - - if (e) { - e.preventDefault(); - $currentTab = $(e.target); - this.activeTab = $currentTab.data('name'); - - _.each(this.$('.account-settings-tabpanels'), function(tabPanel) { - $(tabPanel).addClass('hidden'); - }); - - $('#' + this.activeTab + '-tabpanel').removeClass('hidden'); - - $accountNavLink.attr('tabindex', -1); - $accountNavLink.attr('aria-selected', false); - $accountNavLink.attr('aria-expanded', false); - - $currentTab.attr('tabindex', 0); - $currentTab.attr('aria-selected', true); - $currentTab.attr('aria-expanded', true); - - $(this.navLink).removeClass('active'); - $currentTab.addClass('active'); - } - }, - - setActiveTab: function() { - this.switchTab(); - }, - - renderSection: function(tabSections, tabName, tabLabel) { - var accountSectionView = new AccountSectionView({ - tabName: tabName, - tabLabel: tabLabel, - sections: tabSections, - el: '#' + tabName + '-tabpanel' - }); - - accountSectionView.render(); - }, - - showLoadingError: function() { - this.$('.ui-loading-error').removeClass('is-hidden'); - }, - - revertValue: function(event) { - this.options.userPreferencesModel.trigger('revertValue', event); - } - }); - - return AccountSettingsView; - }); -}).call(this, define || RequireJS.define); diff --git a/lms/static/learner_profile b/lms/static/learner_profile deleted file mode 120000 index ca7ce1f7978..00000000000 --- a/lms/static/learner_profile +++ /dev/null @@ -1 +0,0 @@ -../../openedx/features/learner_profile/static/learner_profile \ No newline at end of file diff --git a/lms/static/lms/js/build.js b/lms/static/lms/js/build.js index c22f366c5d2..1d5e1a983be 100644 --- a/lms/static/lms/js/build.js +++ b/lms/static/lms/js/build.js @@ -33,10 +33,8 @@ 'js/discussions_management/views/discussions_dashboard_factory', 'js/header_factory', 'js/student_account/logistration_factory', - 'js/student_account/views/account_settings_factory', 'js/student_account/views/finish_auth_factory', 'js/views/message_banner', - 'learner_profile/js/learner_profile_factory', 'lms/js/preview/preview_factory', 'support/js/certificates_factory', 'support/js/enrollment_factory', diff --git a/lms/static/lms/js/spec/main.js b/lms/static/lms/js/spec/main.js index cd8668ddc44..4b6b0d9ec64 100644 --- a/lms/static/lms/js/spec/main.js +++ b/lms/static/lms/js/spec/main.js @@ -761,9 +761,6 @@ 'js/spec/shoppingcart/shoppingcart_spec.js', 'js/spec/staff_debug_actions_spec.js', 'js/spec/student_account/access_spec.js', - 'js/spec/student_account/account_settings_factory_spec.js', - 'js/spec/student_account/account_settings_fields_spec.js', - 'js/spec/student_account/account_settings_view_spec.js', 'js/spec/student_account/emailoptin_spec.js', 'js/spec/student_account/enrollment_spec.js', 'js/spec/student_account/finish_auth_spec.js', @@ -787,10 +784,6 @@ 'js/spec/views/file_uploader_spec.js', '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/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', '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/_build-lms-v1.scss b/lms/static/sass/_build-lms-v1.scss index 7a77eb34ca0..90c5077c1f3 100644 --- a/lms/static/sass/_build-lms-v1.scss +++ b/lms/static/sass/_build-lms-v1.scss @@ -52,7 +52,6 @@ @import 'multicourse/survey-page'; // base - specific views -@import 'views/account-settings'; @import 'views/course-entitlements'; @import 'views/login-register'; @import 'views/verification'; @@ -68,7 +67,6 @@ // features @import 'features/bookmarks-v1'; -@import 'features/learner-profile'; @import 'features/_unsupported-browser-alert'; @import 'features/content-type-gating'; @import 'features/course-duration-limits'; diff --git a/lms/static/sass/features/_learner-profile.scss b/lms/static/sass/features/_learner-profile.scss deleted file mode 100644 index 8d35a7eccc6..00000000000 --- a/lms/static/sass/features/_learner-profile.scss +++ /dev/null @@ -1,875 +0,0 @@ -// lms - application - learner profile -// ==================== - -.learner-achievements { - .learner-message { - @extend %no-content; - - margin: $baseline*0.75 0; - - .message-header, - .message-actions { - text-align: center; - } - - .message-actions { - margin-top: $baseline/2; - - .btn-brand { - color: $white; - } - } - } -} - -.certificate-card { - display: flex; - flex-direction: row; - margin-bottom: $baseline; - padding: $baseline/2; - border: 1px; - border-style: solid; - background-color: $white; - cursor: pointer; - - &:hover { - box-shadow: 0 0 1px 1px $gray-l2; - } - - .card-logo { - @include margin-right($baseline); - - width: 100px; - height: 100px; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - display: none; - } - } - - .card-content { - color: $body-color; - margin-top: $baseline/2; - } - - .card-supertitle { - @extend %t-title6; - - color: $lightest-base-font-color; - } - - .card-title { - @extend %t-title5; - @extend %t-strong; - - margin-bottom: $baseline/2; - } - - .card-text { - @extend %t-title8; - - color: $lightest-base-font-color; - } - - &.mode-audit { - border-color: $audit-mode-color; - - .card-logo { - background-image: url('#{$static-path}/images/certificates/audit.png'); - } - } - - &.mode-honor { - border-color: $honor-mode-color; - - .card-logo { - background-image: url('#{$static-path}/images/certificates/honor.png'); - } - } - - &.mode-verified { - border-color: $verified-mode-color; - - .card-logo { - background-image: url('#{$static-path}/images/certificates/verified.png'); - } - } - - &.mode-professional { - border-color: $professional-certificate-color; - - .card-logo { - background-image: url('#{$static-path}/images/certificates/professional.png'); - } - } -} - -.view-profile { - $profile-image-dimension: 120px; - - .window-wrap, - .content-wrapper { - background-color: $body-bg; - padding: 0; - margin-top: 0; - } - - .page-banner { - background-color: $gray-l4; - max-width: none; - - .user-messages { - max-width: map-get($container-max-widths, xl); - margin: auto; - padding: $baseline/2; - } - } - - .ui-loading-indicator { - @extend .ui-loading-base; - - padding-bottom: $baseline; - - // center horizontally - @include margin-left(auto); - @include margin-right(auto); - - width: ($baseline*5); - } - - .profile-image-field { - button { - background: transparent !important; - border: none !important; - padding: 0; - } - - .u-field-image { - padding-top: 0; - padding-bottom: ($baseline/4); - } - - .image-wrapper { - width: $profile-image-dimension; - position: relative; - margin: auto; - - .image-frame { - display: block; - position: relative; - width: $profile-image-dimension; - height: $profile-image-dimension; - border-radius: ($profile-image-dimension/2); - overflow: hidden; - border: 3px solid $gray-l6; - margin-top: $baseline*-0.75; - background: $white; - } - - .u-field-upload-button { - position: absolute; - top: 0; - opacity: 0; - width: $profile-image-dimension; - height: $profile-image-dimension; - border-radius: ($profile-image-dimension/2); - border: 2px dashed transparent; - background: rgba(229, 241, 247, 0.8); - color: $link-color; - text-shadow: none; - - @include transition(all $tmg-f1 ease-in-out 0s); - - z-index: 6; - - i { - color: $link-color; - } - - &:focus, - &:hover { - @include show-hover-state(); - - border-color: $link-color; - } - - &.in-progress { - opacity: 1; - } - } - - .button-visible { - @include show-hover-state(); - } - - .upload-button-icon, - .upload-button-title { - display: block; - margin-bottom: ($baseline/4); - - @include transform(translateY(35px)); - - line-height: 1.3em; - text-align: center; - z-index: 7; - color: $body-color; - } - - .upload-button-input { - position: absolute; - top: 0; - - @include left(0); - - width: $profile-image-dimension; - border-radius: ($profile-image-dimension/2); - height: 100%; - cursor: pointer; - z-index: 5; - outline: 0; - opacity: 0; - } - - .u-field-remove-button { - position: relative; - display: block; - width: $profile-image-dimension; - margin-top: ($baseline / 4); - padding: ($baseline / 5) 0 0; - text-align: center; - opacity: 0; - transition: opacity 0.5s; - } - - &:hover, - &:active { - .u-field-remove-button { - opacity: 1; - } - } - } - } - - .wrapper-profile { - min-height: 200px; - background-color: $gray-l6; - - .ui-loading-indicator { - margin-top: 100px; - } - } - - .profile-self { - .wrapper-profile-field-account-privacy { - @include clearfix(); - - box-sizing: border-box; - width: 100%; - margin: 0 auto; - border-bottom: 1px solid $gray-l3; - background-color: $gray-l4; - padding: ($baseline*0.75) 5%; - display: table; - - .wrapper-profile-records { - display: table-row; - - button { - @extend %btn-secondary-blue-outline; - - margin-top: 1em; - background: $blue; - color: #fff; - } - } - - @include media-breakpoint-up(sm) { - .wrapper-profile-records { - display: table-cell; - vertical-align: middle; - white-space: nowrap; - - button { - margin-top: 0; - } - } - } - - .u-field-account_privacy { - @extend .container; - - display: table-cell; - border: none; - box-shadow: none; - padding: 0; - margin: 0; - vertical-align: middle; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - max-width: calc(100% - 40px); - min-width: auto; - } - - .btn-change-privacy { - @extend %btn-primary-blue; - - padding-top: 4px; - padding-bottom: 5px; - background-image: none; - box-shadow: none; - } - } - - .u-field-title { - @extend %t-strong; - - width: auto; - color: $body-color; - cursor: text; - text-shadow: none; // override bad lms styles on labels - } - - .u-field-value { - width: auto; - - @include margin-left($baseline/2); - } - - .u-field-message { - @include float(left); - - width: 100%; - padding: 0; - color: $body-color; - - .u-field-message-notification { - color: $gray-d2; - } - } - } - } - - .wrapper-profile-sections { - @extend .container; - - @include padding($baseline*1.5, 5%, $baseline*1.5, 5%); - - display: flex; - min-width: 0; - max-width: 100%; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - @include margin-left(0); - - flex-wrap: wrap; - } - } - - .profile-header { - max-width: map-get($container-max-widths, xl); - margin: auto; - padding: $baseline 5% 0; - - .header { - @extend %t-title4; - @extend %t-ultrastrong; - - display: inline-block; - color: #222; - } - - .subheader { - @extend %t-title6; - } - } - - .wrapper-profile-section-container-one { - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - width: 100%; - } - - .wrapper-profile-section-one { - width: 300px; - background-color: $white; - border-top: 5px solid $blue; - padding-bottom: $baseline; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - @include margin-left(0); - - width: 100%; - } - - .profile-section-one-fields { - margin: 0 $baseline/2; - - .social-links { - @include padding($baseline/4, 0, 0, $baseline/4); - - font-size: 2rem; - - & > span { - color: $gray-l4; - } - - a { - .fa-facebook-square { - color: $facebook-blue; - } - - .fa-twitter-square { - color: $twitter-blue; - } - - .fa-linkedin-square { - color: $linkedin-blue; - } - } - } - - .u-field { - font-weight: $font-semibold; - - @include padding(0, 0, 0, 3px); - - color: $body-color; - margin-top: $baseline/5; - - .u-field-value, - .u-field-title { - font-weight: 500; - width: calc(100% - 40px); - color: $lightest-base-font-color; - } - - .u-field-value-readonly { - font-family: $font-family-sans-serif; - color: $darkest-base-font-color; - } - - &.u-field-dropdown { - position: relative; - - &:not(.editable-never) { - cursor: pointer; - } - } - - &:not(.u-field-readonly) { - &.u-field-value { - @extend %t-weight3; - } - - &:not(:last-child) { - padding-bottom: $baseline/4; - border-bottom: 1px solid $border-color; - - &:hover.mode-placeholder { - padding-bottom: $baseline/5; - border-bottom: 2px dashed $link-color; - } - } - } - } - - & > .u-field { - &:not(:first-child) { - font-size: $body-font-size; - color: $body-color; - font-weight: $font-light; - margin-bottom: 0; - } - - &:first-child { - @extend %t-title4; - @extend %t-weight4; - - font-size: em(24); - } - } - - select { - width: 85%; - } - - .u-field-message { - @include right(0); - - position: absolute; - top: 0; - width: 20px; - - .icon { - vertical-align: baseline; - } - } - } - } - } - - - .wrapper-profile-section-container-two { - @include float(left); - @include padding-left($baseline); - - font-family: $font-family-sans-serif; - flex-grow: 1; - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - width: 90%; - margin-top: $baseline; - padding: 0; - } - - .u-field-textarea { - @include padding(0, ($baseline*0.75), ($baseline*0.75), 0); - - margin-bottom: ($baseline/2); - - @media (max-width: $learner-profile-container-flex) { // Switch to map-get($grid-breakpoints,md) for bootstrap - @include padding-left($baseline/4); - } - - .u-field-header { - position: relative; - - .u-field-message { - @include right(0); - - top: $baseline/4; - position: absolute; - } - } - - &.editable-toggle { - cursor: pointer; - } - } - - .u-field-title { - @extend %t-title6; - - display: inline-block; - margin-top: 0; - margin-bottom: ($baseline/4); - color: $gray-d3; - width: 100%; - font: $font-semibold 1.4em/1.4em $font-family-sans-serif; - } - - .u-field-value { - @extend %t-copy-base; - - width: 100%; - overflow: auto; - - textarea { - width: 100%; - background-color: transparent; - border-radius: 5px; - border-color: $gray-d1; - resize: none; - white-space: pre-line; - outline: 0; - box-shadow: none; - -webkit-appearance: none; - } - - a { - color: inherit; - } - } - - .u-field-message { - @include float(right); - - width: auto; - - .message-can-edit { - position: absolute; - } - } - - .u-field.mode-placeholder { - padding: $baseline; - margin: $baseline*0.75 0; - border: 2px dashed $gray-l3; - - i { - font-size: 12px; - - @include padding-right(5px); - - vertical-align: middle; - color: $body-color; - } - - .u-field-title { - width: 100%; - text-align: center; - } - - .u-field-value { - text-align: center; - line-height: 1.5em; - - @extend %t-copy-sub1; - - color: $body-color; - } - - &:hover { - border: 2px dashed $link-color; - - .u-field-title, - i { - color: $link-color; - } - } - } - - .wrapper-u-field { - font-size: $body-font-size; - color: $body-color; - - .u-field-header .u-field-title { - color: $body-color; - } - - .u-field-footer { - .field-textarea-character-count { - @extend %t-weight1; - - @include float(right); - - margin-top: $baseline/4; - } - } - } - - .profile-private-message { - @include padding-left($baseline*0.75); - - line-height: 3em; - } - } - - .badge-paging-header { - padding-top: $baseline; - } - - .page-content-nav { - @extend %page-content-nav; - } - - .badge-set-display { - @extend .container; - - padding: 0; - - .badge-list { - // We're using a div instead of ul for accessibility, so we have to match the style - // used by ul. - margin: 1em 0; - padding: 0 0 0 40px; - } - - .badge-display { - width: 50%; - display: inline-block; - vertical-align: top; - padding: 2em 0; - - .badge-image-container { - padding-right: $baseline; - margin-left: 1em; - width: 20%; - vertical-align: top; - display: inline-block; - - img.badge { - width: 100%; - } - - .accomplishment-placeholder { - border: 4px dotted $gray-l4; - border-radius: 50%; - display: block; - width: 100%; - padding-bottom: 100%; - } - } - - .badge-details { - @extend %t-copy-sub1; - @extend %t-regular; - - max-width: 70%; - display: inline-block; - color: $gray-d1; - - .badge-name { - @extend %t-strong; - @extend %t-copy-base; - - color: $gray-d3; - } - - .badge-description { - padding-bottom: $baseline; - line-height: 1.5em; - } - - .badge-date-stamp { - @extend %t-copy-sub1; - } - - .find-button-container { - border: 1px solid $blue-l1; - padding: ($baseline / 2) $baseline ($baseline / 2) $baseline; - display: inline-block; - border-radius: 5px; - font-weight: bold; - color: $blue-s3; - } - - .share-button { - @extend %t-action3; - @extend %button-reset; - - background: $gray-l6; - color: $gray-d1; - padding: ($baseline / 4) ($baseline / 2); - margin-bottom: ($baseline / 2); - display: inline-block; - border-radius: 5px; - border: 2px solid $gray-d1; - cursor: pointer; - transition: background 0.5s; - - .share-prefix { - display: inline-block; - vertical-align: middle; - } - - .share-icon-container { - display: inline-block; - - img.icon-mozillaopenbadges { - max-width: 1.5em; - margin-right: 0.25em; - } - } - - &:hover { - background: $gray-l4; - } - - &:active { - box-shadow: inset 0 4px 15px 0 $black-t2; - transition: none; - } - } - } - } - - .badge-placeholder { - background-color: $gray-l7; - box-shadow: inset 0 0 4px 0 $gray-l4; - } - } - - // ------------------------------ - // #BADGES MODAL - // ------------------------------ - .badges-overlay { - @extend %ui-depth1; - - position: fixed; - top: 0; - left: 0; - background-color: $dark-trans-bg; /* dim the background */ - width: 100%; - height: 100%; - vertical-align: middle; - - .badges-modal { - @extend %t-copy-lead1; - @extend %ui-depth2; - - color: $lighter-base-font-color; - box-sizing: content-box; - position: fixed; - top: 50%; - left: 50%; - transform: translate(-50%, -50%); - width: 80%; - max-width: 700px; - max-height: calc(100% - 100px); - margin-right: auto; - margin-left: auto; - border-top: rem(10) solid $blue-l2; - background: $light-gray3; - padding-right: ($baseline * 2); - padding-left: ($baseline * 2); - padding-bottom: ($baseline); - overflow-x: hidden; - - .modal-header { - margin-top: ($baseline / 2); - margin-bottom: ($baseline / 2); - } - - .close { - @extend %button-reset; - @extend %t-strong; - - color: $lighter-base-font-color; - position: absolute; - right: ($baseline); - top: $baseline; - cursor: pointer; - padding: ($baseline / 4) ($baseline / 2); - - @include transition(all $tmg-f2 ease-in-out 0s); - - &:focus, - &:hover { - background-color: $blue-d2; - border-radius: 3px; - color: $white; - } - } - - .badges-steps { - display: table; - } - - .image-container { - // Lines the image up with the content of the above list. - @include ltr { - @include padding-left(2em); - } - - @include rtl { - @include padding-right(1em); - - float: right; - } - } - - .backpack-logo { - @include float(right); - @include margin-left($baseline); - } - } - } - - .modal-hr { - display: block; - border: none; - background-color: $light-gray; - height: rem(2); - width: 100%; - } -} diff --git a/lms/static/sass/partials/lms/theme/_variables-v1.scss b/lms/static/sass/partials/lms/theme/_variables-v1.scss index 1cff0168ace..5dca9b84953 100644 --- a/lms/static/sass/partials/lms/theme/_variables-v1.scss +++ b/lms/static/sass/partials/lms/theme/_variables-v1.scss @@ -527,9 +527,6 @@ $palette-success-border: #b9edb9; $palette-success-back: #ecfaec; $palette-success-text: #008100; -// learner profile elements -$learner-profile-container-flex: 768px; - // course elements $course-bg-color: $uxpl-grayscale-x-back !default; $account-content-wrapper-bg: shade($body-bg, 2%) !default; diff --git a/lms/static/sass/views/_account-settings.scss b/lms/static/sass/views/_account-settings.scss deleted file mode 100644 index a4e5ff76eab..00000000000 --- a/lms/static/sass/views/_account-settings.scss +++ /dev/null @@ -1,683 +0,0 @@ -// lms - application - account settings -// ==================== - -// Table of Contents -// * +Container - Account Settings -// * +Main - Header -// * +Settings Section -// * +Alert Messages - - -// +Container - Account Settings -.wrapper-account-settings { - background: $white; - width: 100%; - - .account-settings-container { - max-width: grid-width(12); - padding: 10px; - margin: 0 auto; - } - - .ui-loading-indicator, - .ui-loading-error { - @extend .ui-loading-base; - // center horizontally - @include margin-left(auto); - @include margin-right(auto); - - padding: ($baseline*3); - text-align: center; - - .message-error { - color: $alert-color; - } - } -} - -// +Main - Header -.wrapper-account-settings { - .wrapper-header { - max-width: grid-width(12); - height: 139px; - border-bottom: 4px solid $m-gray-l4; - - .header-title { - @extend %t-title4; - - margin-bottom: ($baseline/2); - padding-top: ($baseline*2); - } - - .header-subtitle { - color: $gray-l2; - } - - .account-nav { - @include float(left); - - margin: ($baseline/2) 0; - padding: 0; - list-style: none; - - .account-nav-link { - @include float(left); - - font-size: em(14); - color: $gray; - padding: $baseline/4 $baseline*1.25 $baseline; - display: inline-block; - box-shadow: none; - border-bottom: 4px solid transparent; - border-radius: 0; - background: transparent none; - } - - button { - @extend %ui-clear-button; - @extend %btn-no-style; - - @include appearance(none); - - display: block; - padding: ($baseline/4); - - &:hover, - &:focus { - text-decoration: none; - border-bottom-color: $courseware-border-bottom-color; - } - - &.active { - border-bottom-color: theme-color("dark"); - } - } - } - - @include media-breakpoint-down(md) { - border-bottom-color: transparent; - - .account-nav { - display: flex; - border-bottom: none; - - .account-nav-link { - border-bottom: 4px solid theme-color("light"); - } - } - } - } -} - -// +Settings Section -.account-settings-sections { - .section-header { - @extend %t-title5; - @extend %t-strong; - - padding-top: ($baseline/2)*3; - color: $dark-gray1; - } - - .section { - background-color: $white; - margin: $baseline 5% 0; - border-bottom: 4px solid $m-gray-l4; - - .account-settings-header-subtitle { - font-size: em(14); - line-height: normal; - color: $dark-gray; - padding-bottom: 10px; - } - - .account-settings-header-subtitle-warning { - @extend .account-settings-header-subtitle; - - color: $alert-color; - } - - .account-settings-section-body { - .u-field { - border-bottom: 2px solid $m-gray-l4; - padding: $baseline*0.75 0; - - .field { - width: 30%; - vertical-align: top; - display: inline-block; - position: relative; - - select { - @include appearance(none); - - padding: 14px 30px 14px 15px; - border: 1px solid $gray58-border; - background-color: transparent; - border-radius: 2px; - position: relative; - z-index: 10; - - &::-ms-expand { - display: none; - } - - ~ .icon-caret-down { - &::after { - content: ""; - border-left: 6px solid transparent; - border-right: 6px solid transparent; - border-top: 7px solid $blue; - position: absolute; - right: 10px; - bottom: 20px; - z-index: 0; - } - } - } - - .field-label { - display: block; - width: auto; - margin-bottom: 0.625rem; - font-size: 1rem; - line-height: 1; - color: $dark-gray; - white-space: nowrap; - } - - .field-input { - @include transition(all 0.125s ease-in-out 0s); - - display: inline-block; - padding: 0.625rem; - border: 1px solid $gray58-border; - border-radius: 2px; - background: $white; - font-size: $body-font-size; - color: $dark-gray; - width: 100%; - height: 48px; - box-shadow: none; - } - - .u-field-link { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - - @include font-size(18); - - width: 100%; - border: 1px solid $blue; - color: $blue; - padding: 11px 14px; - line-height: normal; - } - } - - .u-field-order { - display: flex; - align-items: center; - font-size: em(16); - font-weight: 600; - color: $dark-gray; - width: 100%; - padding-top: $baseline; - padding-bottom: $baseline; - line-height: normal; - flex-flow: row wrap; - - span { - padding: $baseline; - } - - .u-field-order-number { - @include float(left); - - width: 30%; - } - - .u-field-order-date { - @include float(left); - - padding-left: 30px; - width: 20%; - } - - .u-field-order-price { - @include float(left); - - width: 15%; - } - - .u-field-order-link { - width: 10%; - padding: 0; - - .u-field-link { - @extend %ui-clear-button; - @extend %btn-pl-default-base; - - @include font-size(14); - - border: 1px solid $blue; - color: $blue; - line-height: normal; - padding: 10px; - width: 110px; - } - } - } - - .u-field-order-lines { - @extend .u-field-order; - - padding: 5px 0 0; - font-weight: 100; - - .u-field-order-number { - padding: 20px 10px 20px 30px; - } - } - - .social-field-linked { - background: $m-gray-l4; - box-shadow: 0 1px 2px 1px $shadow-l2; - padding: 1.25rem; - box-sizing: border-box; - margin: 10px; - width: 100%; - - .field-label { - @include font-size(24); - } - - .u-field-social-help { - display: inline-block; - padding: 20px 0 6px; - } - - .u-field-link { - @include font-size(14); - @include text-align(left); - - border: none; - margin-top: $baseline; - font-weight: $font-semibold; - padding: 0; - - &:focus, - &:hover, - &:active { - background-color: transparent; - color: $m-blue-d3; - border: none; - } - } - } - - .social-field-unlinked { - background: $m-gray-l4; - box-shadow: 0 1px 2px 1px $shadow-l2; - padding: 1.25rem; - box-sizing: border-box; - text-align: center; - margin: 10px; - width: 100%; - - .field-label { - @include font-size(24); - - text-align: center; - } - - .u-field-link { - @include font-size(14); - - margin-top: $baseline; - font-weight: $font-semibold; - } - } - - .u-field-message { - position: relative; - padding: $baseline*0.75 0 0 ($baseline*4); - width: 60%; - - .u-field-message-notification { - position: absolute; - left: 0; - top: 0; - bottom: 0; - margin: auto; - padding: 38px 0 0 ($baseline*5); - } - } - - &:last-child { - border-bottom: none; - margin-bottom: ($baseline*2); - } - - // Responsive behavior - @include media-breakpoint-down(md) { - .u-field-value { - width: 100%; - } - - .u-field-message { - width: 100%; - padding: $baseline/2 0; - - .u-field-message-notification { - position: relative; - padding: 0; - } - } - - .u-field-order { - display: flex; - flex-wrap: nowrap; - - .u-field-order-number, - .u-field-order-date, - .u-field-order-price, - .u-field-order-link { - width: auto; - float: none; - flex-grow: 1; - - &:first-of-type { - flex-grow: 2; - } - } - } - } - } - - .u-field { - &.u-field-dropdown, - &.editable-never &.mode-display { - .u-field-value { - margin-bottom: ($baseline); - - .u-field-title { - font-size: 16px; - line-height: 22px; - margin-bottom: 18px; - } - - .u-field-value-readonly { - font-size: 22px; - color: #636c72; - line-height: 30px; - white-space: nowrap; - } - } - } - } - - .u-field-readonly .u-field-title { - font-size: 16px; - color: #636c72; - line-height: 22px; - padding-top: ($baseline/2); - padding-bottom: 0; - margin-bottom: 8px !important; - } - - .u-field-readonly .u-field-value { - font-size: 22px; - color: #636c72; - line-height: 30px; - padding-top: 8px; - padding-bottom: ($baseline); - white-space: nowrap; - } - - .u-field-orderHistory { - border-bottom: none; - border: 1px solid $m-gray-l4; - margin-bottom: $baseline; - padding: 0; - - &:last-child { - border-bottom: 1px solid $m-gray-l4; - } - - &:hover, - &:focus { - background-color: $light-gray4; - } - } - - .u-field-order-orderId { - border: none; - margin-top: $baseline; - margin-bottom: 0; - padding-bottom: 0; - - &:hover, - &:focus { - background-color: transparent; - } - - .u-field-order { - font-weight: $font-semibold; - padding-top: 0; - padding-bottom: 0; - - .u-field-order-title { - font-size: em(16); - } - } - } - - .u-field-social { - border-bottom: none; - margin-right: 20px; - width: 30%; - display: inline-block; - vertical-align: top; - - .u-field-social-help { - @include font-size(12); - - color: $m-gray-d1; - } - } - } - - .account-deletion-details { - .btn-outline-primary { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - - @include font-size(18); - - border: 1px solid $blue; - color: $blue; - padding: 11px 14px; - line-height: normal; - margin: 20px 0; - } - - .paragon__modal-open { - overflow-y: scroll; - color: $dark-gray; - - .paragon__modal-title { - font-weight: $font-semibold; - } - - .paragon__modal-body { - line-height: 1.5; - - .alert-title { - line-height: 1.5; - } - } - - .paragon__alert-warning { - color: $dark-gray; - } - - .next-steps { - margin-bottom: 10px; - font-weight: $font-semibold; - } - - .confirm-password-input { - width: 50%; - } - - .paragon__btn:not(.cancel-btn) { - @extend %btn-primary-blue; - } - } - - .modal-alert { - display: flex; - - .icon-wrapper { - padding-right: 15px; - } - - .alert-content { - .alert-title { - color: $dark-gray; - margin-bottom: 10px; - font: { - size: 1rem; - weight: $font-semibold; - } - } - - a { - color: $blue-u1; - } - } - } - - .delete-confirmation-wrapper { - .paragon__modal-footer { - .paragon__btn-outline-primary { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - - @include margin-left(25px); - - border-color: $blue; - color: $blue; - padding: 11px 14px; - line-height: normal; - } - } - } - } - - &:last-child { - border-bottom: none; - } - } -} - -// * +Alert Messages -.account-settings-message, -.account-settings-section-message { - font-size: 16px; - line-height: 22px; - margin-top: 15px; - margin-bottom: 30px; - - .alert-message { - color: #292b2c; - font-family: $font-family-sans-serif; - position: relative; - padding: 10px 10px 10px 35px; - border: 1px solid transparent; - border-radius: 0; - box-shadow: none; - margin-bottom: 8px; - - & > .fa { - position: absolute; - left: 11px; - top: 13px; - font-size: 16px; - } - - span { - display: block; - - a { - text-decoration: underline; - } - } - } - - .success { - background-color: #ecfaec; - border-color: #b9edb9; - } - - .info { - background-color: #d8edf8; - border-color: #bbdff2; - } - - .warning { - background-color: #fcf8e3; - border-color: #faebcc; - } - - .error { - background-color: #f2dede; - border-color: #ebccd1; - } -} - -.account-settings-message { - margin-bottom: 0; - - .alert-message { - padding: 10px; - - .alert-actions { - margin-top: 10px; - - .btn-alert-primary { - @extend %btn-primary-blue; - - @include font-size(18); - - border: 1px solid $m-blue-d3; - border-radius: 3px; - box-shadow: none; - padding: 11px 14px; - line-height: normal; - } - - .btn-alert-secondary { - @extend %ui-clear-button; - - // set styles - @extend %btn-pl-default-base; - - @include font-size(18); - - background-color: white; - border: 1px solid $blue; - color: $blue; - padding: 11px 14px; - line-height: normal; - } - } - } -} diff --git a/lms/templates/course_modes/_upgrade_button.html b/lms/templates/course_modes/_upgrade_button.html deleted file mode 100644 index 02ff82c101b..00000000000 --- a/lms/templates/course_modes/_upgrade_button.html +++ /dev/null @@ -1,36 +0,0 @@ -<%page args="content_gating_enabled, course_duration_limit_enabled, min_price, price_before_discount" expression_filter="h"/> - -<%! -from django.utils.translation import gettext as _ -from openedx.core.djangolib.markup import HTML, Text -%> - -<%namespace name='static' file='../static_content.html'/> - -
  • - - % if content_gating_enabled or course_duration_limit_enabled: - -
  • - -<%static:require_module_async module_name="js/commerce/track_ecommerce_events" class_name="TrackECommerceEvents"> -var upgradeLink = $("#track_selection_upgrade"); - -TrackECommerceEvents.trackUpsellClick(upgradeLink, 'track_selection', { - pageName: "track_selection", - linkType: "button", - linkCategory: "(none)" -}); - - \ No newline at end of file diff --git a/lms/templates/course_modes/choose.html b/lms/templates/course_modes/choose.html deleted file mode 100644 index 78b6dcc26eb..00000000000 --- a/lms/templates/course_modes/choose.html +++ /dev/null @@ -1,233 +0,0 @@ -<%page expression_filter="h"/> -<%inherit file="../main.html" /> -<%! -from django.utils.translation import gettext as _ -from django.urls import reverse -from openedx.core.djangolib.js_utils import js_escaped_string -from openedx.core.djangolib.markup import HTML, Text -%> - -<%block name="bodyclass">register verification-process step-select-track -<%block name="pagetitle"> - ${_("Enroll In {course_name} | Choose Your Track").format(course_name=course_name)} - - -<%block name="js_extra"> - - - -<%block name="content"> - % if error: -
    -
    - -
    -

    ${_("Sorry, there was an error when trying to enroll you")}

    -
    -

    ${error}

    -
    -
    -
    -
    - %endif - -
    -
    -
    -
    - - -
    - <% - b_tag_kwargs = {'b_start': HTML(''), 'b_end': HTML('')} - %> - % if "verified" in modes: -
    -
    - - % if has_credit_upsell: - % if content_gating_enabled or course_duration_limit_enabled: -

    ${_("Pursue Academic Credit with the Verified Track")}

    - % else: -

    ${_("Pursue Academic Credit with a Verified Certificate")}

    - % endif - -
    -

    ${_("Become eligible for academic credit and highlight your new skills and knowledge with a verified certificate. Use this valuable credential to qualify for academic credit, advance your career, or strengthen your school applications.")}

    -

    -

    -
    - % if content_gating_enabled or course_duration_limit_enabled: -

    ${_("Benefits of the Verified Track")}

    -
      -
    • ${Text(_("{b_start}Eligible for credit:{b_end} Receive academic credit after successfully completing the course")).format(**b_tag_kwargs)}
    • - % if course_duration_limit_enabled: -
    • ${Text(_("{b_start}Unlimited Course Access: {b_end}Learn at your own pace, and access materials anytime to brush up on what you've learned.")).format(**b_tag_kwargs)}
    • - % endif - % if content_gating_enabled: -
    • ${Text(_("{b_start}Graded Assignments: {b_end}Build your skills through graded assignments and projects.")).format(**b_tag_kwargs)}
    • - % endif -
    • ${Text(_("{b_start}Easily Sharable: {b_end}Add the certificate to your CV or resumé, or post it directly on LinkedIn.")).format(**b_tag_kwargs)}
    • -
    - % else: -

    ${_("Benefits of a Verified Certificate")}

    -
      -
    • ${Text(_("{b_start}Eligible for credit:{b_end} Receive academic credit after successfully completing the course")).format(**b_tag_kwargs)}
    • -
    • ${Text(_("{b_start}Official:{b_end} Receive an instructor-signed certificate with the institution's logo")).format(**b_tag_kwargs)}
    • -
    • ${Text(_("{b_start}Easily shareable:{b_end} Add the certificate to your CV or resumé, or post it directly on LinkedIn")).format(**b_tag_kwargs)}
    • -
    - % endif -
    -
    -
      - <%include file='_upgrade_button.html' args='content_gating_enabled=content_gating_enabled, course_duration_limit_enabled=course_duration_limit_enabled, currency=currency, currency_symbol=currency_symbol, min_price=min_price, price_before_discount=price_before_discount' /> -
    -
    -
    -

    -
    - % else: - % if content_gating_enabled or course_duration_limit_enabled: -

    ${_("Pursue the Verified Track")}

    - % else: -

    ${_("Pursue a Verified Certificate")}

    - % endif - - -
    -

    ${_("Highlight your new knowledge and skills with a verified certificate. Use this valuable credential to improve your job prospects and advance your career, or highlight your certificate in school applications.")}

    -

    -

    -
    - % if content_gating_enabled or course_duration_limit_enabled: -

    ${_("Benefits of the Verified Track")}

    -
      - % if course_duration_limit_enabled: -
    • ${Text(_("{b_start}Unlimited Course Access: {b_end}Learn at your own pace, and access materials anytime to brush up on what you've learned.")).format(**b_tag_kwargs)}
    • - % endif - % if content_gating_enabled: -
    • ${Text(_("{b_start}Graded Assignments: {b_end}Build your skills through graded assignments and projects.")).format(**b_tag_kwargs)}
    • - % endif -
    • ${Text(_("{b_start}Easily Sharable: {b_end}Add the certificate to your CV or resumé, or post it directly on LinkedIn.")).format(**b_tag_kwargs)}
    • -
    - % else: -

    ${_("Benefits of a Verified Certificate")}

    -
      -
    • ${Text(_("{b_start}Official: {b_end}Receive an instructor-signed certificate with the institution's logo")).format(**b_tag_kwargs)}
    • -
    • ${Text(_("{b_start}Easily shareable: {b_end}Add the certificate to your CV or resumé, or post it directly on LinkedIn")).format(**b_tag_kwargs)}
    • -
    • ${Text(_("{b_start}Motivating: {b_end}Give yourself an additional incentive to complete the course")).format(**b_tag_kwargs)}
    • -
    - % endif -
    -
    -
      - <%include file='_upgrade_button.html' args='content_gating_enabled=content_gating_enabled, course_duration_limit_enabled=course_duration_limit_enabled, currency=currency, currency_symbol=currency_symbol, min_price=min_price, price_before_discount=price_before_discount' /> -
    -
    -
    -

    -
    - % endif -
    -
    - % endif - - % if "honor" in modes: - - ${_("or")} - - -
    -
    - -

    ${_("Audit This Course")}

    -
    -

    ${_("Audit this course for free and have complete access to all the course material, activities, tests, and forums.")}

    -
    -
    - -
      -
    • - -
    • -
    -
    - % elif "audit" in modes: - - ${_("or")} - - -
    -
    - -

    ${_("Audit This Course (No Certificate)")}

    -
    - ## Translators: b_start notes the beginning of a section of text bolded for emphasis, and b_end marks the end of the bolded text. - % if content_gating_enabled and course_duration_limit_enabled: -

    ${Text(_("Audit this course for free and have access to course materials and discussions forums. {b_start}This track does not include graded assignments, or unlimited course access.{b_end}")).format(**b_tag_kwargs)}

    - % elif content_gating_enabled and not course_duration_limit_enabled: -

    ${Text(_("Audit this course for free and have access to course materials and discussions forums. {b_start}This track does not include graded assignments.{b_end}")).format(**b_tag_kwargs)}

    - % elif not content_gating_enabled and course_duration_limit_enabled: -

    ${Text(_("Audit this course for free and have access to course materials and discussions forums. {b_start}This track does not include unlimited course access.{b_end}")).format(**b_tag_kwargs)}

    - % else: -

    ${Text(_("Audit this course for free and have complete access to all the course material, activities, tests, and forums. {b_start}Please note that this track does not offer a certificate for learners who earn a passing grade.{b_end}")).format(**b_tag_kwargs)}

    - % endif -
    -
    - -
      -
    • - -
    • -
    -
    - % endif - - -
    -
    -
    -
    -
    - diff --git a/lms/templates/dashboard/_dashboard_third_party_error.html b/lms/templates/dashboard/_dashboard_third_party_error.html deleted file mode 100644 index 5b9efe0bbdd..00000000000 --- a/lms/templates/dashboard/_dashboard_third_party_error.html +++ /dev/null @@ -1,14 +0,0 @@ -<%page expression_filter="h"/> - -<%! from django.utils.translation import gettext as _ %> -
    -
    -
    -

    ${_("Could Not Link Accounts")}

    -
    - ## Translators: this message is displayed when a user tries to link their account with a third-party authentication provider (for example, Google or LinkedIn) with a given edX account, but their third-party account is already associated with another edX account. provider_name is the name of the third-party authentication provider, and platform_name is the name of the edX deployment. -

    ${_("The {provider_name} account you selected is already linked to another {platform_name} account.").format(provider_name=duplicate_provider, platform_name=platform_name)}

    -
    -
    -
    -
    diff --git a/lms/templates/header/user_dropdown.html b/lms/templates/header/user_dropdown.html index 2e7e168a693..b4b22e0e32b 100644 --- a/lms/templates/header/user_dropdown.html +++ b/lms/templates/header/user_dropdown.html @@ -4,13 +4,13 @@ <%! import json +from urllib.parse import urljoin from django.conf import settings from django.urls import reverse from django.utils.translation import gettext as _ from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_urls_for_user -from openedx.core.djangoapps.user_api.accounts.toggles import should_redirect_to_order_history_microfrontend from openedx.features.enterprise_support.utils import get_enterprise_learner_generic_name, get_enterprise_learner_portal %> @@ -23,7 +23,7 @@ enterprise_customer_portal = get_enterprise_learner_portal(request) ## Enterprises with the learner portal enabled should not show order history, as it does ## not apply to the learner's method of purchasing content. -should_show_order_history = should_redirect_to_order_history_microfrontend() and not enterprise_customer_portal +should_show_order_history = not enterprise_customer_portal %> @@ -36,7 +36,7 @@
    % else: diff --git a/lms/urls.py b/lms/urls.py index f97bc7f0d04..9c7dd433a6f 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -658,12 +658,6 @@ include('openedx.features.calendar_sync.urls'), ), - # Learner profile - path( - 'u/', - include('openedx.features.learner_profile.urls'), - ), - # Survey Report re_path( fr'^survey_report/', diff --git a/mypy.ini b/mypy.ini index c0d739e8468..c6cac098c2b 100644 --- a/mypy.ini +++ b/mypy.ini @@ -12,6 +12,7 @@ files = openedx/core/djangoapps/content_staging, openedx/core/djangoapps/content_libraries, openedx/core/djangoapps/xblock, + openedx/core/lib/derived.py, openedx/core/types, openedx/core/djangoapps/content_tagging, xmodule/util/keys.py, diff --git a/openedx/core/djangoapps/content/search/documents.py b/openedx/core/djangoapps/content/search/documents.py index 98cd7d576e0..3d739781fe7 100644 --- a/openedx/core/djangoapps/content/search/documents.py +++ b/openedx/core/djangoapps/content/search/documents.py @@ -77,6 +77,12 @@ class Fields: # Structural XBlocks may use this one day to indicate how many child blocks they ocntain. num_children = "num_children" + # Publish status can be on of: + # "published", + # "modified" (for blocks that were published but have been modified since), + # "never" (for never-published blocks). + publish_status = "publish_status" + # Published data (dictionary) of this object published = "published" published_display_name = "display_name" @@ -97,6 +103,15 @@ class DocType: collection = "collection" +class PublishStatus: + """ + Values for the 'publish_status' field on each doc in the search index + """ + never = "never" + published = "published" + modified = "modified" + + def meili_id_from_opaque_key(usage_key: UsageKey) -> str: """ Meilisearch requires each document to have a primary key that's either an @@ -380,11 +395,15 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad library_name = lib_api.get_library(xblock_metadata.usage_key.context_key).title block = xblock_api.load_block(xblock_metadata.usage_key, user=None) + publish_status = PublishStatus.published try: block_published = xblock_api.load_block(xblock_metadata.usage_key, user=None, version=LatestVersion.PUBLISHED) + if xblock_metadata.last_published and xblock_metadata.last_published < xblock_metadata.modified: + publish_status = PublishStatus.modified except NotFound: # Never published block_published = None + publish_status = PublishStatus.never doc = searchable_doc_for_usage_key(xblock_metadata.usage_key) doc.update({ @@ -393,6 +412,7 @@ def searchable_doc_for_library_block(xblock_metadata: lib_api.LibraryXBlockMetad Fields.created: xblock_metadata.created.timestamp(), Fields.modified: xblock_metadata.modified.timestamp(), Fields.last_published: xblock_metadata.last_published.timestamp() if xblock_metadata.last_published else None, + Fields.publish_status: publish_status, }) doc.update(_fields_from_block(block)) diff --git a/openedx/core/djangoapps/content/search/index_config.py b/openedx/core/djangoapps/content/search/index_config.py index 9570956e425..f0a6eb9ca30 100644 --- a/openedx/core/djangoapps/content/search/index_config.py +++ b/openedx/core/djangoapps/content/search/index_config.py @@ -25,6 +25,7 @@ Fields.access_id, Fields.last_published, Fields.content + "." + Fields.problem_types, + Fields.publish_status, ] # Mark which attributes are used for keyword search, in order of importance: diff --git a/openedx/core/djangoapps/content/search/tests/test_api.py b/openedx/core/djangoapps/content/search/tests/test_api.py index c9c2b2589a3..8063307d61e 100644 --- a/openedx/core/djangoapps/content/search/tests/test_api.py +++ b/openedx/core/djangoapps/content/search/tests/test_api.py @@ -148,6 +148,7 @@ def setUp(self): "last_published": None, "created": created_date.timestamp(), "modified": modified_date.timestamp(), + "publish_status": "never", } self.doc_problem2 = { "id": "lborg1libproblemp2-b2f65e29", @@ -164,6 +165,7 @@ def setUp(self): "last_published": None, "created": created_date.timestamp(), "modified": created_date.timestamp(), + "publish_status": "never", } # Create a couple of taxonomies with tags diff --git a/openedx/core/djangoapps/content/search/tests/test_documents.py b/openedx/core/djangoapps/content/search/tests/test_documents.py index a97caae168d..b4f7eb2b62c 100644 --- a/openedx/core/djangoapps/content/search/tests/test_documents.py +++ b/openedx/core/djangoapps/content/search/tests/test_documents.py @@ -298,6 +298,7 @@ def test_html_library_block(self): "taxonomy": ["Difficulty"], "level0": ["Difficulty > Normal"], }, + "publish_status": "never", } def test_html_published_library_block(self): @@ -337,6 +338,7 @@ def test_html_published_library_block(self): "level0": ["Difficulty > Normal"], }, 'published': {'display_name': 'Text'}, + "publish_status": "published", } # Update library block to create a draft @@ -378,6 +380,7 @@ def test_html_published_library_block(self): "level0": ["Difficulty > Normal"], }, "published": {"display_name": "Text"}, + "publish_status": "published", } # Publish new changes @@ -420,8 +423,22 @@ def test_html_published_library_block(self): "display_name": "Text 2", "description": "This is a Test", }, + "publish_status": "published", } + # Verify publish status is set to modified + old_modified = self.library_block.modified + old_published = self.library_block.last_published + self.library_block.modified = datetime(2024, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + self.library_block.last_published = datetime(2023, 4, 5, 6, 7, 8, tzinfo=timezone.utc) + doc = searchable_doc_for_library_block(self.library_block) + doc.update(searchable_doc_tags(self.library_block.usage_key)) + doc.update(searchable_doc_collections(self.library_block.usage_key)) + assert doc["publish_status"] == "modified" + + self.library_block.modified = old_modified + self.library_block.last_published = old_published + def test_collection_with_library(self): doc = searchable_doc_for_collection(self.library.key, self.collection.key) doc.update(searchable_doc_tags_for_collection(self.library.key, self.collection.key)) diff --git a/openedx/core/djangoapps/content/search/tests/test_handlers.py b/openedx/core/djangoapps/content/search/tests/test_handlers.py index dc274d18269..95b2ecb6f52 100644 --- a/openedx/core/djangoapps/content/search/tests/test_handlers.py +++ b/openedx/core/djangoapps/content/search/tests/test_handlers.py @@ -153,6 +153,7 @@ def test_create_delete_library_block(self, meilisearch_client): "last_published": None, "created": created_date.timestamp(), "modified": created_date.timestamp(), + "publish_status": "never", } meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem]) @@ -177,6 +178,7 @@ def test_create_delete_library_block(self, meilisearch_client): library_api.publish_changes(library.key) doc_problem["last_published"] = published_date.timestamp() doc_problem["published"] = {"display_name": "Blank Problem"} + doc_problem["publish_status"] = "published" meilisearch_client.return_value.index.return_value.update_documents.assert_called_with([doc_problem]) # Delete the Library Block diff --git a/openedx/core/djangoapps/content_libraries/signal_handlers.py b/openedx/core/djangoapps/content_libraries/signal_handlers.py index 58f45d218e9..68cc7512e54 100644 --- a/openedx/core/djangoapps/content_libraries/signal_handlers.py +++ b/openedx/core/djangoapps/content_libraries/signal_handlers.py @@ -5,7 +5,7 @@ import logging from django.conf import settings -from django.db.models.signals import post_save, post_delete, m2m_changed +from django.db.models.signals import m2m_changed, post_delete, post_save from django.dispatch import receiver from opaque_keys import InvalidKeyError @@ -28,7 +28,6 @@ from .api import library_component_usage_key from .models import ContentLibrary, LtiGradedResource - log = logging.getLogger(__name__) diff --git a/openedx/core/djangoapps/content_libraries/tasks.py b/openedx/core/djangoapps/content_libraries/tasks.py index f56b4adfe31..b76101e1c62 100644 --- a/openedx/core/djangoapps/content_libraries/tasks.py +++ b/openedx/core/djangoapps/content_libraries/tasks.py @@ -22,11 +22,11 @@ from celery_utils.logged_task import LoggedTask from celery.utils.log import get_task_logger from edx_django_utils.monitoring import set_code_owner_attribute, set_code_owner_attribute_from_module +from opaque_keys.edx.keys import CourseKey from user_tasks.tasks import UserTask, UserTaskStatus from xblock.fields import Scope -from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.locator import BlockUsageLocator from openedx.core.lib import ensure_cms from xmodule.capa_block import ProblemBlock diff --git a/openedx/core/djangoapps/content_libraries/views.py b/openedx/core/djangoapps/content_libraries/views.py index 048226c5b16..a1b7a260245 100644 --- a/openedx/core/djangoapps/content_libraries/views.py +++ b/openedx/core/djangoapps/content_libraries/views.py @@ -81,6 +81,7 @@ from django.views.decorators.clickjacking import xframe_options_exempt from django.views.decorators.csrf import csrf_exempt from django.views.generic.base import TemplateResponseMixin, View +from drf_yasg.utils import swagger_auto_schema from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin from pylti1p3.exception import LtiException, OIDCException @@ -201,8 +202,10 @@ class LibraryRootView(GenericAPIView): """ Views to list, search for, and create content libraries. """ + serializer_class = ContentLibraryMetadataSerializer @apidocs.schema( + responses={200: ContentLibraryMetadataSerializer(many=True)}, parameters=[ *LibraryApiPaginationDocs.apidoc_params, apidocs.query_parameter( @@ -530,7 +533,13 @@ class LibraryPasteClipboardView(GenericAPIView): """ Paste content of clipboard into Library. """ + serializer_class = LibraryXBlockMetadataSerializer + @convert_exceptions + @swagger_auto_schema( + request_body=LibraryPasteClipboardSerializer, + responses={200: LibraryXBlockMetadataSerializer} + ) def post(self, request, lib_key_str): """ Import the contents of the user's clipboard and paste them into the Library @@ -558,6 +567,7 @@ class LibraryBlocksView(GenericAPIView): """ Views to work with XBlocks in a specific content library. """ + serializer_class = LibraryXBlockMetadataSerializer @apidocs.schema( parameters=[ @@ -595,6 +605,10 @@ def get(self, request, lib_key_str): return self.get_paginated_response(serializer.data) @convert_exceptions + @swagger_auto_schema( + request_body=LibraryXBlockCreationSerializer, + responses={200: LibraryXBlockMetadataSerializer} + ) def post(self, request, lib_key_str): """ Add a new XBlock to this content library @@ -870,6 +884,9 @@ class LibraryImportTaskViewSet(GenericViewSet): Import blocks from Courseware through modulestore. """ + queryset = [] # type: ignore[assignment] + serializer_class = ContentLibraryBlockImportTaskSerializer + @convert_exceptions def list(self, request, lib_key_str): """ @@ -889,6 +906,10 @@ def list(self, request, lib_key_str): ) @convert_exceptions + @swagger_auto_schema( + request_body=ContentLibraryBlockImportTaskCreateSerializer, + responses={200: ContentLibraryBlockImportTaskSerializer} + ) def create(self, request, lib_key_str): """ Create and queue an import tasks for this library. diff --git a/openedx/core/djangoapps/content_staging/api.py b/openedx/core/djangoapps/content_staging/api.py index 5f85d701faa..acc920fd6cb 100644 --- a/openedx/core/djangoapps/content_staging/api.py +++ b/openedx/core/djangoapps/content_staging/api.py @@ -14,29 +14,36 @@ from xblock.core import XBlock from openedx.core.lib.xblock_serializer.api import StaticFile, XBlockSerializer -from openedx.core.djangoapps.content.course_overviews.api import get_course_overview_or_none from xmodule import block_metadata_utils from xmodule.contentstore.content import StaticContent from xmodule.contentstore.django import contentstore from .data import ( CLIPBOARD_PURPOSE, - StagedContentData, StagedContentFileData, StagedContentStatus, UserClipboardData + StagedContentData, + StagedContentFileData, + StagedContentStatus, + UserClipboardData, ) from .models import ( UserClipboard as _UserClipboard, StagedContent as _StagedContent, StagedContentFile as _StagedContentFile, ) -from .serializers import UserClipboardSerializer as _UserClipboardSerializer +from .serializers import ( + UserClipboardSerializer as _UserClipboardSerializer, +) from .tasks import delete_expired_clipboards log = logging.getLogger(__name__) -def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int | None = None) -> UserClipboardData: +def _save_xblock_to_staged_content( + block: XBlock, user_id: int, purpose: str, version_num: int | None = None +) -> _StagedContent: """ - Copy an XBlock's OLX to the user's clipboard. + Generic function to save an XBlock's OLX to staged content. + Used by both clipboard and library sync functionality. """ block_data = XBlockSerializer( block, @@ -49,7 +56,7 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int # Mark all of the user's existing StagedContent rows as EXPIRED to_expire = _StagedContent.objects.filter( user_id=user_id, - purpose=CLIPBOARD_PURPOSE, + purpose=purpose, ).exclude( status=StagedContentStatus.EXPIRED, ) @@ -60,7 +67,7 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int # Insert a new StagedContent row for this staged_content = _StagedContent.objects.create( user_id=user_id, - purpose=CLIPBOARD_PURPOSE, + purpose=purpose, status=StagedContentStatus.READY, block_type=usage_key.block_type, olx=block_data.olx_str, @@ -69,23 +76,16 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int tags=block_data.tags or {}, version_num=(version_num or 0), ) - (clipboard, _created) = _UserClipboard.objects.update_or_create(user_id=user_id, defaults={ - "content": staged_content, - "source_usage_key": usage_key, - }) # Log an event so we can analyze how this feature is used: - log.info(f"Copied {usage_key.block_type} component \"{usage_key}\" to their clipboard.") + log.info(f'Saved {usage_key.block_type} component "{usage_key}" to staged content for {purpose}.') - # Try to copy the static files. If this fails, we still consider the overall copy attempt to have succeeded, - # because intra-course pasting will still work fine, and in any case users can manually resolve the file issue. + # Try to copy the static files. If this fails, we still consider the overall save attempt to have succeeded, + # because intra-course operations will still work fine, and users can manually resolve file issues. try: - _save_static_assets_to_user_clipboard(block_data.static_files, usage_key, staged_content) + _save_static_assets_to_staged_content(block_data.static_files, usage_key, staged_content) except Exception: # pylint: disable=broad-except - # Regardless of what happened, with get_asset_key_from_path or contentstore or run_filter, we don't want the - # whole "copy to clipboard" operation to fail, which would be a bad user experience. For copying and pasting - # within a single course, static assets don't even matter. So any such errors become warnings here. - log.exception(f"Unable to copy static files to clipboard for component {usage_key}") + log.exception(f"Unable to copy static files to staged content for component {usage_key}") # Enqueue a (potentially slow) task to delete the old staged content try: @@ -93,14 +93,15 @@ def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int except Exception: # pylint: disable=broad-except log.exception(f"Unable to enqueue cleanup task for StagedContents: {','.join(str(x) for x in expired_ids)}") - return _user_clipboard_model_to_data(clipboard) + return staged_content -def _save_static_assets_to_user_clipboard( +def _save_static_assets_to_staged_content( static_files: list[StaticFile], usage_key: UsageKey, staged_content: _StagedContent ): """ - Helper method for save_xblock_to_user_clipboard endpoint. This deals with copying static files into the clipboard. + Helper method for saving static files into staged content. + Used by both clipboard and library sync functionality. """ for f in static_files: source_key = ( @@ -144,6 +145,37 @@ def _save_static_assets_to_user_clipboard( log.exception(f"Unable to copy static file {f.name} to clipboard for component {usage_key}") +def save_xblock_to_user_clipboard(block: XBlock, user_id: int, version_num: int | None = None) -> UserClipboardData: + """ + Copy an XBlock's OLX to the user's clipboard. + """ + staged_content = _save_xblock_to_staged_content(block, user_id, CLIPBOARD_PURPOSE, version_num) + usage_key = block.usage_key + + # Create/update the clipboard entry + (clipboard, _created) = _UserClipboard.objects.update_or_create( + user_id=user_id, + defaults={ + "content": staged_content, + "source_usage_key": usage_key, + }, + ) + + return _user_clipboard_model_to_data(clipboard) + + +def stage_xblock_temporarily( + block: XBlock, user_id: int, purpose: str, version_num: int | None = None, +) -> _StagedContent: + """ + "Stage" an XBlock by copying it (and its associated children + static assets) + into the content staging area. This XBlock can then be accessed and manipulated + using any of the staged content APIs, before being deleted. + """ + staged_content = _save_xblock_to_staged_content(block, user_id, purpose, version_num) + return staged_content + + def get_user_clipboard(user_id: int, only_ready: bool = True) -> UserClipboardData | None: """ Get the details of the user's clipboard. @@ -190,28 +222,29 @@ def get_user_clipboard_json(user_id: int, request: HttpRequest | None = None): return serializer.data +def _staged_content_to_data(content: _StagedContent) -> StagedContentData: + """ + Convert a StagedContent model instance to an immutable data object. + """ + return StagedContentData( + id=content.id, + user_id=content.user_id, + created=content.created, + purpose=content.purpose, + status=content.status, + block_type=content.block_type, + display_name=content.display_name, + tags=content.tags or {}, + version_num=content.version_num, + ) + + def _user_clipboard_model_to_data(clipboard: _UserClipboard) -> UserClipboardData: """ Convert a UserClipboard model instance to an immutable data object. """ - content = clipboard.content - source_context_key = clipboard.source_usage_key.context_key - if source_context_key.is_course and (course_overview := get_course_overview_or_none(source_context_key)): - source_context_title = course_overview.display_name_with_default - else: - source_context_title = str(source_context_key) # Fall back to stringified context key as a title return UserClipboardData( - content=StagedContentData( - id=content.id, - user_id=content.user_id, - created=content.created, - purpose=content.purpose, - status=content.status, - block_type=content.block_type, - display_name=content.display_name, - tags=content.tags or {}, - version_num=content.version_num, - ), + content=_staged_content_to_data(clipboard.content), source_usage_key=clipboard.source_usage_key, source_context_title=clipboard.get_source_context_title(), ) diff --git a/openedx/core/djangoapps/content_staging/data.py b/openedx/core/djangoapps/content_staging/data.py index d077d05a0aa..d095f2506b1 100644 --- a/openedx/core/djangoapps/content_staging/data.py +++ b/openedx/core/djangoapps/content_staging/data.py @@ -25,6 +25,10 @@ class StagedContentStatus(TextChoices): # Value of the "purpose" field on StagedContent objects used for clipboards. CLIPBOARD_PURPOSE = "clipboard" + +# Value of the "purpose" field on StagedContent objects used for library to course sync. +LIBRARY_SYNC_PURPOSE = "library_sync" + # There may be other valid values of "purpose" which aren't defined within this app. diff --git a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py index 551f94e90e1..ab65d444ed6 100644 --- a/openedx/core/djangoapps/content_staging/tests/test_clipboard.py +++ b/openedx/core/djangoapps/content_staging/tests/test_clipboard.py @@ -1,3 +1,4 @@ +# pylint: skip-file """ Tests for the clipboard functionality """ diff --git a/openedx/core/djangoapps/content_tagging/api.py b/openedx/core/djangoapps/content_tagging/api.py index 8a06e483ab7..96f6886b622 100644 --- a/openedx/core/djangoapps/content_tagging/api.py +++ b/openedx/core/djangoapps/content_tagging/api.py @@ -441,3 +441,4 @@ def tag_object( get_object_tags = oel_tagging.get_object_tags add_tag_to_taxonomy = oel_tagging.add_tag_to_taxonomy copy_tags_as_read_only = oel_tagging.copy_tags +make_copied_tags_editable = oel_tagging.unmark_copied_tags diff --git a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py index cbfdd8e7337..f79ead24d9b 100644 --- a/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py +++ b/openedx/core/djangoapps/credentials/management/commands/notify_credentials.py @@ -7,11 +7,13 @@ This management command will manually trigger the receivers we care about. (We don't want to trigger all receivers for these signals, since these are busy signals.) """ + import logging import shlex from datetime import datetime, timedelta import dateutil.parser +from django.conf import settings from django.core.management.base import BaseCommand, CommandError from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey @@ -58,6 +60,7 @@ class Command(BaseCommand): course-v1:edX+RecordsSelfPaced+1 for user 17 course-v1:edX+RecordsSelfPaced+1 for user 18 """ + help = ( "Simulate certificate/grade changes without actually modifying database " "content. Specifically, trigger the handlers that send data to Credentials." @@ -65,98 +68,99 @@ class Command(BaseCommand): def add_arguments(self, parser): parser.add_argument( - '--dry-run', - action='store_true', - help='Just show a preview of what would happen.', + "--dry-run", + action="store_true", + help="Just show a preview of what would happen.", ) parser.add_argument( - '--site', + "--site", default=None, help="Site domain to notify for (if not specified, all sites are notified). Uses course_org_filter.", ) parser.add_argument( - '--courses', - nargs='+', - help='Send information only for specific course runs.', + "--courses", + nargs="+", + help="Send information only for specific course runs.", ) parser.add_argument( - '--program_uuids', - nargs='+', - help='Send user data for course runs for courses within a program based on program uuids provided.', + "--program_uuids", + nargs="+", + help="Send user data for course runs for courses within a program based on program uuids provided.", ) parser.add_argument( - '--start-date', + "--start-date", type=parsetime, - help='Send information only for certificates or grades that have changed since this date.', + help="Send information only for certificates or grades that have changed since this date.", ) parser.add_argument( - '--end-date', + "--end-date", type=parsetime, - help='Send information only for certificates or grades that have changed before this date.', + help="Send information only for certificates or grades that have changed before this date.", ) parser.add_argument( - '--delay', + "--delay", type=float, default=0, help="Number of seconds to sleep between processing queries, so that we don't flood our queues.", ) parser.add_argument( - '--page-size', + "--page-size", type=int, default=100, help="Number of items to query at once.", ) parser.add_argument( - '--auto', - action='store_true', - help='Use to run the management command periodically', + "--auto", + action="store_true", + help="Use to run the management command periodically", ) parser.add_argument( - '--args-from-database', - action='store_true', - help='Use arguments from the NotifyCredentialsConfig model instead of the command line.', + "--args-from-database", + action="store_true", + help="Use arguments from the NotifyCredentialsConfig model instead of the command line.", ) parser.add_argument( - '--verbose', - action='store_true', - help='Run grade/cert change signal in verbose mode', + "--verbose", + action="store_true", + help="Run grade/cert change signal in verbose mode", ) parser.add_argument( - '--notify_programs', - action='store_true', - help='Send program award notifications with course notification tasks', + "--notify_programs", + action="store_true", + help="Send program award notifications with course notification tasks", ) parser.add_argument( - '--user_ids', + "--user_ids", default=None, - nargs='+', - help='Run the command for the given user or list of users', + nargs="+", + help="Run the command for the given user or list of users", ) parser.add_argument( - '--revoke_program_certs', - action='store_true', - help="If true, system will check if any program certificates need to be revoked from learners" + "--revoke_program_certs", + action="store_true", + help="If true, system will check if any program certificates need to be revoked from learners", ) def get_args_from_database(self): - """ Returns an options dictionary from the current NotifyCredentialsConfig model. """ + """Returns an options dictionary from the current NotifyCredentialsConfig model.""" config = NotifyCredentialsConfig.current() if not config.enabled: - raise CommandError('NotifyCredentialsConfig is disabled, but --args-from-database was requested.') + raise CommandError("NotifyCredentialsConfig is disabled, but --args-from-database was requested.") # This split will allow for quotes to wrap datetimes, like "2020-10-20 04:00:00" and other # arguments as if it were the command line argv = shlex.split(config.arguments) - parser = self.create_parser('manage.py', 'notify_credentials') - return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object + parser = self.create_parser("manage.py", "notify_credentials") + return parser.parse_args(argv).__dict__ # we want a dictionary, not a non-iterable Namespace object def handle(self, *args, **options): - if options['args_from_database']: + if options["args_from_database"]: options = self.get_args_from_database() - if options['auto']: - options['end_date'] = datetime.now().replace(minute=0, second=0, microsecond=0) - options['start_date'] = options['end_date'] - timedelta(hours=4) + if options["auto"]: + run_frequency = settings.NOTIFY_CREDENTIALS_FREQUENCY + options["end_date"] = datetime.now().replace(minute=0, second=0, microsecond=0) + options["start_date"] = options["end_date"] - timedelta(seconds=run_frequency) log.info( f"notify_credentials starting, dry-run={options['dry_run']}, site={options['site']}, " @@ -176,14 +180,9 @@ def handle(self, *args, **options): course_runs.extend(program_course_run_keys) course_run_keys = self._get_validated_course_run_keys(course_runs) - if not ( - course_run_keys or - options['start_date'] or - options['end_date'] or - options['user_ids'] - ): + if not (course_run_keys or options["start_date"] or options["end_date"] or options["user_ids"]): raise CommandError( - 'You must specify a filter (e.g. --courses, --program_uuids, --start-date, or --user_ids)' + "You must specify a filter (e.g. --courses, --program_uuids, --start-date, or --user_ids)" ) handle_notify_credentials.delay(options, course_run_keys) diff --git a/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py b/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py index e50173001fd..f7bed3446ac 100644 --- a/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py +++ b/openedx/core/djangoapps/credentials/management/commands/tests/test_notify_credentials.py @@ -7,7 +7,7 @@ from django.core.management import call_command from django.core.management.base import CommandError -from django.test import TestCase, override_settings # lint-amnesty, pylint: disable=unused-import +from django.test import TestCase, override_settings from freezegun import freeze_time from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory, CourseFactory, CourseRunFactory @@ -125,6 +125,7 @@ def test_multiple_programs_uuid_args(self, mock_get_programs, mock_task): @freeze_time(datetime(2017, 5, 1, 4)) def test_auto_execution(self, mock_task): + """Verify that an automatic execution designed for scheduled windows works correctly""" self.expected_options['auto'] = True self.expected_options['start_date'] = datetime(2017, 5, 1, 0, 0) self.expected_options['end_date'] = datetime(2017, 5, 1, 4, 0) @@ -133,6 +134,19 @@ def test_auto_execution(self, mock_task): assert mock_task.called assert mock_task.call_args[0][0] == self.expected_options + @override_settings(NOTIFY_CREDENTIALS_FREQUENCY=3600) + @freeze_time(datetime(2017, 5, 1, 4)) + def test_auto_execution_different_schedule(self, mock_task): + """Verify that an automatic execution designed for scheduled windows + works correctly if the window frequency has been changed""" + self.expected_options["auto"] = True + self.expected_options["start_date"] = datetime(2017, 5, 1, 3, 0) + self.expected_options["end_date"] = datetime(2017, 5, 1, 4, 0) + + call_command(Command(), "--auto") + assert mock_task.called + assert mock_task.call_args[0][0] == self.expected_options + def test_date_args(self, mock_task): self.expected_options['start_date'] = datetime(2017, 1, 31, 0, 0, tzinfo=timezone.utc) call_command(Command(), '--start-date', '2017-01-31') diff --git a/openedx/core/djangoapps/credit/models.py b/openedx/core/djangoapps/credit/models.py index 2a9fa208855..9c14a15104b 100644 --- a/openedx/core/djangoapps/credit/models.py +++ b/openedx/core/djangoapps/credit/models.py @@ -514,7 +514,6 @@ def remove_requirement_status(cls, username, requirement): ) ) log.error(log_msg) - return @classmethod def retire_user(cls, retirement): diff --git a/openedx/core/djangoapps/credit/tasks.py b/openedx/core/djangoapps/credit/tasks.py index 312e278a985..79ef613e3d1 100644 --- a/openedx/core/djangoapps/credit/tasks.py +++ b/openedx/core/djangoapps/credit/tasks.py @@ -41,8 +41,7 @@ def update_credit_course_requirements(course_id): except (InvalidKeyError, ItemNotFoundError, InvalidCreditRequirements) as exc: LOGGER.error('Error on adding the requirements for course %s - %s', course_id, str(exc)) raise update_credit_course_requirements.retry(args=[course_id], exc=exc) - else: - LOGGER.info('Requirements added for course %s', course_id) + LOGGER.info('Requirements added for course %s', course_id) def _get_course_credit_requirements(course_key): diff --git a/openedx/core/djangoapps/enrollments/data.py b/openedx/core/djangoapps/enrollments/data.py index 9986830a349..b76042f72c9 100644 --- a/openedx/core/djangoapps/enrollments/data.py +++ b/openedx/core/djangoapps/enrollments/data.py @@ -341,8 +341,7 @@ def get_course_enrollment_info(course_id, include_expired=False): msg = f"Requested enrollment information for unknown course {course_id}" log.warning(msg) raise CourseNotFoundError(msg) # lint-amnesty, pylint: disable=raise-missing-from - else: - return CourseSerializer(course, include_expired=include_expired).data + return CourseSerializer(course, include_expired=include_expired).data def get_user_roles(username): diff --git a/openedx/core/djangoapps/notifications/events.py b/openedx/core/djangoapps/notifications/events.py index 74e6e56e41e..046b18c7582 100644 --- a/openedx/core/djangoapps/notifications/events.py +++ b/openedx/core/djangoapps/notifications/events.py @@ -46,20 +46,32 @@ def notification_event_context(user, course_id, notification): } -def notification_preferences_viewed_event(request, course_id): +def notification_preferences_viewed_event(request, course_id=None): """ Emit an event when a user views their notification preferences. """ + event_data = { + 'user_id': str(request.user.id), + 'course_id': None, + 'user_forum_roles': [], + 'user_course_roles': [], + 'type': 'account' + } + if not course_id: + tracker.emit( + NOTIFICATION_PREFERENCES_VIEWED, + event_data + ) + return context = contexts.course_context_from_course_id(course_id) with tracker.get_tracker().context(NOTIFICATION_PREFERENCES_VIEWED, context): + event_data['course_id']: str(course_id) + event_data['user_forum_roles'] = get_user_forums_roles(request.user, course_id) + event_data['user_course_roles'] = get_user_course_roles(request.user, course_id) + event_data['type'] = 'course' tracker.emit( NOTIFICATION_PREFERENCES_VIEWED, - { - 'user_id': str(request.user.id), - 'course_id': str(course_id), - 'user_forum_roles': get_user_forums_roles(request.user, course_id), - 'user_course_roles': get_user_course_roles(request.user, course_id), - } + event_data ) @@ -125,23 +137,36 @@ def notification_preference_update_event(user, course_id, updated_preference): """ Emit an event when a notification preference is updated. """ - context = contexts.course_context_from_course_id(course_id) - with tracker.get_tracker().context(NOTIFICATION_PREFERENCES_UPDATED, context): - value = updated_preference.get('value', '') - if updated_preference.get('notification_channel', '') == 'email_cadence': - value = updated_preference.get('email_cadence', '') + value = updated_preference.get('value', '') + if updated_preference.get('notification_channel', '') == 'email_cadence': + value = updated_preference.get('email_cadence', '') + event_data = { + 'user_id': str(user.id), + 'notification_app': updated_preference.get('notification_app', ''), + 'notification_type': updated_preference.get('notification_type', ''), + 'notification_channel': updated_preference.get('notification_channel', ''), + 'value': value, + 'course_id': None, + 'user_forum_roles': [], + 'user_course_roles': [], + 'type': 'course', + } + if not isinstance(course_id, list): + context = contexts.course_context_from_course_id(course_id) + with tracker.get_tracker().context(NOTIFICATION_PREFERENCES_UPDATED, context): + event_data['course_id'] = str(course_id) + event_data['user_forum_roles'] = get_user_forums_roles(user, course_id) + event_data['user_course_roles'] = get_user_course_roles(user, course_id) + tracker.emit( + NOTIFICATION_PREFERENCES_UPDATED, + event_data + ) + else: + event_data['course_ids'] = course_id + event_data['type'] = 'account' tracker.emit( NOTIFICATION_PREFERENCES_UPDATED, - { - 'user_id': str(user.id), - 'course_id': str(course_id), - 'user_forum_roles': get_user_forums_roles(user, course_id), - 'user_course_roles': get_user_course_roles(user, course_id), - 'notification_app': updated_preference.get('notification_app', ''), - 'notification_type': updated_preference.get('notification_type', ''), - 'notification_channel': updated_preference.get('notification_channel', ''), - 'value': value - } + event_data ) diff --git a/openedx/core/djangoapps/notifications/views.py b/openedx/core/djangoapps/notifications/views.py index 8e41b11554c..8718b6818cd 100644 --- a/openedx/core/djangoapps/notifications/views.py +++ b/openedx/core/djangoapps/notifications/views.py @@ -29,7 +29,7 @@ notification_preferences_viewed_event, notification_read_event, notification_tray_opened_event, - notifications_app_all_read_event + notifications_app_all_read_event, ) from .models import CourseNotificationPreference, Notification from .serializers import ( @@ -523,12 +523,11 @@ def post(self, request): 'error': f'Invalid path: {app}.notification_types.{notification_type}.{channel}' }) - except Exception as e: + except (KeyError, AttributeError, ValueError) as e: errors.append({ 'course_id': str(preference.course_id), 'error': str(e) }) - response_data = { 'status': 'success' if updated_courses else 'partial_success' if errors else 'error', 'message': 'Notification preferences update completed', @@ -542,16 +541,26 @@ def post(self, request): 'total_courses': notification_preferences.count() } } - if errors: response_data['errors'] = errors - + event_data = { + 'notification_app': app, + 'notification_type': notification_type, + 'notification_channel': channel, + 'value': value, + 'email_cadence': value + } + notification_preference_update_event( + request.user, + [course['course_id'] for course in updated_courses], + event_data + ) return Response( response_data, status=status.HTTP_200_OK if updated_courses else status.HTTP_400_BAD_REQUEST ) - except Exception as e: + except (KeyError, AttributeError, ValueError) as e: return Response({ 'status': 'error', 'message': str(e) @@ -579,7 +588,7 @@ def get(self, request): notification_configs = aggregate_notification_configs( notification_configs ) - + notification_preferences_viewed_event(request) return Response({ 'status': 'success', 'message': 'Notification preferences retrieved', diff --git a/openedx/core/djangoapps/password_policy/compliance.py b/openedx/core/djangoapps/password_policy/compliance.py index 78e5ae902be..fdd103d2437 100644 --- a/openedx/core/djangoapps/password_policy/compliance.py +++ b/openedx/core/djangoapps/password_policy/compliance.py @@ -97,7 +97,7 @@ def enforce_compliance_on_login(user, password): platform_name=settings.PLATFORM_NAME, deadline=strftime_localized(deadline, DEFAULT_SHORT_DATE_FORMAT), anchor_tag_open=HTML('').format( - account_settings_url=settings.LMS_ROOT_URL + "/account/settings" + account_settings_url=settings.ACCOUNT_MICROFRONTEND_URL ), anchor_tag_close=HTML('') ) diff --git a/openedx/core/djangoapps/safe_sessions/middleware.py b/openedx/core/djangoapps/safe_sessions/middleware.py index f3948217efd..950a3e08c54 100644 --- a/openedx/core/djangoapps/safe_sessions/middleware.py +++ b/openedx/core/djangoapps/safe_sessions/middleware.py @@ -244,14 +244,13 @@ def parse(cls, safe_cookie_string): raise SafeCookieError( # lint-amnesty, pylint: disable=raise-missing-from f"SafeCookieData BWC parse error: {safe_cookie_string!r}." ) - else: - if safe_cookie_data.version != cls.CURRENT_VERSION: - raise SafeCookieError( - "SafeCookieData version {!r} is not supported. Current version is {}.".format( - safe_cookie_data.version, - cls.CURRENT_VERSION, - )) - return safe_cookie_data + if safe_cookie_data.version != cls.CURRENT_VERSION: + raise SafeCookieError( + "SafeCookieData version {!r} is not supported. Current version is {}.".format( + safe_cookie_data.version, + cls.CURRENT_VERSION, + )) + return safe_cookie_data def __str__(self): """ diff --git a/openedx/core/djangoapps/session_inactivity_timeout/middleware.py b/openedx/core/djangoapps/session_inactivity_timeout/middleware.py index eb5633ff129..79609e4766e 100644 --- a/openedx/core/djangoapps/session_inactivity_timeout/middleware.py +++ b/openedx/core/djangoapps/session_inactivity_timeout/middleware.py @@ -7,6 +7,9 @@ SESSION_INACTIVITY_TIMEOUT_IN_SECS = 300 This was taken from StackOverflow (http://stackoverflow.com/questions/14830669/how-to-expire-django-session-in-5minutes) + +If left unset, session expiration will be handled by Django's SESSION_COOKIE_AGE, +which defauts to 1209600 (2 weeks, in seconds). """ 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 d7578f25eb8..41f91c7d1eb 100644 --- a/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py +++ b/openedx/core/djangoapps/theming/tests/test_theme_style_overrides.py @@ -44,21 +44,6 @@ def test_footer(self): # This string comes from header.html of test-theme self.assertContains(resp, "This is a footer for test-theme.") - @with_comprehensive_theme("edx.org") - def test_account_settings_hide_nav(self): - """ - Test that theme header doesn't show marketing site links for Account Settings page. - """ - self._login() - - account_settings_url = reverse('account_settings') - response = self.client.get(account_settings_url) - - # Verify that the header navigation links are hidden for the edx.org version - self.assertNotContains(response, "How it Works") - self.assertNotContains(response, "Find courses") - self.assertNotContains(response, "Schools & Partners") - @with_comprehensive_theme("test-theme") def test_logo_image(self): """ diff --git a/openedx/core/djangoapps/user_api/accounts/settings_views.py b/openedx/core/djangoapps/user_api/accounts/settings_views.py deleted file mode 100644 index 79e01e0bf04..00000000000 --- a/openedx/core/djangoapps/user_api/accounts/settings_views.py +++ /dev/null @@ -1,300 +0,0 @@ -""" Views related to Account Settings. """ - - -import logging -import urllib -from datetime import datetime - -from django.conf import settings -from django.contrib import messages -from django.contrib.auth.decorators import login_required -from django.http import HttpResponseRedirect -from django.shortcuts import redirect -from django.urls import reverse -from django.utils.translation import gettext as _ -from django.views.decorators.http import require_http_methods -from django_countries import countries - -from openedx_filters.learning.filters import AccountSettingsRenderStarted -from common.djangoapps import third_party_auth -from common.djangoapps.edxmako.shortcuts import render_to_response -from common.djangoapps.student.models import UserProfile -from common.djangoapps.third_party_auth import pipeline -from common.djangoapps.util.date_utils import strftime_localized -from lms.djangoapps.commerce.models import CommerceConfiguration -from lms.djangoapps.commerce.utils import EcommerceService -from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client -from openedx.core.djangoapps.dark_lang.models import DarkLangConfig -from openedx.core.djangoapps.lang_pref.api import all_languages, released_languages -from openedx.core.djangoapps.programs.models import ProgramsApiConfig -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_api.accounts.toggles import ( - should_redirect_to_account_microfrontend, - should_redirect_to_order_history_microfrontend -) -from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences -from openedx.core.lib.edx_api_utils import get_api_data -from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES -from openedx.features.enterprise_support.api import enterprise_customer_for_request -from openedx.features.enterprise_support.utils import update_account_settings_context_for_enterprise - -log = logging.getLogger(__name__) - - -@login_required -@require_http_methods(['GET']) -def account_settings(request): - """Render the current user's account settings page. - - Args: - request (HttpRequest) - - Returns: - HttpResponse: 200 if the page was sent successfully - HttpResponse: 302 if not logged in (redirect to login page) - HttpResponse: 405 if using an unsupported HTTP method - - Example usage: - - GET /account/settings - - """ - if should_redirect_to_account_microfrontend(): - url = settings.ACCOUNT_MICROFRONTEND_URL - - duplicate_provider = pipeline.get_duplicate_provider(messages.get_messages(request)) - if duplicate_provider: - url = '{url}?{params}'.format( - url=url, - params=urllib.parse.urlencode({ - 'duplicate_provider': duplicate_provider, - }), - ) - - return redirect(url) - - context = account_settings_context(request) - - account_settings_template = 'student_account/account_settings.html' - - try: - # .. filter_implemented_name: AccountSettingsRenderStarted - # .. filter_type: org.openedx.learning.student.settings.render.started.v1 - context, account_settings_template = AccountSettingsRenderStarted.run_filter( - context=context, template_name=account_settings_template, - ) - except AccountSettingsRenderStarted.RenderInvalidAccountSettings as exc: - response = render_to_response(exc.account_settings_template, exc.template_context) - except AccountSettingsRenderStarted.RedirectToPage as exc: - response = HttpResponseRedirect(exc.redirect_to or reverse('dashboard')) - except AccountSettingsRenderStarted.RenderCustomResponse as exc: - response = exc.response - else: - response = render_to_response(account_settings_template, context) - - return response - - -def account_settings_context(request): - """ Context for the account settings page. - - Args: - request: The request object. - - Returns: - dict - - """ - user = request.user - - year_of_birth_options = [(str(year), str(year)) for year in UserProfile.VALID_YEARS] - try: - user_orders = get_user_orders(user) - except: # pylint: disable=bare-except - log.exception('Error fetching order history from Otto.') - # Return empty order list as account settings page expect a list and - # it will be broken if exception raised - user_orders = [] - - beta_language = {} - dark_lang_config = DarkLangConfig.current() - if dark_lang_config.enable_beta_languages: - user_preferences = get_user_preferences(user) - pref_language = user_preferences.get('pref-lang') - if pref_language in dark_lang_config.beta_languages_list: - beta_language['code'] = pref_language - beta_language['name'] = settings.LANGUAGE_DICT.get(pref_language) - - context = { - 'auth': {}, - 'duplicate_provider': None, - 'nav_hidden': True, - 'fields': { - 'country': { - 'options': list(countries), - }, 'gender': { - 'options': [(choice[0], _(choice[1])) for choice in UserProfile.GENDER_CHOICES], # lint-amnesty, pylint: disable=translation-of-non-string - }, 'language': { - 'options': released_languages(), - }, 'level_of_education': { - 'options': [(choice[0], _(choice[1])) for choice in UserProfile.LEVEL_OF_EDUCATION_CHOICES], # lint-amnesty, pylint: disable=translation-of-non-string - }, 'password': { - 'url': reverse('password_reset'), - }, 'year_of_birth': { - 'options': year_of_birth_options, - }, 'preferred_language': { - 'options': all_languages(), - }, 'time_zone': { - 'options': TIME_ZONE_CHOICES, - } - }, - 'platform_name': configuration_helpers.get_value('PLATFORM_NAME', settings.PLATFORM_NAME), - 'password_reset_support_link': configuration_helpers.get_value( - 'PASSWORD_RESET_SUPPORT_LINK', settings.PASSWORD_RESET_SUPPORT_LINK - ) or settings.SUPPORT_SITE_LINK, - 'user_accounts_api_url': reverse("accounts_api", kwargs={'username': user.username}), - 'user_preferences_api_url': reverse('preferences_api', kwargs={'username': user.username}), - 'disable_courseware_js': True, - 'show_program_listing': ProgramsApiConfig.is_enabled(), - 'show_dashboard_tabs': True, - 'order_history': user_orders, - 'disable_order_history_tab': should_redirect_to_order_history_microfrontend(), - 'enable_account_deletion': configuration_helpers.get_value( - 'ENABLE_ACCOUNT_DELETION', settings.FEATURES.get('ENABLE_ACCOUNT_DELETION', False) - ), - 'extended_profile_fields': _get_extended_profile_fields(), - 'beta_language': beta_language, - 'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE, - } - - enterprise_customer = enterprise_customer_for_request(request) - update_account_settings_context_for_enterprise(context, enterprise_customer, user) - - if third_party_auth.is_enabled(): - # If the account on the third party provider is already connected with another edX account, - # we display a message to the user. - context['duplicate_provider'] = pipeline.get_duplicate_provider(messages.get_messages(request)) - - auth_states = pipeline.get_provider_user_states(user) - - context['auth']['providers'] = [{ - 'id': state.provider.provider_id, - 'name': state.provider.name, # The name of the provider e.g. Facebook - 'connected': state.has_account, # Whether the user's edX account is connected with the provider. - # If the user is not connected, they should be directed to this page to authenticate - # with the particular provider, as long as the provider supports initiating a login. - 'connect_url': pipeline.get_login_url( - state.provider.provider_id, - pipeline.AUTH_ENTRY_ACCOUNT_SETTINGS, - # The url the user should be directed to after the auth process has completed. - redirect_url=reverse('account_settings'), - ), - 'accepts_logins': state.provider.accepts_logins, - # If the user is connected, sending a POST request to this url removes the connection - # information for this provider from their edX account. - 'disconnect_url': pipeline.get_disconnect_url(state.provider.provider_id, state.association_id), - # We only want to include providers if they are either currently available to be logged - # in with, or if the user is already authenticated with them. - } for state in auth_states if state.provider.display_for_login or state.has_account] - - return context - - -def get_user_orders(user): - """Given a user, get the detail of all the orders from the Ecommerce service. - - Args: - user (User): The user to authenticate as when requesting ecommerce. - - Returns: - list of dict, representing orders returned by the Ecommerce service. - """ - user_orders = [] - commerce_configuration = CommerceConfiguration.current() - user_query = {'username': user.username} - - use_cache = commerce_configuration.is_cache_enabled - cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None - commerce_user_orders = get_api_data( - commerce_configuration, - 'orders', - api_client=get_ecommerce_api_client(user), - base_api_url=get_ecommerce_api_base_url(), - querystring=user_query, - cache_key=cache_key - ) - - for order in commerce_user_orders: - if order['status'].lower() == 'complete': - date_placed = datetime.strptime(order['date_placed'], "%Y-%m-%dT%H:%M:%SZ") - order_data = { - 'number': order['number'], - 'price': order['total_excl_tax'], - 'order_date': strftime_localized(date_placed, 'SHORT_DATE'), - 'receipt_url': EcommerceService().get_receipt_page_url(order['number']), - 'lines': order['lines'], - } - user_orders.append(order_data) - - return user_orders - - -def _get_extended_profile_fields(): - """Retrieve the extended profile fields from site configuration to be shown on the - Account Settings page - - Returns: - A list of dicts. Each dict corresponds to a single field. The keys per field are: - "field_name" : name of the field stored in user_profile.meta - "field_label" : The label of the field. - "field_type" : TextField or ListField - "field_options": a list of tuples for options in the dropdown in case of ListField - """ - - extended_profile_fields = [] - fields_already_showing = ['username', 'name', 'email', 'pref-lang', 'country', 'time_zone', 'level_of_education', - 'gender', 'year_of_birth', 'language_proficiencies', 'social_links'] - - field_labels_map = { - "first_name": _("First Name"), - "last_name": _("Last Name"), - "city": _("City"), - "state": _("State/Province/Region"), - "company": _("Company"), - "title": _("Title"), - "job_title": _("Job Title"), - "mailing_address": _("Mailing address"), - "goals": _("Tell us why you're interested in {platform_name}").format( - platform_name=configuration_helpers.get_value("PLATFORM_NAME", settings.PLATFORM_NAME) - ), - "profession": _("Profession"), - "specialty": _("Specialty"), - "work_experience": _("Work experience") - } - - extended_profile_field_names = configuration_helpers.get_value('extended_profile_fields', []) - for field_to_exclude in fields_already_showing: - if field_to_exclude in extended_profile_field_names: - extended_profile_field_names.remove(field_to_exclude) - - extended_profile_field_options = configuration_helpers.get_value('EXTRA_FIELD_OPTIONS', []) - extended_profile_field_option_tuples = {} - for field in extended_profile_field_options.keys(): - field_options = extended_profile_field_options[field] - extended_profile_field_option_tuples[field] = [(option.lower(), option) for option in field_options] - - for field in extended_profile_field_names: - field_dict = { - "field_name": field, - "field_label": field_labels_map.get(field, field), - } - - field_options = extended_profile_field_option_tuples.get(field) - if field_options: - field_dict["field_type"] = "ListField" - field_dict["field_options"] = field_options - else: - field_dict["field_type"] = "TextField" - extended_profile_fields.append(field_dict) - - return extended_profile_fields diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py b/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py deleted file mode 100644 index 782549aea0b..00000000000 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_filters.py +++ /dev/null @@ -1,241 +0,0 @@ -""" -Test that various filters are fired for views in the certificates app. -""" -from django.http import HttpResponse -from django.test import override_settings -from django.urls import reverse -from openedx_filters import PipelineStep -from openedx_filters.learning.filters import AccountSettingsRenderStarted -from rest_framework import status -from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase - -from openedx.core.djangolib.testing.utils import skip_unless_lms -from common.djangoapps.student.tests.factories import UserFactory - - -class TestRenderInvalidAccountSettings(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """ - Pipeline step that stops the course about render process. - """ - raise AccountSettingsRenderStarted.RenderInvalidAccountSettings( - "You can't access the account settings page.", - account_settings_template="static_templates/server-error.html", - ) - - -class TestRedirectToPage(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """ - Pipeline step that redirects to dashboard before rendering the account settings page. - - When raising RedirectToPage, this filter uses a redirect_to field handled by - the course about view that redirects to that URL. - """ - raise AccountSettingsRenderStarted.RedirectToPage( - "You can't access this page, redirecting to dashboard.", - redirect_to="/courses", - ) - - -class TestRedirectToDefaultPage(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """ - Pipeline step that redirects to dashboard before rendering the account settings page. - - When raising RedirectToPage, this filter uses a redirect_to field handled by - the course about view that redirects to that URL. - """ - raise AccountSettingsRenderStarted.RedirectToPage( - "You can't access this page, redirecting to dashboard." - ) - - -class TestRenderCustomResponse(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """Pipeline step that returns a custom response when rendering the account settings page.""" - response = HttpResponse("Here's the text of the web page.") - raise AccountSettingsRenderStarted.RenderCustomResponse( - "You can't access this page.", - response=response, - ) - - -class TestAccountSettingsRender(PipelineStep): - """ - Utility class used when getting steps for pipeline. - """ - - def run_filter(self, context, template_name): # pylint: disable=arguments-differ - """Pipeline step that returns a custom response when rendering the account settings page.""" - template_name = 'static_templates/about.html' - return { - "context": context, "template_name": template_name, - } - - -@skip_unless_lms -class TestAccountSettingsFilters(SharedModuleStoreTestCase): - """ - Tests for the Open edX Filters associated with the account settings proccess. - - This class guarantees that the following filters are triggered during the user's account settings rendering: - - - AccountSettingsRenderStarted - """ - def setUp(self): # pylint: disable=arguments-differ - super().setUp() - self.user = UserFactory.create( - username="somestudent", - first_name="Student", - last_name="Person", - email="robot@robot.org", - is_active=True, - password="password", - ) - self.client.login(username=self.user.username, password="password") - self.account_settings_url = '/account/settings' - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestAccountSettingsRender", - ], - "fail_silently": False, - }, - }, - ) - def test_account_settings_render_filter_executed(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestAccountSettingsRender - """ - response = self.client.get(self.account_settings_url) - self.assertEqual(response.status_code, status.HTTP_200_OK) - self.assertContains(response, "This page left intentionally blank. Feel free to add your own content.") - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRenderInvalidAccountSettings", # pylint: disable=line-too-long - ], - "fail_silently": False, - }, - }, - PLATFORM_NAME="My site", - ) - def test_account_settings_render_alternative(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestRenderInvalidAccountSettings # pylint: disable=line-too-long - """ - response = self.client.get(self.account_settings_url) - - self.assertContains(response, "There has been a 500 error on the My site servers") - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRenderCustomResponse", - ], - "fail_silently": False, - }, - }, - ) - def test_account_settings_render_custom_response(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestRenderCustomResponse - """ - response = self.client.get(self.account_settings_url) - - self.assertEqual(response.content, b"Here's the text of the web page.") - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRedirectToPage", - ], - "fail_silently": False, - }, - }, - ) - def test_account_settings_redirect_to_page(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestRedirectToPage - """ - response = self.client.get(self.account_settings_url) - - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertEqual('/courses', response.url) - - @override_settings( - OPEN_EDX_FILTERS_CONFIG={ - "org.openedx.learning.student.settings.render.started.v1": { - "pipeline": [ - "openedx.core.djangoapps.user_api.accounts.tests.test_filters.TestRedirectToDefaultPage", - ], - "fail_silently": False, - }, - }, - ) - def test_account_settings_redirect_default(self): - """ - Test whether the account settings filter is triggered before the user's - account settings page is rendered. - - Expected result: - - AccountSettingsRenderStarted is triggered and executes TestRedirectToDefaultPage - """ - response = self.client.get(self.account_settings_url) - - self.assertEqual(response.status_code, status.HTTP_302_FOUND) - self.assertEqual(f"{reverse('dashboard')}", response.url) - - @override_settings(OPEN_EDX_FILTERS_CONFIG={}) - def test_account_settings_render_without_filter_config(self): - """ - Test whether the course about filter is triggered before the course about - render without affecting its execution flow. - - Expected result: - - AccountSettingsRenderStarted executes a noop (empty pipeline). Without any - modification comparing it with the effects of TestAccountSettingsRender. - - The view response is HTTP_200_OK. - """ - response = self.client.get(self.account_settings_url) - - self.assertNotContains(response, "This page left intentionally blank. Feel free to add your own content.") diff --git a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py b/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py deleted file mode 100644 index badee6e8755..00000000000 --- a/openedx/core/djangoapps/user_api/accounts/tests/test_settings_views.py +++ /dev/null @@ -1,287 +0,0 @@ -""" Tests for views related to account settings. """ - - -from unittest import mock -from django.conf import settings -from django.contrib import messages -from django.contrib.messages.middleware import MessageMiddleware -from django.http import HttpRequest -from django.test import TestCase -from django.test.utils import override_settings -from django.urls import reverse -from requests import exceptions - -from edx_toggles.toggles.testutils import override_waffle_flag -from lms.djangoapps.commerce.models import CommerceConfiguration -from lms.djangoapps.commerce.tests import factories -from lms.djangoapps.commerce.tests.mocks import mock_get_orders -from openedx.core.djangoapps.dark_lang.models import DarkLangConfig -from openedx.core.djangoapps.lang_pref.tests.test_api import EN, LT_LT -from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin -from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory -from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin -from openedx.core.djangoapps.site_configuration.tests.test_util import with_site_configuration -from openedx.core.djangoapps.user_api.accounts.settings_views import account_settings_context, get_user_orders -from openedx.core.djangoapps.user_api.accounts.toggles import REDIRECT_TO_ACCOUNT_MICROFRONTEND -from openedx.core.djangoapps.user_api.tests.factories import UserPreferenceFactory -from openedx.core.djangolib.testing.utils import skip_unless_lms -from openedx.features.enterprise_support.utils import get_enterprise_readonly_account_fields -from common.djangoapps.student.tests.factories import UserFactory -from common.djangoapps.third_party_auth.tests.testutil import ThirdPartyAuthTestMixin - - -@skip_unless_lms -class AccountSettingsViewTest(ThirdPartyAuthTestMixin, SiteMixin, ProgramsApiConfigMixin, TestCase): - """ Tests for the account settings view. """ - - USERNAME = 'student' - PASSWORD = 'password' - FIELDS = [ - 'country', - 'gender', - 'language', - 'level_of_education', - 'password', - 'year_of_birth', - 'preferred_language', - 'time_zone', - ] - - @mock.patch("django.conf.settings.MESSAGE_STORAGE", 'django.contrib.messages.storage.cookie.CookieStorage') - def setUp(self): # pylint: disable=arguments-differ - super().setUp() - self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) - CommerceConfiguration.objects.create(cache_ttl=10, enabled=True) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - - self.request = HttpRequest() - self.request.user = self.user - - # For these tests, two third party auth providers are enabled by default: - self.configure_google_provider(enabled=True, visible=True) - self.configure_facebook_provider(enabled=True, visible=True) - - # Python-social saves auth failure notifcations in Django messages. - # See pipeline.get_duplicate_provider() for details. - self.request.COOKIES = {} - MessageMiddleware(get_response=lambda request: None).process_request(self.request) - messages.error(self.request, 'Facebook is already in use.', extra_tags='Auth facebook') - - @mock.patch('openedx.features.enterprise_support.api.enterprise_customer_for_request') - def test_context(self, mock_enterprise_customer_for_request): - self.request.site = SiteFactory.create() - UserPreferenceFactory(user=self.user, key='pref-lang', value='lt-lt') - DarkLangConfig( - released_languages='en', - changed_by=self.user, - enabled=True, - beta_languages='lt-lt', - enable_beta_languages=True - ).save() - mock_enterprise_customer_for_request.return_value = {} - - with override_settings(LANGUAGES=[EN, LT_LT], LANGUAGE_CODE='en'): - context = account_settings_context(self.request) - - user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) - assert context['user_accounts_api_url'] == user_accounts_api_url - - user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) - assert context['user_preferences_api_url'] == user_preferences_api_url - - for attribute in self.FIELDS: - assert attribute in context['fields'] - - assert context['user_accounts_api_url'] == reverse('accounts_api', kwargs={'username': self.user.username}) - assert context['user_preferences_api_url'] ==\ - reverse('preferences_api', kwargs={'username': self.user.username}) - - assert context['duplicate_provider'] == 'facebook' - assert context['auth']['providers'][0]['name'] == 'Facebook' - assert context['auth']['providers'][1]['name'] == 'Google' - - assert context['sync_learner_profile_data'] is False - assert context['edx_support_url'] == settings.SUPPORT_SITE_LINK - assert context['enterprise_name'] is None - assert context['enterprise_readonly_account_fields'] ==\ - {'fields': list(get_enterprise_readonly_account_fields(self.user))} - - expected_beta_language = {'code': 'lt-lt', 'name': settings.LANGUAGE_DICT.get('lt-lt')} - assert context['beta_language'] == expected_beta_language - - @with_site_configuration( - configuration={ - 'extended_profile_fields': ['work_experience'] - } - ) - def test_context_extended_profile(self): - """ - Test that if the field is available in extended_profile configuration then the field - will be sent in response. - """ - context = account_settings_context(self.request) - extended_pofile_field = context['extended_profile_fields'][0] - assert extended_pofile_field['field_name'] == 'work_experience' - assert extended_pofile_field['field_label'] == 'Work experience' - - @mock.patch('openedx.core.djangoapps.user_api.accounts.settings_views.enterprise_customer_for_request') - @mock.patch('openedx.features.enterprise_support.utils.third_party_auth.provider.Registry.get') - def test_context_for_enterprise_learner( - self, mock_get_auth_provider, mock_enterprise_customer_for_request - ): - dummy_enterprise_customer = { - 'uuid': 'real-ent-uuid', - 'name': 'Dummy Enterprise', - 'identity_provider': 'saml-ubc' - } - mock_enterprise_customer_for_request.return_value = dummy_enterprise_customer - self.request.site = SiteFactory.create() - mock_get_auth_provider.return_value.sync_learner_profile_data = True - context = account_settings_context(self.request) - - user_accounts_api_url = reverse("accounts_api", kwargs={'username': self.user.username}) - assert context['user_accounts_api_url'] == user_accounts_api_url - - user_preferences_api_url = reverse('preferences_api', kwargs={'username': self.user.username}) - assert context['user_preferences_api_url'] == user_preferences_api_url - - for attribute in self.FIELDS: - assert attribute in context['fields'] - - assert context['user_accounts_api_url'] == reverse('accounts_api', kwargs={'username': self.user.username}) - assert context['user_preferences_api_url'] ==\ - reverse('preferences_api', kwargs={'username': self.user.username}) - - assert context['duplicate_provider'] == 'facebook' - assert context['auth']['providers'][0]['name'] == 'Facebook' - assert context['auth']['providers'][1]['name'] == 'Google' - - assert context['sync_learner_profile_data'] == mock_get_auth_provider.return_value.sync_learner_profile_data - assert context['edx_support_url'] == settings.SUPPORT_SITE_LINK - assert context['enterprise_name'] == dummy_enterprise_customer['name'] - assert context['enterprise_readonly_account_fields'] ==\ - {'fields': list(get_enterprise_readonly_account_fields(self.user))} - - def test_view(self): - """ - Test that all fields are visible - """ - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - for attribute in self.FIELDS: - self.assertContains(response, attribute) - - def test_header_with_programs_listing_enabled(self): - """ - Verify that tabs header will be shown while program listing is enabled. - """ - self.create_programs_config() - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - self.assertContains(response, 'global-header') - - def test_header_with_programs_listing_disabled(self): - """ - Verify that nav header will be shown while program listing is disabled. - """ - self.create_programs_config(enabled=False) - view_path = reverse('account_settings') - response = self.client.get(path=view_path) - - self.assertContains(response, 'global-header') - - def test_commerce_order_detail(self): - """ - Verify that get_user_orders returns the correct order data. - """ - with mock_get_orders(): - order_detail = get_user_orders(self.user) - - for i, order in enumerate(mock_get_orders.default_response['results']): - expected = { - 'number': order['number'], - 'price': order['total_excl_tax'], - 'order_date': 'Jan 01, 2016', - 'receipt_url': '/checkout/receipt/?order_number=' + order['number'], - 'lines': order['lines'], - } - assert order_detail[i] == expected - - def test_commerce_order_detail_exception(self): - with mock_get_orders(exception=exceptions.HTTPError): - order_detail = get_user_orders(self.user) - - assert not order_detail - - def test_incomplete_order_detail(self): - response = { - 'results': [ - factories.OrderFactory( - status='Incomplete', - lines=[ - factories.OrderLineFactory( - product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory()]) - ) - ] - ) - ] - } - with mock_get_orders(response=response): - order_detail = get_user_orders(self.user) - - assert not order_detail - - def test_order_history_with_no_product(self): - response = { - 'results': [ - factories.OrderFactory( - lines=[ - factories.OrderLineFactory( - product=None - ), - factories.OrderLineFactory( - product=factories.ProductFactory(attribute_values=[factories.ProductAttributeFactory( - name='certificate_type', - value='verified' - )]) - ) - ] - ) - ] - } - with mock_get_orders(response=response): - order_detail = get_user_orders(self.user) - - assert len(order_detail) == 1 - - def test_redirect_view(self): - old_url_path = reverse('account_settings') - with override_waffle_flag(REDIRECT_TO_ACCOUNT_MICROFRONTEND, active=True): - # Test with waffle flag active and none site setting, redirects to microfrontend - response = self.client.get(path=old_url_path) - self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, fetch_redirect_response=False) - - # Test with waffle flag disabled and site setting disabled, does not redirect - response = self.client.get(path=old_url_path) - for attribute in self.FIELDS: - self.assertContains(response, attribute) - - # Test with site setting disabled, does not redirect - site_domain = 'othersite.example.com' - site = self.set_up_site(site_domain, { - 'SITE_NAME': site_domain, - 'ENABLE_ACCOUNT_MICROFRONTEND': False - }) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - response = self.client.get(path=old_url_path) - for attribute in self.FIELDS: - self.assertContains(response, attribute) - - # Test with site setting enabled, redirects to microfrontend - site.configuration.site_values['ENABLE_ACCOUNT_MICROFRONTEND'] = True - site.configuration.save() - site.__class__.objects.clear_cache() - response = self.client.get(path=old_url_path) - self.assertRedirects(response, settings.ACCOUNT_MICROFRONTEND_URL, fetch_redirect_response=False) diff --git a/openedx/core/djangoapps/user_api/accounts/toggles.py b/openedx/core/djangoapps/user_api/accounts/toggles.py deleted file mode 100644 index 80de4fa7569..00000000000 --- a/openedx/core/djangoapps/user_api/accounts/toggles.py +++ /dev/null @@ -1,44 +0,0 @@ -""" -Toggles for accounts related code. -""" - -from edx_toggles.toggles import WaffleFlag - -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers - -# .. toggle_name: order_history.redirect_to_microfrontend -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the order history page. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2019-04-11 -# .. toggle_target_removal_date: 2020-12-31 -# .. toggle_warning: Also set settings.ORDER_HISTORY_MICROFRONTEND_URL and site's -# ENABLE_ORDER_HISTORY_MICROFRONTEND. -# .. toggle_tickets: DEPR-17 -REDIRECT_TO_ORDER_HISTORY_MICROFRONTEND = WaffleFlag('order_history.redirect_to_microfrontend', __name__) - - -def should_redirect_to_order_history_microfrontend(): - return ( - configuration_helpers.get_value('ENABLE_ORDER_HISTORY_MICROFRONTEND') and - REDIRECT_TO_ORDER_HISTORY_MICROFRONTEND.is_enabled() - ) - - -# .. toggle_name: account.redirect_to_microfrontend -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the account page. -# Its action can be overridden using site's ENABLE_ACCOUNT_MICROFRONTEND setting. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2019-04-30 -# .. toggle_target_removal_date: 2021-12-31 -# .. toggle_warning: Also set settings.ACCOUNT_MICROFRONTEND_URL. -# .. toggle_tickets: DEPR-17 -REDIRECT_TO_ACCOUNT_MICROFRONTEND = WaffleFlag('account.redirect_to_microfrontend', __name__) - - -def should_redirect_to_account_microfrontend(): - return configuration_helpers.get_value('ENABLE_ACCOUNT_MICROFRONTEND', - REDIRECT_TO_ACCOUNT_MICROFRONTEND.is_enabled()) diff --git a/openedx/core/djangoapps/user_api/legacy_urls.py b/openedx/core/djangoapps/user_api/legacy_urls.py index b3f707f64b5..ad02f7f19ce 100644 --- a/openedx/core/djangoapps/user_api/legacy_urls.py +++ b/openedx/core/djangoapps/user_api/legacy_urls.py @@ -5,7 +5,6 @@ from rest_framework import routers from . import views as user_api_views -from .accounts.settings_views import account_settings from .models import UserPreference USER_API_ROUTER = routers.DefaultRouter() @@ -13,7 +12,6 @@ USER_API_ROUTER.register(r'user_prefs', user_api_views.UserPreferenceViewSet) urlpatterns = [ - path('account/settings', account_settings, name='account_settings'), path('user_api/v1/', include(USER_API_ROUTER.urls)), re_path( fr'^user_api/v1/preferences/(?P{UserPreference.KEY_REGEX})/users/$', diff --git a/openedx/core/djangoapps/user_api/management/commands/bulk_user_org_email_optout.py b/openedx/core/djangoapps/user_api/management/commands/bulk_user_org_email_optout.py index e465ff5610e..d194e58ee88 100644 --- a/openedx/core/djangoapps/user_api/management/commands/bulk_user_org_email_optout.py +++ b/openedx/core/djangoapps/user_api/management/commands/bulk_user_org_email_optout.py @@ -135,11 +135,10 @@ def handle(self, *args, **options): optout_rows[end_idx][0], optout_rows[end_idx][1], str(err)) raise - else: - cursor.execute('COMMIT;') - log.info("Committed opt-out for rows (%s, %s) through (%s, %s).", - optout_rows[start_idx][0], optout_rows[start_idx][1], - optout_rows[end_idx][0], optout_rows[end_idx][1]) + cursor.execute('COMMIT;') + log.info("Committed opt-out for rows (%s, %s) through (%s, %s).", + optout_rows[start_idx][0], optout_rows[start_idx][1], + optout_rows[end_idx][0], optout_rows[end_idx][1]) log.info("Sleeping %s seconds...", sleep_between) time.sleep(sleep_between) curr_row_idx += chunk_size diff --git a/openedx/core/djangoapps/user_authn/cookies.py b/openedx/core/djangoapps/user_authn/cookies.py index 24f929698fa..036baf2125b 100644 --- a/openedx/core/djangoapps/user_authn/cookies.py +++ b/openedx/core/djangoapps/user_authn/cookies.py @@ -6,6 +6,7 @@ import json import logging import time +from urllib.parse import urljoin from django.conf import settings from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user @@ -244,8 +245,8 @@ def _get_user_info_cookie_data(request, user): # External sites will need to have fallback mechanisms to handle this case # (most likely just hiding the links). try: - header_urls['account_settings'] = reverse('account_settings') - header_urls['learner_profile'] = reverse('learner_profile', kwargs={'username': user.username}) + header_urls['account_settings'] = settings.ACCOUNT_MICROFRONTEND_URL + header_urls['learner_profile'] = urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{user.username}') except NoReverseMatch: pass diff --git a/openedx/core/djangoapps/user_authn/tests/test_cookies.py b/openedx/core/djangoapps/user_authn/tests/test_cookies.py index 8a7841b3b98..d4d9a59fc9f 100644 --- a/openedx/core/djangoapps/user_authn/tests/test_cookies.py +++ b/openedx/core/djangoapps/user_authn/tests/test_cookies.py @@ -4,6 +4,7 @@ from datetime import date import json from unittest.mock import MagicMock, patch +from urllib.parse import urljoin from django.conf import settings from django.http import HttpResponse from django.test import RequestFactory, TestCase @@ -57,8 +58,8 @@ def _get_expected_image_urls(self): def _get_expected_header_urls(self): expected_header_urls = { 'logout': reverse('logout'), - 'account_settings': reverse('account_settings'), - 'learner_profile': reverse('learner_profile', kwargs={'username': self.user.username}), + 'account_settings': settings.ACCOUNT_MICROFRONTEND_URL, + 'learner_profile': urljoin(settings.PROFILE_MICROFRONTEND_URL, f'/u/{self.user.username}'), } block_url = retrieve_last_sitewide_block_completed(self.user) if block_url: diff --git a/openedx/core/djangoapps/user_authn/views/tests/test_login.py b/openedx/core/djangoapps/user_authn/views/tests/test_login.py index aa34a076d40..38649bb93a0 100644 --- a/openedx/core/djangoapps/user_authn/views/tests/test_login.py +++ b/openedx/core/djangoapps/user_authn/views/tests/test_login.py @@ -496,7 +496,7 @@ def test_login_user_info_cookie(self): # Check that the URLs are absolute for url in user_info["header_urls"].values(): - assert 'http://testserver/' in url + assert 'http://' in url def test_logout_deletes_mktg_cookies(self): response, _ = self._login_response(self.user_email, self.password) diff --git a/openedx/core/djangoapps/util/management/commands/dump_settings.py b/openedx/core/djangoapps/util/management/commands/dump_settings.py new file mode 100644 index 00000000000..965ea16c6d2 --- /dev/null +++ b/openedx/core/djangoapps/util/management/commands/dump_settings.py @@ -0,0 +1,100 @@ +""" +Defines the dump_settings management command. +""" +import inspect +import json +import re + +from django.conf import settings +from django.core.management.base import BaseCommand + + +SETTING_NAME_REGEX = re.compile(r'^[A-Z][A-Z0-9_]*$') + + +class Command(BaseCommand): + """ + Dump current Django settings to JSON for debugging/diagnostics. + + BEWARE: OUTPUT IS NOT SUITABLE FOR CONSUMPTION BY PRODUCTION SYSTEMS. + The purpose of this output is to be *helpful* for a *human* operator to understand how their settings are being + rendered and how they differ between different settings files. The serialization format is NOT perfect: there are + certain situations where two different settings will output identical JSON. For example, this command does NOT: + + disambiguate between lists and tuples: + * (1, 2, 3) # <-- this tuple will be printed out as [1, 2, 3] + * [1, 2, 3] + + disambiguate between sets and sorted lists: + * {2, 1, 3} # <-- this set will be printed out as [1, 2, 3] + * [1, 2, 3] + + disambiguate between internationalized and non-internationalized strings: + * _("hello") # <-- this will become just "hello" + * "hello" + + Furthermore, functions and classes are printed as JSON objects like: + { + "module": "path.to.module", + "qualname": "MyClass.MyInnerClass.my_method", // Or, "" + "source_hint": "MY_SETTING = lambda: x + y", // For s only + } + + And everything else will be stringified as its `repr(...)`. + """ + + def handle(self, *args, **kwargs): + """ + Handle the command. + """ + settings_json = { + name: _to_json_friendly_repr(getattr(settings, name)) + for name in dir(settings) + if SETTING_NAME_REGEX.match(name) + } + print(json.dumps(settings_json, indent=4)) + + +def _to_json_friendly_repr(value: object) -> object: + """ + Turn the value into something that we can print to a JSON file (that is: str, bool, None, int, float, list, dict). + + See the docstring of `Command` for warnings about this function's behavior. + """ + if isinstance(value, (type(None), bool, int, float, str)): + # All these types can be printed directly + return value + if isinstance(value, (list, tuple, set)): + if isinstance(value, set): + # Print sets by sorting them (so that order doesn't matter) into a JSON array. + elements = sorted(value) + else: + # Print both lists and tuples as JSON arrays. + elements = value + return [_to_json_friendly_repr(element) for ix, element in enumerate(elements)] + if isinstance(value, dict): + # Print dicts as JSON objects + for subkey in value.keys(): + if not isinstance(subkey, (str, int)): + raise ValueError(f"Unexpected dict key {subkey} of type {type(subkey)}") + return {subkey: _to_json_friendly_repr(subval) for subkey, subval in value.items()} + if proxy_args := getattr(value, "_proxy____args", None): + if len(proxy_args) == 1 and isinstance(proxy_args[0], str): + # Print gettext_lazy as simply the wrapped string + return proxy_args[0] + try: + module = value.__module__ + qualname = value.__qualname__ + except AttributeError: + pass + else: + # Handle functions and classes by printing their location (plus approximate source, for lambdas) + return { + "module": module, + "qualname": qualname, + **({ + "source_hint": inspect.getsource(value).strip(), + } if qualname == "" else {}), + } + # For all other objects, print the repr + return repr(value) diff --git a/openedx/core/djangoapps/util/tests/test_dump_settings.py b/openedx/core/djangoapps/util/tests/test_dump_settings.py new file mode 100644 index 00000000000..90171eb48c9 --- /dev/null +++ b/openedx/core/djangoapps/util/tests/test_dump_settings.py @@ -0,0 +1,64 @@ +""" +Basic tests for dump_settings management command. + +These are moreso testing that dump_settings works, less-so testing anything about the Django +settings files themselves. Remember that tests only run with (lms,cms)/envs/test.py, +which are based on (lms,cms)/envs/common.py, so these tests will not execute any of the +YAML-loading or post-processing defined in (lms,cms)/envs/production.py. +""" +import json + +from django.core.management import call_command + +from openedx.core.djangolib.testing.utils import skip_unless_lms, skip_unless_cms + + +@skip_unless_lms +def test_for_lms_settings(capsys): + """ + Ensure LMS's test settings can be dumped, and sanity-check them for certain values. + """ + dump = _get_settings_dump(capsys) + + # Check: something LMS-specific + assert dump['MODULESTORE_BRANCH'] == "published-only" + + # Check: tuples are converted to lists + assert isinstance(dump['XBLOCK_MIXINS'], list) + + # Check: classes are converted to dicts of info on the class location + assert {"module": "xmodule.x_module", "qualname": "XModuleMixin"} in dump['XBLOCK_MIXINS'] + + # Check: nested dictionaries come through OK, and int'l strings are just strings + assert dump['COURSE_ENROLLMENT_MODES']['audit']['display_name'] == "Audit" + + +@skip_unless_cms +def test_for_cms_settings(capsys): + """ + Ensure CMS's test settings can be dumped, and sanity-check them for certain values. + """ + dump = _get_settings_dump(capsys) + + # Check: something CMS-specific + assert dump['MODULESTORE_BRANCH'] == "draft-preferred" + + # Check: tuples are converted to lists + assert isinstance(dump['XBLOCK_MIXINS'], list) + + # Check: classes are converted to dicts of info on the class location + assert {"module": "xmodule.x_module", "qualname": "XModuleMixin"} in dump['XBLOCK_MIXINS'] + + # Check: nested dictionaries come through OK, and int'l strings are just strings + assert dump['COURSE_ENROLLMENT_MODES']['audit']['display_name'] == "Audit" + + +def _get_settings_dump(captured_sys): + """ + Call dump_settings, ensure no error output, and return parsed JSON. + """ + call_command('dump_settings') + out, err = captured_sys.readouterr() + assert out + assert not err + return json.loads(out) diff --git a/openedx/core/djangoapps/xblock/rest_api/views.py b/openedx/core/djangoapps/xblock/rest_api/views.py index a1fbd1e062a..0fd202b4eff 100644 --- a/openedx/core/djangoapps/xblock/rest_api/views.py +++ b/openedx/core/djangoapps/xblock/rest_api/views.py @@ -118,10 +118,12 @@ def embed_block_view(request, usage_key: UsageKeyV2, view_name: str): # for key in itertools.chain([block.scope_ids.usage_id], getattr(block, 'children', [])) # } lms_root_url = configuration_helpers.get_value('LMS_ROOT_URL', settings.LMS_ROOT_URL) + cms_root_url = configuration_helpers.get_value('CMS_ROOT_URL', settings.CMS_ROOT_URL) context = { 'fragment': fragment, 'handler_urls_json': json.dumps(handler_urls), 'lms_root_url': lms_root_url, + 'cms_root_url': cms_root_url, 'view_name': view_name, 'is_development': settings.DEBUG, } diff --git a/openedx/core/djangoapps/xblock/runtime/runtime.py b/openedx/core/djangoapps/xblock/runtime/runtime.py index 2bf84e99541..8829cedac77 100644 --- a/openedx/core/djangoapps/xblock/runtime/runtime.py +++ b/openedx/core/djangoapps/xblock/runtime/runtime.py @@ -18,7 +18,7 @@ from web_fragments.fragment import Fragment from xblock.core import XBlock from xblock.exceptions import NoSuchServiceError -from xblock.field_data import FieldData, SplitFieldData +from xblock.field_data import DictFieldData, FieldData, SplitFieldData from xblock.fields import Scope, ScopeIds from xblock.runtime import IdReader, KvsFieldData, MemoryIdManager, Runtime @@ -351,8 +351,10 @@ def _init_field_data_for_block(self, block: XBlock) -> FieldData: Initialize the FieldData implementation for the specified XBlock """ if self.user is None: - # No user is specified, so we want to throw an error if anything attempts to read/write user-specific fields - student_data_store = None + # No user is specified, so we want to ignore any user-specific data. We cannot throw an + # error here because the XBlock loading process will write to the user_state if we have + # mutable fields. + student_data_store = DictFieldData({}) elif self.user.is_anonymous: # This is an anonymous (non-registered) user: assert isinstance(self.user_id, str) and self.user_id.startswith("anon") diff --git a/openedx/core/lib/api/view_utils.py b/openedx/core/lib/api/view_utils.py index 054755ae3cc..d876e49ae57 100644 --- a/openedx/core/lib/api/view_utils.py +++ b/openedx/core/lib/api/view_utils.py @@ -265,8 +265,7 @@ def __len__(self): def __iter__(self): # Yield all the known data first - for item in self._data: - yield item + yield from self._data # Capture and yield data from the underlying iterator # until it is exhausted diff --git a/openedx/core/lib/celery/task_utils.py b/openedx/core/lib/celery/task_utils.py index 738f074be68..9a54f1b3a55 100644 --- a/openedx/core/lib/celery/task_utils.py +++ b/openedx/core/lib/celery/task_utils.py @@ -50,9 +50,8 @@ def emulate_http_request(site=None, user=None, middleware_classes=None): for middleware in reversed(middleware_instances): _run_method_if_implemented(middleware, 'process_exception', request, exc) raise - else: - for middleware in reversed(middleware_instances): - _run_method_if_implemented(middleware, 'process_response', request, response) + for middleware in reversed(middleware_instances): + _run_method_if_implemented(middleware, 'process_response', request, response) def _run_method_if_implemented(instance, method_name, *args, **kwargs): diff --git a/openedx/core/lib/derived.py b/openedx/core/lib/derived.py index a62731ef543..745d6f1262f 100644 --- a/openedx/core/lib/derived.py +++ b/openedx/core/lib/derived.py @@ -4,71 +4,121 @@ other settings have been set. The derived setting can also be overridden by setting the derived setting to an actual value. """ +from __future__ import annotations +import re import sys +import types +import typing as t -# Global list holding all settings which will be derived. -__DERIVED = [] +Settings: t.TypeAlias = types.ModuleType -def derived(*settings): + +T = t.TypeVar('T') + + +class Derived(t.Generic[T]): """ - Registers settings which are derived from other settings. - Can be called multiple times to add more derived settings. + A temporary Django setting value, defined with a function which generates the setting's eventual value. - Args: - settings (str): Setting names to register. + Said function (`calculate_value`) should accept a Django settings module, and return a calculated value. + + To ensure that application code does not encounter an instance of this class in your settings, be sure to call + `derive_settings` somewhere in your terminal settings file. """ - __DERIVED.extend(settings) + def __init__(self, calculate_value: t.Callable[[Settings], T]): + self.calculate_value = calculate_value -def derived_collection_entry(collection_name, *accessors): +def derive_settings(module_name: str) -> None: """ - Registers a setting which is a dictionary or list and needs a derived value for a particular entry. - Can be called multiple times to add more derived settings. + In the Django settings module at `module_name`, replace `Derived` values with their cacluated values. - Args: - collection_name (str): Name of setting which contains a dictionary or list. - accessors (int|str): Sequence of dictionary keys and list indices in the collection (and - collections within it) leading to the value which will be derived. - For example: 0, 'DIRS'. + The replacement happens recursively for any values or containers defined by a Django setting name (which is: an + uppercase top-level variable name which is not prefixed by an underscore). Within containers, """ - __DERIVED.append((collection_name, accessors)) + module = sys.modules[module_name] + _derive_dict(module, vars(module), key_filter=_key_is_a_setting_name) + + +_SETTING_NAME_REGEX = re.compile(r'^[A-Z][A-Z0-9_]*$') + + +def _key_is_a_setting_name(key: str) -> bool: + return bool(_SETTING_NAME_REGEX.match(key)) -def derive_settings(module_name): +def _match_every_key(_key: str) -> bool: + return True + + +def _derive_recursively(settings: Settings, value: t.Any) -> t.Any: """ - Derives all registered settings and sets them onto a particular module. - Skips deriving settings that are set to a value. + Recursively evaluate `Derived` objects` in `value` and any child containers. Return evaluated version of `value`. - Args: - module_name (str): Name of module to which the derived settings will be added. + * If `value` is a `Derived` object, then use `settings` to calculate and return its value. + * If `value` is a mutable container, then recursively evaluate it in-place. + * If `value` is an immutable container, then recursively evalute a shallow copy of it. + Keep in mind that immutable containers (particularly: tuples) can contain mutable containers. In such a case, the + original and shallow-copied mutable containers will both reference the same child mutable container object. """ - module = sys.modules[module_name] - for derived in __DERIVED: # lint-amnesty, pylint: disable=redefined-outer-name - if isinstance(derived, str): - setting = getattr(module, derived) - if callable(setting): - setting_val = setting(module) - setattr(module, derived, setting_val) - elif isinstance(derived, tuple): - # If a tuple, two elements are expected - else ignore. - if len(derived) == 2: - # The first element is the name of the attribute which is expected to be a dictionary or list. - # The second element is a list of string keys in that dictionary leading to a derived setting. - collection = getattr(module, derived[0]) - accessors = derived[1] - for accessor in accessors[:-1]: - collection = collection[accessor] - setting = collection[accessors[-1]] - if callable(setting): - setting_val = setting(module) - collection[accessors[-1]] = setting_val - - -def clear_for_tests(): - """ - Clears all settings to be derived. For tests only. - """ - global __DERIVED - __DERIVED = [] + if isinstance(value, Derived): + return value.calculate_value(settings) + elif isinstance(value, dict): + return _derive_dict(settings, value) + elif isinstance(value, list): + return _derive_list(settings, value) + elif isinstance(value, tuple): + return _derive_tuple(settings, value) + elif isinstance(value, frozenset): + return _derive_frozenset(settings, value) + else: + return value + + +def _derive_dict(settings: Settings, the_dict: dict, key_filter: t.Callable[[str], bool] = _match_every_key) -> dict: + """ + Recursively evaluate `Derived` objects in `the_dict` and any child containers. Modifies `the_dict` in place. + + Optionally takes a `key_filter`. Items that do not match the provided `key_filter` will be left alone. + """ + for key, value in the_dict.items(): + if key_filter(key): + the_dict[key] = _derive_recursively(settings, value) + return the_dict + + +def _derive_list(settings: Settings, the_list: list) -> list: + """ + Recursively evaluate `Derived` objects in `the_list` and any child containers. Modifies `the_list` in place. + """ + for ix in range(len(the_list)): + the_list[ix] = _derive_recursively(settings, the_list[ix]) + return the_list + + +def _derive_tuple(settings: Settings, tup: tuple) -> tuple: + """ + Recursively evaluate `Derived` objects in `tup` and any child containers. Returns a shallow copy of `tup`. + """ + return tuple(_derive_recursively(settings, item) for item in tup) + + +def _derive_set(settings: Settings, the_set: set) -> set: + """ + Recursively evaluate `Derived` objects in `the_set` and any child containers. Modifies `the_set` in-place. + """ + for original in the_set: + derived = _derive_recursively(settings, original) + if derived != original: + the_set.remove(original) + the_set.add(derived) + return the_set + + +def _derive_frozenset(settings: Settings, the_set: frozenset) -> frozenset: + """ + Recursively evaluate `Derived` objects in `the_set` and any child containers. Returns a shallow copy of `the_set`. + """ + return frozenset(_derive_recursively(settings, item) for item in the_set) diff --git a/openedx/core/lib/jwt.py b/openedx/core/lib/jwt.py new file mode 100644 index 00000000000..47642b86956 --- /dev/null +++ b/openedx/core/lib/jwt.py @@ -0,0 +1,91 @@ +""" +JWT Token handling and signing functions. +""" + +import json +from time import time + +from django.conf import settings +from jwkest import Expired, Invalid, MissingKey, jwk +from jwkest.jws import JWS + + +def create_jwt(lms_user_id, expires_in_seconds, additional_token_claims, now=None): + """ + Produce an encoded JWT (string) indicating some temporary permission for the indicated user. + + What permission that is must be encoded in additional_claims. + Arguments: + lms_user_id (int): LMS user ID this token is being generated for + expires_in_seconds (int): Time to token expiry, specified in seconds. + additional_token_claims (dict): Additional claims to include in the token. + now(int): optional now value for testing + """ + now = now or int(time()) + + payload = { + 'lms_user_id': lms_user_id, + 'exp': now + expires_in_seconds, + 'iat': now, + 'iss': settings.TOKEN_SIGNING['JWT_ISSUER'], + 'version': settings.TOKEN_SIGNING['JWT_SUPPORTED_VERSION'], + } + payload.update(additional_token_claims) + return _encode_and_sign(payload) + + +def _encode_and_sign(payload): + """ + Encode and sign the provided payload. + + The signing key and algorithm are pulled from settings. + """ + keys = jwk.KEYS() + + serialized_keypair = json.loads(settings.TOKEN_SIGNING['JWT_PRIVATE_SIGNING_JWK']) + keys.add(serialized_keypair) + algorithm = settings.TOKEN_SIGNING['JWT_SIGNING_ALGORITHM'] + + data = json.dumps(payload) + jws = JWS(data, alg=algorithm) + return jws.sign_compact(keys=keys) + + +def unpack_jwt(token, lms_user_id, now=None): + """ + Unpack and verify an encoded JWT. + + Validate the user and expiration. + + Arguments: + token (string): The token to be unpacked and verified. + lms_user_id (int): LMS user ID this token should match with. + now (int): Optional now value for testing. + + Returns a valid, decoded json payload (string). + """ + now = now or int(time()) + payload = _unpack_and_verify(token) + + if "lms_user_id" not in payload: + raise MissingKey("LMS user id is missing") + if "exp" not in payload: + raise MissingKey("Expiration is missing") + if payload["lms_user_id"] != lms_user_id: + raise Invalid("User does not match") + if payload["exp"] < now: + raise Expired("Token is expired") + + return payload + + +def _unpack_and_verify(token): + """ + Unpack and verify the provided token. + + The signing key and algorithm are pulled from settings. + """ + keys = jwk.KEYS() + keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET']) + decoded = JWS().verify_compact(token.encode('utf-8'), keys) + return decoded diff --git a/openedx/core/lib/tests/test_derived.py b/openedx/core/lib/tests/test_derived.py index ef3f9804243..7d3f70fa6ab 100644 --- a/openedx/core/lib/tests/test_derived.py +++ b/openedx/core/lib/tests/test_derived.py @@ -5,7 +5,7 @@ import sys from unittest import TestCase -from openedx.core.lib.derived import derived, derived_collection_entry, derive_settings, clear_for_tests +from openedx.core.lib.derived import Derived, derive_settings class TestDerivedSettings(TestCase): @@ -14,18 +14,14 @@ class TestDerivedSettings(TestCase): """ def setUp(self): super().setUp() - clear_for_tests() self.module = sys.modules[__name__] self.module.SIMPLE_VALUE = 'paneer' - self.module.DERIVED_VALUE = lambda settings: 'mutter ' + settings.SIMPLE_VALUE - self.module.ANOTHER_DERIVED_VALUE = lambda settings: settings.DERIVED_VALUE + ' with naan' + self.module.DERIVED_VALUE = Derived(lambda settings: 'mutter ' + settings.SIMPLE_VALUE) + self.module.ANOTHER_DERIVED_VALUE = Derived(lambda settings: settings.DERIVED_VALUE + ' with naan') self.module.UNREGISTERED_DERIVED_VALUE = lambda settings: settings.SIMPLE_VALUE + ' is cheese' - derived('DERIVED_VALUE', 'ANOTHER_DERIVED_VALUE') self.module.DICT_VALUE = {} - self.module.DICT_VALUE['test_key'] = lambda settings: settings.DERIVED_VALUE * 3 - derived_collection_entry('DICT_VALUE', 'test_key') - self.module.DICT_VALUE['list_key'] = ['not derived', lambda settings: settings.DERIVED_VALUE] - derived_collection_entry('DICT_VALUE', 'list_key', 1) + self.module.DICT_VALUE['test_key'] = Derived(lambda settings: settings.DERIVED_VALUE * 3) + self.module.DICT_VALUE['list_key'] = ['not derived', Derived(lambda settings: settings.DERIVED_VALUE)] def test_derived_settings_are_derived(self): derive_settings(__name__) diff --git a/openedx/core/lib/tests/test_jwt.py b/openedx/core/lib/tests/test_jwt.py new file mode 100644 index 00000000000..7a678dd3c09 --- /dev/null +++ b/openedx/core/lib/tests/test_jwt.py @@ -0,0 +1,129 @@ +""" +Tests for token handling +""" +import unittest + +from django.conf import settings +from jwkest import BadSignature, Expired, Invalid, MissingKey, jwk +from jwkest.jws import JWS + +from openedx.core.djangolib.testing.utils import skip_unless_lms +from openedx.core.lib.jwt import _encode_and_sign, create_jwt, unpack_jwt + + +test_user_id = 121 +invalid_test_user_id = 120 +test_timeout = 60 +test_now = 1661432902 +test_claims = {"foo": "bar", "baz": "quux", "meaning": 42} +expected_full_token = { + "lms_user_id": test_user_id, + "iat": 1661432902, + "exp": 1661432902 + 60, + "iss": "token-test-issuer", # these lines from test_settings.py + "version": "1.2.0", # these lines from test_settings.py +} + + +@skip_unless_lms +class TestSign(unittest.TestCase): + """ + Tests for JWT creation and signing. + """ + + def test_create_jwt(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + + decoded = _verify_jwt(token) + self.assertEqual(expected_full_token, decoded) + + def test_create_jwt_with_claims(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + decoded = _verify_jwt(token) + self.assertEqual(expected_token_with_claims, decoded) + + def test_malformed_token(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + token = token + "a" + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + with self.assertRaises(BadSignature): + _verify_jwt(token) + + +def _verify_jwt(jwt_token): + """ + Helper function which verifies the signature and decodes the token + from string back to claims form + """ + keys = jwk.KEYS() + keys.load_jwks(settings.TOKEN_SIGNING['JWT_PUBLIC_SIGNING_JWK_SET']) + decoded = JWS().verify_compact(jwt_token.encode('utf-8'), keys) + return decoded + + +@skip_unless_lms +class TestUnpack(unittest.TestCase): + """ + Tests for JWT unpacking. + """ + + def test_unpack_jwt(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + decoded = unpack_jwt(token, test_user_id, test_now) + + self.assertEqual(expected_full_token, decoded) + + def test_unpack_jwt_with_claims(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + decoded = unpack_jwt(token, test_user_id, test_now) + + self.assertEqual(expected_token_with_claims, decoded) + + def test_malformed_token(self): + token = create_jwt(test_user_id, test_timeout, test_claims, test_now) + token = token + "a" + + expected_token_with_claims = expected_full_token.copy() + expected_token_with_claims.update(test_claims) + + with self.assertRaises(BadSignature): + unpack_jwt(token, test_user_id, test_now) + + def test_unpack_token_with_invalid_user(self): + token = create_jwt(invalid_test_user_id, test_timeout, {}, test_now) + + with self.assertRaises(Invalid): + unpack_jwt(token, test_user_id, test_now) + + def test_unpack_expired_token(self): + token = create_jwt(test_user_id, test_timeout, {}, test_now) + + with self.assertRaises(Expired): + unpack_jwt(token, test_user_id, test_now + test_timeout + 1) + + def test_missing_expired_lms_user_id(self): + payload = expected_full_token.copy() + del payload['lms_user_id'] + token = _encode_and_sign(payload) + + with self.assertRaises(MissingKey): + unpack_jwt(token, test_user_id, test_now) + + def test_missing_expired_key(self): + payload = expected_full_token.copy() + del payload['exp'] + token = _encode_and_sign(payload) + + with self.assertRaises(MissingKey): + unpack_jwt(token, test_user_id, test_now) diff --git a/openedx/core/lib/xblock_pipeline/finder.py b/openedx/core/lib/xblock_pipeline/finder.py index 63c7122a6c3..4893e5e0480 100644 --- a/openedx/core/lib/xblock_pipeline/finder.py +++ b/openedx/core/lib/xblock_pipeline/finder.py @@ -4,13 +4,13 @@ import os from datetime import datetime +import importlib.resources as resources from django.contrib.staticfiles import utils from django.contrib.staticfiles.finders import BaseFinder from django.contrib.staticfiles.storage import FileSystemStorage from django.core.files.storage import Storage from django.utils import timezone -from importlib.resources import files from xblock.core import XBlock from openedx.core.lib.xblock_utils import xblock_resource_pkg @@ -38,7 +38,8 @@ def path(self, name): """ Returns a file system filename for the specified file name. """ - return str(files(self.module).joinpath(self.base_dir, name)) + with resources.as_file(resources.files(self.module.rsplit('.', 1)[0]) / self.base_dir / name) as file_path: + return str(file_path) def exists(self, path): # lint-amnesty, pylint: disable=arguments-differ """ @@ -46,23 +47,23 @@ def exists(self, path): # lint-amnesty, pylint: disable=arguments-differ """ if self.base_dir is None: return False - - return os.path.exists(os.path.join(self.base_dir, path)) + return (resources.files(self.module.rsplit('.', 1)[0]) / self.base_dir / path).exists() def listdir(self, path): """ Lists the directories beneath the specified path. """ directories = [] - files_p = [] - for item in files(self.module).joinpath(self.base_dir, path).iterdir(): - __, file_extension = os.path.splitext(item) - if file_extension not in [".py", ".pyc", ".scss"]: - if files(self.module).joinpath(self.base_dir, path, item).is_dir(): - directories.append(item) - else: - files_p.append(item) - return directories, files_p + files = [] + base_path = resources.files(self.module.rsplit('.', 1)[0]) / self.base_dir / path + if base_path.is_dir(): + for item in base_path.iterdir(): + if item.suffix not in [".py", ".pyc", ".scss"]: + if item.is_dir(): + directories.append(item.name) + else: + files.append(item.name) + return directories, files def open(self, name, mode='rb'): """ diff --git a/openedx/features/enterprise_support/tests/test_utils.py b/openedx/features/enterprise_support/tests/test_utils.py index d693f6be72b..48a331083f4 100644 --- a/openedx/features/enterprise_support/tests/test_utils.py +++ b/openedx/features/enterprise_support/tests/test_utils.py @@ -29,6 +29,7 @@ ) from openedx.features.enterprise_support.utils import ( ENTERPRISE_HEADER_LINKS, + _user_has_social_auth_record, clear_data_consent_share_cache, enterprise_fields_only, fetch_enterprise_customer_by_id, @@ -539,6 +540,54 @@ def test_get_provider_login_url_with_redirect_url(self, mock_tpa, mock_next_logi ) assert not mock_next_login_url.called + @mock.patch('openedx.features.enterprise_support.utils.UserSocialAuth') + @mock.patch('openedx.features.enterprise_support.utils.third_party_auth') + def test_user_has_social_auth_record(self, mock_tpa, mock_user_social_auth): + user = mock.Mock() + enterprise_customer = { + 'identity_providers': [ + {'provider_id': 'mock-idp'}, + ], + } + mock_idp = mock.MagicMock(backend_name='mock-backend') + mock_tpa.provider.Registry.get.return_value = mock_idp + mock_user_social_auth.objects.select_related.return_value.filter.return_value.exists.return_value = True + + result = _user_has_social_auth_record(user, enterprise_customer) + assert result is True + + mock_tpa.provider.Registry.get.assert_called_once_with(provider_id='mock-idp') + mock_user_social_auth.objects.select_related.assert_called_once_with('user') + mock_user_social_auth.objects.select_related.return_value.filter.assert_called_once_with( + provider__in=['mock-backend'], user=user + ) + + @mock.patch('openedx.features.enterprise_support.utils.UserSocialAuth') + @mock.patch('openedx.features.enterprise_support.utils.third_party_auth') + def test_user_has_social_auth_record_no_providers(self, mock_tpa, mock_user_social_auth): + user = mock.Mock() + enterprise_customer = { + 'identity_providers': [], + } + + result = _user_has_social_auth_record(user, enterprise_customer) + assert result is False + + assert not mock_tpa.provider.Registry.get.called + assert not mock_user_social_auth.objects.select_related.called + + @mock.patch('openedx.features.enterprise_support.utils.UserSocialAuth') + @mock.patch('openedx.features.enterprise_support.utils.third_party_auth') + def test_user_has_social_auth_record_no_enterprise_customer(self, mock_tpa, mock_user_social_auth): + user = mock.Mock() + enterprise_customer = None + + result = _user_has_social_auth_record(user, enterprise_customer) + assert result is False + + assert not mock_tpa.provider.Registry.get.called + assert not mock_user_social_auth.objects.select_related.called + @override_settings(FEATURES=FEATURES_WITH_ENTERPRISE_ENABLED) @skip_unless_lms diff --git a/openedx/features/enterprise_support/utils.py b/openedx/features/enterprise_support/utils.py index 6b007ebed57..9e99bf59926 100644 --- a/openedx/features/enterprise_support/utils.py +++ b/openedx/features/enterprise_support/utils.py @@ -294,9 +294,12 @@ def _user_has_social_auth_record(user, enterprise_customer): identity_provider = third_party_auth.provider.Registry.get( provider_id=idp['provider_id'] ) - provider_backend_names.append(identity_provider.backend_name) - return UserSocialAuth.objects.select_related('user').\ - filter(provider__in=provider_backend_names, user=user).exists() + if identity_provider and hasattr(identity_provider, 'backend_name'): + provider_backend_names.append(identity_provider.backend_name) + + if provider_backend_names: + return UserSocialAuth.objects.select_related('user').\ + filter(provider__in=provider_backend_names, user=user).exists() return False diff --git a/openedx/features/learner_profile/README.rst b/openedx/features/learner_profile/README.rst deleted file mode 100644 index 0dce8e10ccd..00000000000 --- a/openedx/features/learner_profile/README.rst +++ /dev/null @@ -1,8 +0,0 @@ -Learner Profile ---------------- - -This directory contains a Django application that provides a view to render -a profile for any Open edX learner. See `Exploring Your Dashboard and Profile`_ -for more details. - -.. _Exploring Your Dashboard and Profile: https://edx.readthedocs.io/projects/open-edx-learner-guide/en/latest/SFD_dashboard_profile_SectionHead.html?highlight=profile diff --git a/openedx/features/learner_profile/__init__.py b/openedx/features/learner_profile/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html b/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html deleted file mode 100644 index 61c139210a2..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/fixtures/learner_profile.html +++ /dev/null @@ -1,40 +0,0 @@ -
    -
    -
    - - -
    -
    -

    - - - - - Loading - -

    -
    - -
    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 deleted file mode 100644 index d4f81ae7f47..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/learner_profile_factory.js +++ /dev/null @@ -1,219 +0,0 @@ -(function(define) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'backbone', - 'logger', - 'edx-ui-toolkit/js/utils/string-utils', - 'edx-ui-toolkit/js/pagination/paging-collection', - 'js/student_account/models/user_account_model', - 'js/student_account/models/user_preferences_model', - 'js/views/fields', - 'learner_profile/js/views/learner_profile_fields', - 'learner_profile/js/views/learner_profile_view', - 'js/student_account/views/account_settings_fields', - 'js/views/message_banner', - 'string_utils' - ], function(gettext, $, _, Backbone, Logger, StringUtils, PagingCollection, AccountSettingsModel, - AccountPreferencesModel, FieldsView, LearnerProfileFieldsView, LearnerProfileView, - AccountSettingsFieldViews, MessageBannerView) { - return function(options) { - var $learnerProfileElement = $('.wrapper-profile'); - - var accountSettingsModel = new AccountSettingsModel( - _.extend( - options.account_settings_data, - { - default_public_account_fields: options.default_public_account_fields, - parental_consent_age_limit: options.parental_consent_age_limit, - enable_coppa_compliance: options.enable_coppa_compliance - } - ), - {parse: true} - ); - var AccountPreferencesModelWithDefaults = AccountPreferencesModel.extend({ - defaults: { - account_privacy: options.default_visibility - } - }); - var accountPreferencesModel = new AccountPreferencesModelWithDefaults(options.preferences_data); - - var editable = options.own_profile ? 'toggle' : 'never'; - - var messageView = new MessageBannerView({ - el: $('.message-banner') - }); - - var accountPrivacyFieldView, - profileImageFieldView, - usernameFieldView, - nameFieldView, - sectionOneFieldViews, - sectionTwoFieldViews, - learnerProfileView, - getProfileVisibility, - showLearnerProfileView; - - accountSettingsModel.url = options.accounts_api_url; - accountPreferencesModel.url = options.preferences_api_url; - - accountPrivacyFieldView = new LearnerProfileFieldsView.AccountPrivacyFieldView({ - model: accountPreferencesModel, - required: true, - editable: 'always', - showMessages: false, - title: gettext('Profile Visibility:'), - valueAttribute: 'account_privacy', - options: [ - ['private', gettext('Limited Profile')], - ['all_users', gettext('Full Profile')] - ], - helpMessage: '', - accountSettingsPageUrl: options.account_settings_page_url, - persistChanges: true - }); - - profileImageFieldView = new LearnerProfileFieldsView.ProfileImageFieldView({ - model: accountSettingsModel, - valueAttribute: 'profile_image', - editable: editable === 'toggle', - messageView: messageView, - imageMaxBytes: options.profile_image_max_bytes, - imageMinBytes: options.profile_image_min_bytes, - imageUploadUrl: options.profile_image_upload_url, - imageRemoveUrl: options.profile_image_remove_url - }); - - usernameFieldView = new FieldsView.ReadonlyFieldView({ - model: accountSettingsModel, - screenReaderTitle: gettext('Username'), - valueAttribute: 'username', - helpMessage: '' - }); - - nameFieldView = new FieldsView.ReadonlyFieldView({ - model: accountSettingsModel, - screenReaderTitle: gettext('Full Name'), - valueAttribute: 'name', - helpMessage: '' - }); - - sectionOneFieldViews = [ - new LearnerProfileFieldsView.SocialLinkIconsView({ - model: accountSettingsModel, - socialPlatforms: options.social_platforms, - ownProfile: options.own_profile - }), - - new FieldsView.DateFieldView({ - title: gettext('Joined'), - titleVisible: true, - model: accountSettingsModel, - screenReaderTitle: gettext('Joined Date'), - valueAttribute: 'date_joined', - helpMessage: '', - userLanguage: accountSettingsModel.get('language'), - userTimezone: accountPreferencesModel.get('time_zone'), - dateFormat: 'MMMM YYYY' // not localized, but hopefully ok. - }), - - new FieldsView.DropdownFieldView({ - title: gettext('Location'), - titleVisible: true, - model: accountSettingsModel, - screenReaderTitle: gettext('Country'), - required: true, - editable: editable, - showMessages: false, - placeholderValue: gettext('Add Country'), - valueAttribute: 'country', - options: options.country_options, - helpMessage: '', - persistChanges: true - }), - - new AccountSettingsFieldViews.LanguageProficienciesFieldView({ - title: gettext('Language'), - titleVisible: true, - model: accountSettingsModel, - screenReaderTitle: gettext('Preferred Language'), - required: false, - editable: editable, - showMessages: false, - placeholderValue: gettext('Add language'), - valueAttribute: 'language_proficiencies', - options: options.language_options, - helpMessage: '', - persistChanges: true - }) - ]; - - sectionTwoFieldViews = [ - new FieldsView.TextareaFieldView({ - model: accountSettingsModel, - editable: editable, - showMessages: false, - title: gettext('About me'), - // eslint-disable-next-line max-len - placeholderValue: gettext("Tell other learners a little about yourself: where you live, what your interests are, why you're taking courses, or what you hope to learn."), - valueAttribute: 'bio', - helpMessage: '', - persistChanges: true, - messagePosition: 'header', - maxCharacters: 300 - }) - ]; - - learnerProfileView = new LearnerProfileView({ - el: $learnerProfileElement, - ownProfile: options.own_profile, - has_preferences_access: options.has_preferences_access, - accountSettingsModel: accountSettingsModel, - preferencesModel: accountPreferencesModel, - accountPrivacyFieldView: accountPrivacyFieldView, - profileImageFieldView: profileImageFieldView, - usernameFieldView: usernameFieldView, - nameFieldView: nameFieldView, - sectionOneFieldViews: sectionOneFieldViews, - sectionTwoFieldViews: sectionTwoFieldViews, - platformName: options.platform_name - }); - - getProfileVisibility = function() { - if (options.has_preferences_access) { - return accountPreferencesModel.get('account_privacy'); - } else { - return accountSettingsModel.get('profile_is_public') ? 'all_users' : 'private'; - } - }; - - showLearnerProfileView = function() { - // Record that the profile page was viewed - Logger.log('edx.user.settings.viewed', { - page: 'profile', - visibility: getProfileVisibility(), - user_id: options.profile_user_id - }); - - // Render the view for the first time - learnerProfileView.render(); - }; - - if (options.has_preferences_access) { - if (accountSettingsModel.get('requires_parental_consent')) { - accountPreferencesModel.set('account_privacy', 'private'); - } - } - showLearnerProfileView(); - - return { - accountSettingsModel: accountSettingsModel, - accountPreferencesModel: accountPreferencesModel, - learnerProfileView: learnerProfileView, - }; - }; - }); -}).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 deleted file mode 100644 index 0019c2fc136..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/learner_profile_factory_spec.js +++ /dev/null @@ -1,79 +0,0 @@ -define( - [ - 'backbone', 'jquery', 'underscore', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'learner_profile/js/spec_helpers/helpers', - 'js/views/fields', - 'js/student_account/models/user_account_model', - 'js/student_account/models/user_preferences_model', - 'learner_profile/js/views/learner_profile_view', - 'learner_profile/js/views/learner_profile_fields', - 'learner_profile/js/learner_profile_factory', - 'js/views/message_banner' - ], - function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, FieldViews, - UserAccountModel, UserPreferencesModel, LearnerProfileView, LearnerProfileFields, LearnerProfilePage) { - 'use strict'; - - describe('edx.user.LearnerProfileFactory', function() { - var createProfilePage; - - beforeEach(function() { - loadFixtures('learner_profile/fixtures/learner_profile.html'); - }); - - afterEach(function() { - Backbone.history.stop(); - }); - - createProfilePage = function(ownProfile, options) { - return new LearnerProfilePage({ - accounts_api_url: Helpers.USER_ACCOUNTS_API_URL, - preferences_api_url: Helpers.USER_PREFERENCES_API_URL, - own_profile: ownProfile, - account_settings_page_url: Helpers.USER_ACCOUNTS_API_URL, - country_options: Helpers.FIELD_OPTIONS, - language_options: Helpers.FIELD_OPTIONS, - has_preferences_access: true, - profile_image_max_bytes: Helpers.IMAGE_MAX_BYTES, - profile_image_min_bytes: Helpers.IMAGE_MIN_BYTES, - profile_image_upload_url: Helpers.IMAGE_UPLOAD_API_URL, - profile_image_remove_url: Helpers.IMAGE_REMOVE_API_URL, - default_visibility: 'all_users', - platform_name: 'edX', - find_courses_url: '/courses/', - account_settings_data: Helpers.createAccountSettingsData(options), - preferences_data: Helpers.createUserPreferencesData() - }); - }; - - it('renders the full profile for a user', function() { - var context, - learnerProfileView; - AjaxHelpers.requests(this); - context = createProfilePage(true); - learnerProfileView = context.learnerProfileView; - - // sets the profile for full view. - context.accountPreferencesModel.set({account_privacy: 'all_users'}); - LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, false); - }); - - it("renders the limited profile for undefined 'year_of_birth'", function() { - var context = createProfilePage(true, {year_of_birth: '', requires_parental_consent: true}), - learnerProfileView = context.learnerProfileView; - - LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); - }); - - it('renders the limited profile for under 13 users', function() { - var context = createProfilePage( - true, - {year_of_birth: new Date().getFullYear() - 10, requires_parental_consent: true} - ); - var learnerProfileView = context.learnerProfileView; - LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); - }); - }); - }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js deleted file mode 100644 index 49b3dbc630d..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_fields_spec.js +++ /dev/null @@ -1,381 +0,0 @@ -define( - [ - 'backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'js/student_account/models/user_account_model', - 'learner_profile/js/views/learner_profile_fields', - 'js/views/message_banner' - ], - function(Backbone, $, _, AjaxHelpers, TemplateHelpers, Helpers, UserAccountModel, LearnerProfileFields, - MessageBannerView) { - 'use strict'; - - describe('edx.user.LearnerProfileFields', function() { - var MOCK_YEAR_OF_BIRTH = 1989; - var MOCK_IMAGE_MAX_BYTES = 64; - var MOCK_IMAGE_MIN_BYTES = 16; - - var createImageView = function(options) { - var yearOfBirth = _.isUndefined(options.yearOfBirth) ? MOCK_YEAR_OF_BIRTH : options.yearOfBirth; - var imageMaxBytes = _.isUndefined(options.imageMaxBytes) ? MOCK_IMAGE_MAX_BYTES : options.imageMaxBytes; - var imageMinBytes = _.isUndefined(options.imageMinBytes) ? MOCK_IMAGE_MIN_BYTES : options.imageMinBytes; - var messageView; - - var imageData = { - image_url_large: '/media/profile-images/default.jpg', - has_image: !!options.hasImage - }; - - var accountSettingsModel = new UserAccountModel(); - accountSettingsModel.set({profile_image: imageData}); - accountSettingsModel.set({year_of_birth: yearOfBirth}); - accountSettingsModel.set({requires_parental_consent: !!_.isEmpty(yearOfBirth)}); - - accountSettingsModel.url = Helpers.USER_ACCOUNTS_API_URL; - - messageView = new MessageBannerView({ - el: $('.message-banner') - }); - - return new LearnerProfileFields.ProfileImageFieldView({ - model: accountSettingsModel, - valueAttribute: 'profile_image', - editable: options.ownProfile, - messageView: messageView, - imageMaxBytes: imageMaxBytes, - imageMinBytes: imageMinBytes, - imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL, - imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL - }); - }; - - var createSocialLinksView = function(ownProfile, socialPlatformLinks) { - var accountSettingsModel = new UserAccountModel(); - accountSettingsModel.set({social_platforms: socialPlatformLinks}); - - return new LearnerProfileFields.SocialLinkIconsView({ - model: accountSettingsModel, - socialPlatforms: ['twitter', 'facebook', 'linkedin'], - ownProfile: ownProfile - }); - }; - - var createFakeImageFile = function(size) { - var fileFakeData = 'i63ljc6giwoskyb9x5sw0169bdcmcxr3cdz8boqv0lik971972cmd6yknvcxr5sw0nvc169bdcmcxsdf'; - return new Blob( - [fileFakeData.substr(0, size)], - {type: 'image/jpg'} - ); - }; - - var initializeUploader = function(view) { - view.$('.upload-button-input').fileupload({ - url: Helpers.IMAGE_UPLOAD_API_URL, - type: 'POST', - add: view.fileSelected, - done: view.imageChangeSucceeded, - fail: view.imageChangeFailed - }); - }; - - beforeEach(function() { - loadFixtures('learner_profile/fixtures/learner_profile.html'); - TemplateHelpers.installTemplate('templates/fields/field_image'); - TemplateHelpers.installTemplate('templates/fields/message_banner'); - TemplateHelpers.installTemplate('learner_profile/templates/social_icons'); - }); - - afterEach(function() { - // image_field.js's window.onBeforeUnload breaks Karma in Chrome, clean it up after each test - $(window).off('beforeunload'); - }); - - describe('ProfileImageFieldView', function() { - var verifyImageUploadButtonMessage = function(view, inProgress) { - var iconName = inProgress ? 'fa-spinner' : 'fa-camera'; - var message = inProgress ? view.titleUploading : view.uploadButtonTitle(); - expect(view.$('.upload-button-icon span').attr('class')).toContain(iconName); - expect(view.$('.upload-button-title').text().trim()).toBe(message); - }; - - var verifyImageRemoveButtonMessage = function(view, inProgress) { - var iconName = inProgress ? 'fa-spinner' : 'fa-remove'; - var message = inProgress ? view.titleRemoving : view.removeButtonTitle(); - expect(view.$('.remove-button-icon span').attr('class')).toContain(iconName); - expect(view.$('.remove-button-title').text().trim()).toBe(message); - }; - - it('can upload profile image', function() { - var requests = AjaxHelpers.requests(this); - var imageName = 'profile_image.jpg'; - var imageView = createImageView({ownProfile: true, hasImage: false}); - var data; - imageView.render(); - - initializeUploader(imageView); - - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - // For default image, image title should be `Upload an image` - verifyImageUploadButtonMessage(imageView, false); - - // Add image to upload queue. Validate the image size and send POST request to upload image - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); - - // Verify image upload progress message - verifyImageUploadButtonMessage(imageView, true); - - // Verify if POST request received for image upload - AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); - - // Send 204 NO CONTENT to confirm the image upload success - AjaxHelpers.respondWithNoContent(requests); - - // Upon successful image upload, account settings model will be fetched to - // get the url for newly uploaded image, So we need to send the response for that GET - data = { - profile_image: { - image_url_large: '/media/profile-images/' + imageName, - has_image: true - } - }; - AjaxHelpers.respondWithJson(requests, data); - - // Verify uploaded image name - expect(imageView.$('.image-frame').attr('src')).toContain(imageName); - - // Remove button should be present after successful image upload - expect(imageView.$('.u-field-remove-button').css('display') !== 'none').toBeTruthy(); - - // After image upload, image title should be `Change image` - verifyImageUploadButtonMessage(imageView, false); - }); - - it('can remove profile image', function() { - var requests = AjaxHelpers.requests(this); - var imageView = createImageView({ownProfile: true, hasImage: false}); - var data; - imageView.render(); - - // Verify image remove title - verifyImageRemoveButtonMessage(imageView, false); - - imageView.$('.u-field-remove-button').click(); - - // Verify image remove progress message - verifyImageRemoveButtonMessage(imageView, true); - - // Verify if POST request received for image remove - AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_REMOVE_API_URL, null); - - // Send 204 NO CONTENT to confirm the image removal success - AjaxHelpers.respondWithNoContent(requests); - - // Upon successful image removal, account settings model will be fetched to get default image url - // So we need to send the response for that GET - data = { - profile_image: { - image_url_large: '/media/profile-images/default.jpg', - has_image: false - } - }; - AjaxHelpers.respondWithJson(requests, data); - - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - }); - - it("can't remove default profile image", function() { - var imageView = createImageView({ownProfile: true, hasImage: false}); - imageView.render(); - - spyOn(imageView, 'clickedRemoveButton'); - - // Remove button should not be present for default image - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-remove-button').click(); - - // Remove button click handler should not be called - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it("can't upload image having size greater than max size", function() { - var imageView = createImageView({ownProfile: true, hasImage: false}); - imageView.render(); - - initializeUploader(imageView); - - // Add image to upload queue, this will validate the image size - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(70)]}); - - // Verify error message - expect($('.message-banner').text().trim()) - .toBe('The file must be smaller than 64 bytes in size.'); - }); - - it("can't upload image having size less than min size", function() { - var imageView = createImageView({ownProfile: true, hasImage: false}); - imageView.render(); - - initializeUploader(imageView); - - // Add image to upload queue, this will validate the image size - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(10)]}); - - // Verify error message - expect($('.message-banner').text().trim()).toBe('The file must be at least 16 bytes in size.'); - }); - - it("can't upload and remove image if parental consent required", function() { - var imageView = createImageView({ownProfile: true, hasImage: false, yearOfBirth: ''}); - imageView.render(); - - spyOn(imageView, 'clickedUploadButton'); - spyOn(imageView, 'clickedRemoveButton'); - - expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-upload-button').click(); - imageView.$('.u-field-remove-button').click(); - - expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it("can't upload and remove image on others profile", function() { - var imageView = createImageView({ownProfile: false}); - imageView.render(); - - spyOn(imageView, 'clickedUploadButton'); - spyOn(imageView, 'clickedRemoveButton'); - - expect(imageView.$('.u-field-upload-button').css('display') === 'none').toBeTruthy(); - expect(imageView.$('.u-field-remove-button').css('display') === 'none').toBeTruthy(); - - imageView.$('.u-field-upload-button').click(); - imageView.$('.u-field-remove-button').click(); - - expect(imageView.clickedUploadButton).not.toHaveBeenCalled(); - expect(imageView.clickedRemoveButton).not.toHaveBeenCalled(); - }); - - it('shows message if we try to navigate away during image upload/remove', function() { - var imageView = createImageView({ownProfile: true, hasImage: false}); - spyOn(imageView, 'onBeforeUnload'); - imageView.render(); - - initializeUploader(imageView); - - // Add image to upload queue, this will validate image size and send POST request to upload image - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); - - // Verify image upload progress message - verifyImageUploadButtonMessage(imageView, true); - - window.onbeforeunload = null; - $(window).trigger('beforeunload'); - expect(imageView.onBeforeUnload).toHaveBeenCalled(); - }); - - it('shows error message for HTTP 500', function() { - var requests = AjaxHelpers.requests(this); - var imageView = createImageView({ownProfile: true, hasImage: false}); - imageView.render(); - - initializeUploader(imageView); - - // Add image to upload queue. Validate the image size and send POST request to upload image - imageView.$('.upload-button-input').fileupload('add', {files: [createFakeImageFile(60)]}); - - // Verify image upload progress message - verifyImageUploadButtonMessage(imageView, true); - - // Verify if POST request received for image upload - AjaxHelpers.expectRequest(requests, 'POST', Helpers.IMAGE_UPLOAD_API_URL, new FormData()); - - // Send HTTP 500 - AjaxHelpers.respondWithError(requests); - - expect($('.message-banner').text().trim()).toBe(imageView.errorMessage); - }); - }); - - describe('SocialLinkIconsView', function() { - var socialPlatformLinks, - socialLinkData, - socialLinksView, - socialPlatform, - $icon; - - it('icons are visible and links to social profile if added in account settings', function() { - socialPlatformLinks = { - twitter: { - platform: 'twitter', - social_link: 'https://www.twitter.com/edX' - }, - facebook: { - platform: 'facebook', - social_link: 'https://www.facebook.com/edX' - }, - linkedin: { - platform: 'linkedin', - social_link: '' - } - }; - - socialLinksView = createSocialLinksView(true, socialPlatformLinks); - - // Icons should be present and contain links if defined - for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top - socialPlatform = Object.keys(socialPlatformLinks)[i]; - socialLinkData = socialPlatformLinks[socialPlatform]; - if (socialLinkData.social_link) { - // Icons with a social_link value should be displayed with a surrounding link - $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); - expect($icon).toExist(); - expect($icon.parent().is('a')); - } else { - // Icons without a social_link value should be displayed without a surrounding link - $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); - expect($icon).toExist(); - expect(!$icon.parent().is('a')); - } - } - }); - - it('icons are not visible on a profile with no links', function() { - socialPlatformLinks = { - twitter: { - platform: 'twitter', - social_link: '' - }, - facebook: { - platform: 'facebook', - social_link: '' - }, - linkedin: { - platform: 'linkedin', - social_link: '' - } - }; - - socialLinksView = createSocialLinksView(false, socialPlatformLinks); - - // Icons should not be present if not defined on another user's profile - for (var i = 0; i < Object.keys(socialPlatformLinks); i++) { // eslint-disable-line vars-on-top - socialPlatform = Object.keys(socialPlatformLinks)[i]; - socialLinkData = socialPlatformLinks[socialPlatform]; - $icon = socialLinksView.$('span.fa-' + socialPlatform + '-square'); - expect($icon).toBe(null); - } - }); - }); - }); - }); 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 deleted file mode 100644 index 21d2dd0f5df..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/learner_profile_view_spec.js +++ /dev/null @@ -1,218 +0,0 @@ -/* eslint-disable vars-on-top */ -define( - [ - 'gettext', - 'backbone', - 'jquery', - 'underscore', - 'edx-ui-toolkit/js/pagination/paging-collection', - 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers', - 'common/js/spec_helpers/template_helpers', - 'js/spec/student_account/helpers', - 'learner_profile/js/spec_helpers/helpers', - 'js/views/fields', - 'js/student_account/models/user_account_model', - 'js/student_account/models/user_preferences_model', - 'learner_profile/js/views/learner_profile_fields', - 'learner_profile/js/views/learner_profile_view', - 'js/student_account/views/account_settings_fields', - 'js/views/message_banner' - ], - function(gettext, Backbone, $, _, PagingCollection, AjaxHelpers, TemplateHelpers, Helpers, LearnerProfileHelpers, - FieldViews, UserAccountModel, AccountPreferencesModel, LearnerProfileFields, LearnerProfileView, - AccountSettingsFieldViews, MessageBannerView) { - 'use strict'; - - describe('edx.user.LearnerProfileView', function() { - var createLearnerProfileView = function(ownProfile, accountPrivacy, profileIsPublic) { - var accountSettingsModel = new UserAccountModel(); - accountSettingsModel.set(Helpers.createAccountSettingsData()); - accountSettingsModel.set({profile_is_public: profileIsPublic}); - accountSettingsModel.set({profile_image: Helpers.PROFILE_IMAGE}); - - var accountPreferencesModel = new AccountPreferencesModel(); - accountPreferencesModel.set({account_privacy: accountPrivacy}); - - accountPreferencesModel.url = Helpers.USER_PREFERENCES_API_URL; - - var editable = ownProfile ? 'toggle' : 'never'; - - var accountPrivacyFieldView = new LearnerProfileFields.AccountPrivacyFieldView({ - model: accountPreferencesModel, - required: true, - editable: 'always', - showMessages: false, - title: 'edX learners can see my:', - valueAttribute: 'account_privacy', - options: [ - ['all_users', 'Full Profile'], - ['private', 'Limited Profile'] - ], - helpMessage: '', - accountSettingsPageUrl: '/account/settings/' - }); - - var messageView = new MessageBannerView({ - el: $('.message-banner') - }); - - var profileImageFieldView = new LearnerProfileFields.ProfileImageFieldView({ - model: accountSettingsModel, - valueAttribute: 'profile_image', - editable: editable, - messageView: messageView, - imageMaxBytes: Helpers.IMAGE_MAX_BYTES, - imageMinBytes: Helpers.IMAGE_MIN_BYTES, - imageUploadUrl: Helpers.IMAGE_UPLOAD_API_URL, - imageRemoveUrl: Helpers.IMAGE_REMOVE_API_URL - }); - - var usernameFieldView = new FieldViews.ReadonlyFieldView({ - model: accountSettingsModel, - valueAttribute: 'username', - helpMessage: '' - }); - - var nameFieldView = new FieldViews.ReadonlyFieldView({ - model: accountSettingsModel, - valueAttribute: 'name', - helpMessage: '' - }); - - var sectionOneFieldViews = [ - new LearnerProfileFields.SocialLinkIconsView({ - model: accountSettingsModel, - socialPlatforms: Helpers.SOCIAL_PLATFORMS, - ownProfile: true - }), - - new FieldViews.DropdownFieldView({ - title: gettext('Location'), - model: accountSettingsModel, - required: false, - editable: editable, - showMessages: false, - placeholderValue: '', - valueAttribute: 'country', - options: Helpers.FIELD_OPTIONS, - helpMessage: '' - }), - - new AccountSettingsFieldViews.LanguageProficienciesFieldView({ - title: gettext('Language'), - model: accountSettingsModel, - required: false, - editable: editable, - showMessages: false, - placeholderValue: 'Add language', - valueAttribute: 'language_proficiencies', - options: Helpers.FIELD_OPTIONS, - helpMessage: '' - }), - - new FieldViews.DateFieldView({ - model: accountSettingsModel, - valueAttribute: 'date_joined', - helpMessage: '' - }) - ]; - - var sectionTwoFieldViews = [ - new FieldViews.TextareaFieldView({ - model: accountSettingsModel, - editable: editable, - showMessages: false, - title: 'About me', - placeholderValue: 'Tell other edX learners a little about yourself: where you live, ' - + "what your interests are, why you're taking courses on edX, or what you hope to learn.", - valueAttribute: 'bio', - helpMessage: '', - messagePosition: 'header' - }) - ]; - - return new LearnerProfileView( - { - el: $('.wrapper-profile'), - ownProfile: ownProfile, - hasPreferencesAccess: true, - accountSettingsModel: accountSettingsModel, - preferencesModel: accountPreferencesModel, - accountPrivacyFieldView: accountPrivacyFieldView, - usernameFieldView: usernameFieldView, - nameFieldView: nameFieldView, - profileImageFieldView: profileImageFieldView, - sectionOneFieldViews: sectionOneFieldViews, - sectionTwoFieldViews: sectionTwoFieldViews, - }); - }; - - beforeEach(function() { - loadFixtures('learner_profile/fixtures/learner_profile.html'); - }); - - afterEach(function() { - Backbone.history.stop(); - }); - - it('shows loading error correctly', function() { - var learnerProfileView = createLearnerProfileView(false, 'all_users'); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - learnerProfileView.showLoadingError(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, true); - }); - - it('renders all fields as expected for self with full access', function() { - var learnerProfileView = createLearnerProfileView(true, 'all_users', true); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView); - }); - - it('renders all fields as expected for self with limited access', function() { - var learnerProfileView = createLearnerProfileView(true, 'private', false); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView); - }); - - it('renders the fields as expected for others with full access', function() { - var learnerProfileView = createLearnerProfileView(false, 'all_users', true); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - LearnerProfileHelpers.expectProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); - }); - - it('renders the fields as expected for others with limited access', function() { - var learnerProfileView = createLearnerProfileView(false, 'private', false); - - Helpers.expectLoadingIndicatorIsVisible(learnerProfileView, true); - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - - learnerProfileView.render(); - - Helpers.expectLoadingErrorIsVisible(learnerProfileView, false); - LearnerProfileHelpers.expectLimitedProfileSectionsAndFieldsToBeRendered(learnerProfileView, true); - }); - }); - }); diff --git a/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js b/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js deleted file mode 100644 index d0e22d670be..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec/views/section_two_tab_spec.js +++ /dev/null @@ -1,113 +0,0 @@ -/* eslint-disable vars-on-top */ -define( - [ - 'backbone', 'jquery', 'underscore', - 'js/spec/student_account/helpers', - 'learner_profile/js/views/section_two_tab', - 'js/views/fields', - 'js/student_account/models/user_account_model' - ], - function(Backbone, $, _, Helpers, SectionTwoTabView, FieldViews, UserAccountModel) { - 'use strict'; - - describe('edx.user.SectionTwoTab', function() { - var createSectionTwoView = function(ownProfile, profileIsPublic) { - var accountSettingsModel = new UserAccountModel(); - accountSettingsModel.set(Helpers.createAccountSettingsData()); - accountSettingsModel.set({profile_is_public: profileIsPublic}); - accountSettingsModel.set({profile_image: Helpers.PROFILE_IMAGE}); - - var editable = ownProfile ? 'toggle' : 'never'; - - var sectionTwoFieldViews = [ - new FieldViews.TextareaFieldView({ - model: accountSettingsModel, - editable: editable, - showMessages: false, - title: 'About me', - placeholderValue: 'Tell other edX learners a little about yourself: where you live, ' - + "what your interests are, why you're taking courses on edX, or what you hope to learn.", - valueAttribute: 'bio', - helpMessage: '', - messagePosition: 'header' - }) - ]; - - return new SectionTwoTabView({ - viewList: sectionTwoFieldViews, - showFullProfile: function() { - return profileIsPublic; - }, - ownProfile: ownProfile - }); - }; - - it('full profile displayed for public profile', function() { - var view = createSectionTwoView(false, true); - view.render(); - var bio = view.$el.find('.u-field-bio'); - expect(bio.length).toBe(1); - }); - - it('profile field parts are actually rendered for public profile', function() { - var view = createSectionTwoView(false, true); - _.each(view.options.viewList, function(fieldView) { - spyOn(fieldView, 'render').and.callThrough(); - }); - view.render(); - _.each(view.options.viewList, function(fieldView) { - expect(fieldView.render).toHaveBeenCalled(); - }); - }); - - var testPrivateProfile = function(ownProfile, messageString) { - var view = createSectionTwoView(ownProfile, false); - view.render(); - var bio = view.$el.find('.u-field-bio'); - expect(bio.length).toBe(0); - var msg = view.$el.find('span.profile-private-message'); - expect(msg.length).toBe(1); - expect(_.count(msg.html(), messageString)).toBeTruthy(); - }; - - it('no profile when profile is private for other people', function() { - testPrivateProfile(false, 'This learner is currently sharing a limited profile'); - }); - - it('no profile when profile is private for the user herself', function() { - testPrivateProfile(true, 'You are currently sharing a limited profile'); - }); - - var testProfilePrivatePartsDoNotRender = function(ownProfile) { - var view = createSectionTwoView(ownProfile, false); - _.each(view.options.viewList, function(fieldView) { - spyOn(fieldView, 'render'); - }); - view.render(); - _.each(view.options.viewList, function(fieldView) { - expect(fieldView.render).not.toHaveBeenCalled(); - }); - }; - - it('profile field parts are not rendered for private profile for owner', function() { - testProfilePrivatePartsDoNotRender(true); - }); - - it('profile field parts are not rendered for private profile for other people', function() { - testProfilePrivatePartsDoNotRender(false); - }); - - it('does not allow fields to be edited when visiting a profile for other people', function() { - var view = createSectionTwoView(false, true); - var bio = view.options.viewList[0]; - expect(bio.editable).toBe('never'); - }); - - it("allows fields to be edited when visiting one's own profile", function() { - var view = createSectionTwoView(true, true); - var bio = view.options.viewList[0]; - expect(bio.editable).toBe('toggle'); - }); - }); - } -); 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 deleted file mode 100644 index e1369284a4b..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/spec_helpers/helpers.js +++ /dev/null @@ -1,133 +0,0 @@ -define(['underscore', 'URI', 'edx-ui-toolkit/js/utils/spec-helpers/ajax-helpers'], function(_, URI, AjaxHelpers) { - 'use strict'; - - var expectProfileElementContainsField = function(element, view) { - var titleElement, fieldTitle; - var $element = $(element); - - // Avoid testing for elements without titles - titleElement = $element.find('.u-field-title'); - if (titleElement.length === 0) { - return; - } - - fieldTitle = titleElement.text().trim(); - if (!_.isUndefined(view.options.title) && !_.isUndefined(fieldTitle)) { - expect(fieldTitle).toBe(view.options.title); - } - - if ('fieldValue' in view || 'imageUrl' in view) { - if ('imageUrl' in view) { - expect($($element.find('.image-frame')[0]).attr('src')).toBe(view.imageUrl()); - } else if (view.fieldType === 'date') { - expect(view.fieldValue()).toBe(view.timezoneFormattedDate()); - } else if (view.fieldValue()) { - expect(view.fieldValue()).toBe(view.modelValue()); - } else if ('optionForValue' in view) { - expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe( - view.displayValue(view.modelValue()) - ); - } else { - expect($($element.find('.u-field-value .u-field-value-readonly')[0]).text()).toBe(view.modelValue()); - } - } else { - throw new Error('Unexpected field type: ' + view.fieldType); - } - }; - - var expectProfilePrivacyFieldTobeRendered = function(learnerProfileView, othersProfile) { - var $accountPrivacyElement = $('.wrapper-profile-field-account-privacy'); - var $privacyFieldElement = $($accountPrivacyElement).find('.u-field'); - - if (othersProfile) { - expect($privacyFieldElement.length).toBe(0); - } else { - expect($privacyFieldElement.length).toBe(1); - expectProfileElementContainsField($privacyFieldElement, learnerProfileView.options.accountPrivacyFieldView); - } - }; - - var expectSectionOneTobeRendered = function(learnerProfileView) { - var sectionOneFieldElements = $(learnerProfileView.$('.wrapper-profile-section-one')) - .find('.u-field, .social-links'); - - expect(sectionOneFieldElements.length).toBe(7); - expectProfileElementContainsField(sectionOneFieldElements[0], learnerProfileView.options.profileImageFieldView); - expectProfileElementContainsField(sectionOneFieldElements[1], learnerProfileView.options.usernameFieldView); - expectProfileElementContainsField(sectionOneFieldElements[2], learnerProfileView.options.nameFieldView); - - _.each(_.rest(sectionOneFieldElements, 3), function(sectionFieldElement, fieldIndex) { - expectProfileElementContainsField( - sectionFieldElement, - learnerProfileView.options.sectionOneFieldViews[fieldIndex] - ); - }); - }; - - var expectSectionTwoTobeRendered = function(learnerProfileView) { - var $sectionTwoElement = $('.wrapper-profile-section-two'); - var $sectionTwoFieldElements = $($sectionTwoElement).find('.u-field'); - - expect($sectionTwoFieldElements.length).toBe(learnerProfileView.options.sectionTwoFieldViews.length); - - _.each($sectionTwoFieldElements, function(sectionFieldElement, fieldIndex) { - expectProfileElementContainsField( - sectionFieldElement, - learnerProfileView.options.sectionTwoFieldViews[fieldIndex] - ); - }); - }; - - var expectProfileSectionsAndFieldsToBeRendered = function(learnerProfileView, othersProfile) { - expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile); - expectSectionOneTobeRendered(learnerProfileView); - expectSectionTwoTobeRendered(learnerProfileView); - }; - - var expectLimitedProfileSectionsAndFieldsToBeRendered = function(learnerProfileView, othersProfile) { - var sectionOneFieldElements = $('.wrapper-profile-section-one').find('.u-field'); - - expectProfilePrivacyFieldTobeRendered(learnerProfileView, othersProfile); - - expect(sectionOneFieldElements.length).toBe(2); - expectProfileElementContainsField( - sectionOneFieldElements[0], - learnerProfileView.options.profileImageFieldView - ); - expectProfileElementContainsField( - sectionOneFieldElements[1], - learnerProfileView.options.usernameFieldView - ); - - if (othersProfile) { - expect($('.profile-private-message').text()) - .toBe('This learner is currently sharing a limited profile.'); - } else { - expect($('.profile-private-message').text()).toBe('You are currently sharing a limited profile.'); - } - }; - - var expectProfileSectionsNotToBeRendered = function() { - expect($('.wrapper-profile-field-account-privacy').length).toBe(0); - expect($('.wrapper-profile-section-one').length).toBe(0); - expect($('.wrapper-profile-section-two').length).toBe(0); - }; - - var expectTabbedViewToBeUndefined = function(requests, tabbedViewView) { - // Unrelated initial request, no badge request - expect(requests.length).toBe(1); - expect(tabbedViewView).toBe(undefined); - }; - - var expectTabbedViewToBeShown = function(tabbedViewView) { - expect(tabbedViewView.$el.find('.page-content-nav').is(':visible')).toBe(true); - }; - - return { - expectLimitedProfileSectionsAndFieldsToBeRendered: expectLimitedProfileSectionsAndFieldsToBeRendered, - expectProfileSectionsAndFieldsToBeRendered: expectProfileSectionsAndFieldsToBeRendered, - expectProfileSectionsNotToBeRendered: expectProfileSectionsNotToBeRendered, - expectTabbedViewToBeUndefined: expectTabbedViewToBeUndefined, - expectTabbedViewToBeShown: expectTabbedViewToBeShown - }; -}); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js b/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js deleted file mode 100644 index 807e3e4c5b8..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_fields.js +++ /dev/null @@ -1,169 +0,0 @@ -/* eslint-disable no-underscore-dangle */ -(function(define) { - 'use strict'; - - define([ - 'gettext', - 'jquery', - 'underscore', - 'backbone', - 'edx-ui-toolkit/js/utils/string-utils', - 'edx-ui-toolkit/js/utils/html-utils', - 'js/views/fields', - 'js/views/image_field', - 'text!learner_profile/templates/social_icons.underscore', - 'backbone-super' - ], function(gettext, $, _, Backbone, StringUtils, HtmlUtils, FieldViews, ImageFieldView, socialIconsTemplate) { - var LearnerProfileFieldViews = {}; - - LearnerProfileFieldViews.AccountPrivacyFieldView = FieldViews.DropdownFieldView.extend({ - - events: { - 'click button.btn-change-privacy': 'finishEditing', - 'change select': 'showSaveButton' - }, - - render: function() { - this._super(); - this.showNotificationMessage(); - this.updateFieldValue(); - return this; - }, - - showNotificationMessage: function() { - var accountSettingsLink = HtmlUtils.joinHtml( - HtmlUtils.interpolateHtml( - HtmlUtils.HTML(''), {settings_url: this.options.accountSettingsPageUrl} - ), - gettext('Account Settings page.'), - HtmlUtils.HTML('') - ); - if (this.profileIsPrivate) { - this._super( - HtmlUtils.interpolateHtml( - gettext('You must specify your birth year before you can share your full profile. To specify your birth year, go to the {account_settings_page_link}'), // eslint-disable-line max-len - {account_settings_page_link: accountSettingsLink} - ) - ); - } else if (this.requiresParentalConsent) { - this._super( - HtmlUtils.interpolateHtml( - gettext('You must be over 13 to share a full profile. If you are over 13, make sure that you have specified a birth year on the {account_settings_page_link}'), // eslint-disable-line max-len - {account_settings_page_link: accountSettingsLink} - ) - ); - } else { - this._super(''); - } - }, - - updateFieldValue: function() { - if (!this.isAboveMinimumAge) { - this.$('.u-field-value select').val('private'); - this.disableField(true); - } - }, - - showSaveButton: function() { - $('.btn-change-privacy').removeClass('hidden'); - } - }); - - LearnerProfileFieldViews.ProfileImageFieldView = ImageFieldView.extend({ - - screenReaderTitle: gettext('Profile Image'), - - imageUrl: function() { - return this.model.profileImageUrl(); - }, - - imageAltText: function() { - return StringUtils.interpolate( - gettext('Profile image for {username}'), - {username: this.model.get('username')} - ); - }, - - imageChangeSucceeded: function() { - var view = this; - // Update model to get the latest urls of profile image. - this.model.fetch().done(function() { - view.setCurrentStatus(''); - view.render(); - view.$('.u-field-upload-button').focus(); - }).fail(function() { - view.setCurrentStatus(''); - view.showErrorMessage(view.errorMessage); - }); - }, - - imageChangeFailed: function(e, data) { - this.setCurrentStatus(''); - this.showImageChangeFailedMessage(data.jqXHR.status, data.jqXHR.responseText); - }, - - showImageChangeFailedMessage: function(status, responseText) { - var errors; - if (_.contains([400, 404], status)) { - try { - errors = JSON.parse(responseText); - this.showErrorMessage(errors.user_message); - } catch (error) { - this.showErrorMessage(this.errorMessage); - } - } else { - this.showErrorMessage(this.errorMessage); - } - }, - - showErrorMessage: function(message) { - this.options.messageView.showMessage(message); - }, - - isEditingAllowed: function() { - return this.model.isAboveMinimumAge(); - }, - - isShowingPlaceholder: function() { - return !this.model.hasProfileImage(); - }, - - clickedRemoveButton: function(e, data) { - this.options.messageView.hideMessage(); - this._super(e, data); - }, - - fileSelected: function(e, data) { - this.options.messageView.hideMessage(); - this._super(e, data); - } - }); - - LearnerProfileFieldViews.SocialLinkIconsView = Backbone.View.extend({ - - initialize: function(options) { - this.options = _.extend({}, options); - }, - - render: function() { - var socialLinks = {}; - for (var platformName in this.options.socialPlatforms) { // eslint-disable-line no-restricted-syntax, guard-for-in, vars-on-top, max-len - socialLinks[platformName] = null; - for (var link in this.model.get('social_links')) { // eslint-disable-line no-restricted-syntax, vars-on-top, max-len - if (platformName === this.model.get('social_links')[link].platform) { - socialLinks[platformName] = this.model.get('social_links')[link].social_link; - } - } - } - - HtmlUtils.setHtml(this.$el, HtmlUtils.template(socialIconsTemplate)({ - socialLinks: socialLinks, - ownProfile: this.options.ownProfile - })); - return this; - } - }); - - return LearnerProfileFieldViews; - }); -}).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 deleted file mode 100644 index 74c0e6b819f..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/learner_profile_view.js +++ /dev/null @@ -1,150 +0,0 @@ -(function(define) { - 'use strict'; - - define( - [ - 'gettext', 'jquery', 'underscore', 'backbone', 'edx-ui-toolkit/js/utils/html-utils', - 'common/js/components/views/tabbed_view', - 'learner_profile/js/views/section_two_tab' - ], - function(gettext, $, _, Backbone, HtmlUtils, TabbedView, SectionTwoTab) { - var LearnerProfileView = Backbone.View.extend({ - - initialize: function(options) { - var Router; - this.options = _.extend({}, options); - _.bindAll(this, 'showFullProfile', 'render', 'renderFields', 'showLoadingError'); - this.listenTo(this.options.preferencesModel, 'change:account_privacy', this.render); - Router = Backbone.Router.extend({ - routes: {':about_me': 'loadTab', ':accomplishments': 'loadTab'} - }); - - this.router = new Router(); - this.firstRender = true; - }, - - showFullProfile: function() { - var isAboveMinimumAge = this.options.accountSettingsModel.isAboveMinimumAge(); - if (this.options.ownProfile) { - return isAboveMinimumAge - && this.options.preferencesModel.get('account_privacy') === 'all_users'; - } else { - return this.options.accountSettingsModel.get('profile_is_public'); - } - }, - - setActiveTab: function(tab) { - // This tab may not actually exist. - if (this.tabbedView.getTabMeta(tab).tab) { - this.tabbedView.setActiveTab(tab); - } - }, - - render: function() { - var tabs, - $tabbedViewElement, - $wrapperProfileBioElement = this.$el.find('.wrapper-profile-bio'), - self = this; - - this.sectionTwoView = new SectionTwoTab({ - viewList: this.options.sectionTwoFieldViews, - showFullProfile: this.showFullProfile, - ownProfile: this.options.ownProfile - }); - - this.renderFields(); - - // Reveal the profile and hide the loading indicator - $('.ui-loading-indicator').addClass('is-hidden'); - $('.wrapper-profile-section-container-one').removeClass('is-hidden'); - $('.wrapper-profile-section-container-two').removeClass('is-hidden'); - - - if (this.showFullProfile()) { - tabs = [ - {view: this.sectionTwoView, title: gettext('About Me'), url: 'about_me'} - ]; - - this.tabbedView = new TabbedView({ - tabs: tabs, - router: this.router, - viewLabel: gettext('Profile') - }); - - $tabbedViewElement = this.tabbedView.render().el; - HtmlUtils.setHtml( - $wrapperProfileBioElement, - HtmlUtils.HTML($tabbedViewElement) - ); - - if (this.firstRender) { - this.router.on('route:loadTab', _.bind(this.setActiveTab, this)); - Backbone.history.start(); - this.firstRender = false; - // Load from history. - this.router.navigate((Backbone.history.getFragment() || 'about_me'), {trigger: true}); - } else { - // Restart the router so the tab will be brought up anew. - Backbone.history.stop(); - Backbone.history.start(); - } - } else { - if (this.isCoppaCompliant()) { - // xss-lint: disable=javascript-jquery-html - $wrapperProfileBioElement.html(this.sectionTwoView.render().el); - } - } - return this; - }, - - isCoppaCompliant: function() { - var enableCoppaCompliance = this.options.accountSettingsModel.get('enable_coppa_compliance'), - isAboveAge = this.options.accountSettingsModel.isAboveMinimumAge(); - return !enableCoppaCompliance || (enableCoppaCompliance && isAboveAge); - }, - - renderFields: function() { - var view = this, - fieldView, - imageView, - settings; - - if (this.options.ownProfile && this.isCoppaCompliant()) { - fieldView = this.options.accountPrivacyFieldView; - settings = this.options.accountSettingsModel; - fieldView.profileIsPrivate = !settings.get('year_of_birth'); - fieldView.requiresParentalConsent = settings.get('requires_parental_consent'); - fieldView.isAboveMinimumAge = settings.isAboveMinimumAge(); - fieldView.undelegateEvents(); - this.$('.wrapper-profile-field-account-privacy').prepend(fieldView.render().el); - fieldView.delegateEvents(); - } - - // Clear existing content in user profile card - this.$('.profile-section-one-fields').html(''); - - // Do not show name when in limited mode or no name has been set - if (this.showFullProfile() && this.options.accountSettingsModel.get('name')) { - this.$('.profile-section-one-fields').append(this.options.nameFieldView.render().el); - } - this.$('.profile-section-one-fields').append(this.options.usernameFieldView.render().el); - - imageView = this.options.profileImageFieldView; - this.$('.profile-image-field').append(imageView.render().el); - - if (this.showFullProfile()) { - _.each(this.options.sectionOneFieldViews, function(childFieldView) { - view.$('.profile-section-one-fields').append(childFieldView.render().el); - }); - } - }, - - showLoadingError: function() { - this.$('.ui-loading-indicator').addClass('is-hidden'); - this.$('.ui-loading-error').removeClass('is-hidden'); - } - }); - - return LearnerProfileView; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/js/views/section_two_tab.js b/openedx/features/learner_profile/static/learner_profile/js/views/section_two_tab.js deleted file mode 100644 index 23064c9e6b6..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/js/views/section_two_tab.js +++ /dev/null @@ -1,33 +0,0 @@ -(function(define) { - 'use strict'; - - define( - [ - 'gettext', 'jquery', 'underscore', 'backbone', 'text!learner_profile/templates/section_two.underscore', - 'edx-ui-toolkit/js/utils/html-utils' - ], - function(gettext, $, _, Backbone, sectionTwoTemplate, HtmlUtils) { - var SectionTwoTab = Backbone.View.extend({ - attributes: { - class: 'wrapper-profile-section-two' - }, - template: _.template(sectionTwoTemplate), - initialize: function(options) { - this.options = _.extend({}, options); - }, - render: function() { - var self = this; - var showFullProfile = this.options.showFullProfile(); - this.$el.html(HtmlUtils.HTML(this.template({ownProfile: self.options.ownProfile, showFullProfile: showFullProfile})).toString()); // eslint-disable-line max-len - if (showFullProfile) { - _.each(this.options.viewList, function(fieldView) { - self.$el.find('.field-container').append(fieldView.render().el); - }); - } - return this; - } - }); - - return SectionTwoTab; - }); -}).call(this, define || RequireJS.define); diff --git a/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore b/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore deleted file mode 100644 index 0c7d11cd8b1..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/section_two.underscore +++ /dev/null @@ -1,10 +0,0 @@ -
    -
    - <% if (!showFullProfile) { %> - <% if(ownProfile) { %> - <%- gettext("You are currently sharing a limited profile.") %> - <% } else { %> - <%- gettext("This learner is currently sharing a limited profile.") %> - <% } %> - <% } %> -
    \ No newline at end of file diff --git a/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore b/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore deleted file mode 100644 index 52b864cfb66..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/social_icons.underscore +++ /dev/null @@ -1,9 +0,0 @@ - diff --git a/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html b/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html deleted file mode 100644 index 07e14bc48ab..00000000000 --- a/openedx/features/learner_profile/static/learner_profile/templates/third_party_auth.html +++ /dev/null @@ -1,47 +0,0 @@ -<%page expression_filter="h"/> -<%! -from django.utils.translation import gettext as _ -from common.djangoapps.third_party_auth import pipeline -%> - - diff --git a/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html b/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html deleted file mode 100644 index 09e6ce36b9b..00000000000 --- a/openedx/features/learner_profile/templates/learner_profile/learner-achievements-fragment.html +++ /dev/null @@ -1,69 +0,0 @@ -## mako - -<%page expression_filter="h"/> - -<%namespace name='static' file='/static_content.html'/> - -<%! -from django.utils.translation import gettext as _ -from openedx.core.djangolib.markup import HTML, Text -%> - -
    - % if course_certificates or own_profile: -

    Course Certificates

    - % if course_certificates: - % for certificate in course_certificates: - <% - course = certificate['course'] - - completion_date_message_html = Text(_('Completed {completion_date_html}')).format( - completion_date_html=HTML( - '' - ).format( - completion_date=certificate['created'], - user_timezone=user_timezone, - user_language=user_language, - ), - ) - %> -
    - -
    -
    ${course.display_org_with_default}
    -
    ${course.display_name_with_default}
    -

    ${completion_date_message_html}

    -
    -
    - % endfor - % elif own_profile: -
    -

    ${_("You haven't earned any certificates yet.")}

    - % if settings.FEATURES.get('COURSES_ARE_BROWSABLE'): -

    - - - ${_('Explore New Courses')} - -

    - % endif -
    - % endif - % endif -
    - -<%static:require_module_async module_name="js/dateutil_factory" class_name="DateUtilFactory"> - DateUtilFactory.transform('.localized-datetime'); - diff --git a/openedx/features/learner_profile/templates/learner_profile/learner_profile.html b/openedx/features/learner_profile/templates/learner_profile/learner_profile.html deleted file mode 100644 index 6de4744e66f..00000000000 --- a/openedx/features/learner_profile/templates/learner_profile/learner_profile.html +++ /dev/null @@ -1,79 +0,0 @@ -## mako - -<%page expression_filter="h"/> -<%inherit file="/main.html" /> -<%def name="online_help_token()"><% return "profile" %> -<%namespace name='static' file='/static_content.html'/> - -<%! -import json -from django.urls import reverse -from django.utils.translation import gettext as _ -from openedx.core.djangolib.js_utils import dump_js_escaped_json -from openedx.core.djangolib.markup import HTML -%> - -<%block name="pagetitle">${_("Learner Profile")} - -<%block name="bodyclass">view-profile - -<%block name="headextra"> -<%static:css group='style-course'/> - - -
    -
    -
    -
    - - % if own_profile: -
    -

    ${_("My Profile")}

    -
    - ${_('Build out your profile to personalize your identity on {platform_name}.').format( - platform_name=platform_name, - )} -
    -
    - % endif - -
    -
    -
    - -<%block name="js_extra"> -<%static:require_module module_name="learner_profile/js/learner_profile_factory" class_name="LearnerProfileFactory"> - var options = ${data | n, dump_js_escaped_json}; - LearnerProfileFactory(options); - - diff --git a/openedx/features/learner_profile/tests/__init__.py b/openedx/features/learner_profile/tests/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/openedx/features/learner_profile/tests/views/__init__.py b/openedx/features/learner_profile/tests/views/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/openedx/features/learner_profile/tests/views/test_learner_profile.py b/openedx/features/learner_profile/tests/views/test_learner_profile.py deleted file mode 100644 index c4c83520008..00000000000 --- a/openedx/features/learner_profile/tests/views/test_learner_profile.py +++ /dev/null @@ -1,281 +0,0 @@ -""" Tests for student profile views. """ - - -import datetime -from unittest import mock - -import ddt -from django.conf import settings -from django.test.client import RequestFactory -from django.urls import reverse -from edx_toggles.toggles.testutils import override_waffle_flag -from opaque_keys.edx.locator import CourseLocator - -from common.djangoapps.course_modes.models import CourseMode -from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory -from common.djangoapps.util.testing import UrlResetMixin -from lms.djangoapps.certificates.data import CertificateStatuses -from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory -from lms.envs.test import CREDENTIALS_PUBLIC_SERVICE_URL -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin -from openedx.features.learner_profile.toggles import REDIRECT_TO_PROFILE_MICROFRONTEND -from openedx.features.learner_profile.views.learner_profile import learner_profile_context -from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order -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.ddt -class LearnerProfileViewTest(SiteMixin, UrlResetMixin, ModuleStoreTestCase): - """ Tests for the student profile view. """ - - USERNAME = "username" - OTHER_USERNAME = "other_user" - PASSWORD = "password" - DOWNLOAD_URL = "http://www.example.com/certificate.pdf" - CONTEXT_DATA = [ - 'default_public_account_fields', - 'accounts_api_url', - 'preferences_api_url', - 'account_settings_page_url', - 'has_preferences_access', - 'own_profile', - 'country_options', - 'language_options', - 'account_settings_data', - 'preferences_data', - ] - - def setUp(self): - super().setUp() - self.user = UserFactory.create(username=self.USERNAME, password=self.PASSWORD) - self.other_user = UserFactory.create(username=self.OTHER_USERNAME, password=self.PASSWORD) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - self.course = CourseFactory.create( - start=datetime.datetime(2013, 9, 16, 7, 17, 28), - end=datetime.datetime.now(), - certificate_available_date=datetime.datetime.now(), - ) - - def test_context(self): - """ - Verify learner profile page context data. - """ - request = RequestFactory().get('/url') - request.user = self.user - - context = learner_profile_context(request, self.USERNAME, self.user.is_staff) - - assert context['data']['default_public_account_fields'] == \ - settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'] - - assert context['data']['accounts_api_url'] == \ - reverse('accounts_api', kwargs={'username': self.user.username}) - - assert context['data']['preferences_api_url'] == \ - reverse('preferences_api', kwargs={'username': self.user.username}) - - assert context['data']['profile_image_upload_url'] == \ - reverse('profile_image_upload', kwargs={'username': self.user.username}) - - assert context['data']['profile_image_remove_url'] == \ - reverse('profile_image_remove', kwargs={'username': self.user.username}) - - assert context['data']['profile_image_max_bytes'] == settings.PROFILE_IMAGE_MAX_BYTES - - assert context['data']['profile_image_min_bytes'] == settings.PROFILE_IMAGE_MIN_BYTES - - assert context['data']['account_settings_page_url'] == reverse('account_settings') - - for attribute in self.CONTEXT_DATA: - assert attribute in context['data'] - - def test_view(self): - """ - Verify learner profile page view. - """ - profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) - response = self.client.get(path=profile_path) - - for attribute in self.CONTEXT_DATA: - self.assertContains(response, attribute) - - def test_redirect_view(self): - with override_waffle_flag(REDIRECT_TO_PROFILE_MICROFRONTEND, active=True): - profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) - - # Test with waffle flag active and site setting disabled, does not redirect - response = self.client.get(path=profile_path) - for attribute in self.CONTEXT_DATA: - self.assertContains(response, attribute) - - # Test with waffle flag active and site setting enabled, redirects to microfrontend - site_domain = 'othersite.example.com' - self.set_up_site(site_domain, { - 'SITE_NAME': site_domain, - 'ENABLE_PROFILE_MICROFRONTEND': True - }) - self.client.login(username=self.USERNAME, password=self.PASSWORD) - response = self.client.get(path=profile_path) - profile_url = settings.PROFILE_MICROFRONTEND_URL - self.assertRedirects(response, profile_url + self.USERNAME, fetch_redirect_response=False) - - def test_records_link(self): - profile_path = reverse('learner_profile', kwargs={'username': self.USERNAME}) - response = self.client.get(path=profile_path) - self.assertContains(response, f'') - - def test_undefined_profile_page(self): - """ - Verify that a 404 is returned for a non-existent profile page. - """ - profile_path = reverse('learner_profile', kwargs={'username': "no_such_user"}) - response = self.client.get(path=profile_path) - assert 404 == response.status_code - - def _create_certificate(self, course_key=None, enrollment_mode=CourseMode.HONOR, status='downloadable'): - """Simulate that the user has a generated certificate. """ - CourseEnrollmentFactory.create(user=self.user, course_id=self.course.id, mode=enrollment_mode) - return GeneratedCertificateFactory( - user=self.user, - course_id=course_key or self.course.id, - mode=enrollment_mode, - download_url=self.DOWNLOAD_URL, - status=status, - ) - - @ddt.data(CourseMode.HONOR, CourseMode.PROFESSIONAL, CourseMode.VERIFIED) - def test_certificate_visibility(self, cert_mode): - """ - Verify that certificates are displayed with the correct card mode. - """ - # Add new certificate - cert = self._create_certificate(enrollment_mode=cert_mode) - cert.save() - - response = self.client.get(f'/u/{self.user.username}') - - self.assertContains(response, f'card certificate-card mode-{cert_mode}') - - @ddt.data( - ['downloadable', True], - ['notpassing', False], - ) - @ddt.unpack - def test_certificate_status_visibility(self, status, is_passed_status): - """ - Verify that certificates are only displayed for passing status. - """ - # Add new certificate - cert = self._create_certificate(status=status) - cert.save() - - # Ensure that this test is actually using both passing and non-passing certs. - assert CertificateStatuses.is_passing_status(cert.status) == is_passed_status - - response = self.client.get(f'/u/{self.user.username}') - - if is_passed_status: - self.assertContains(response, f'card certificate-card mode-{cert.mode}') - else: - self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') - - def test_certificate_for_missing_course(self): - """ - Verify that a certificate is not shown for a missing course. - """ - # Add new certificate - cert = self._create_certificate(course_key=CourseLocator.from_string('course-v1:edX+INVALID+1')) - cert.save() - - response = self.client.get(f'/u/{self.user.username}') - - self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') - - @ddt.data(True, False) - def test_no_certificate_visibility(self, own_profile): - """ - Verify that the 'You haven't earned any certificates yet.' well appears on the user's - own profile when they do not have certificates and does not appear when viewing - another user that does not have any certificates. - """ - profile_username = self.user.username if own_profile else self.other_user.username - response = self.client.get(f'/u/{profile_username}') - - if own_profile: - self.assertContains(response, 'You haven't earned any certificates yet.') - else: - self.assertNotContains(response, 'You haven't earned any certificates yet.') - - @ddt.data(True, False) - def test_explore_courses_visibility(self, courses_browsable): - with mock.patch.dict('django.conf.settings.FEATURES', {'COURSES_ARE_BROWSABLE': courses_browsable}): - response = self.client.get(f'/u/{self.user.username}') - if courses_browsable: - self.assertContains(response, 'Explore New Courses') - else: - self.assertNotContains(response, 'Explore New Courses') - - def test_certificate_for_visibility_for_not_viewable_course(self): - """ - Verify that a certificate is not shown if certificate are not viewable to users. - """ - # add new course with certificate_available_date is future date. - course = CourseFactory.create( - certificate_available_date=datetime.datetime.now() + datetime.timedelta(days=5), - certificates_display_behavior=CertificatesDisplayBehaviors.END_WITH_DATE - ) - - cert = self._create_certificate(course_key=course.id) - cert.save() - - response = self.client.get(f'/u/{self.user.username}') - - self.assertNotContains(response, f'card certificate-card mode-{cert.mode}') - - def test_certificates_visible_only_for_staff_and_profile_user(self): - """ - Verify that certificates data are passed to template only in case of staff user - and profile user. - """ - request = RequestFactory().get('/url') - request.user = self.user - profile_username = self.other_user.username - user_is_staff = True - context = learner_profile_context(request, profile_username, user_is_staff) - - assert 'achievements_fragment' in context - - user_is_staff = False - context = learner_profile_context(request, profile_username, user_is_staff) - assert 'achievements_fragment' not in context - - profile_username = self.user.username - context = learner_profile_context(request, profile_username, user_is_staff) - assert 'achievements_fragment' in context - - @mock.patch.dict(settings.FEATURES, {'CERTIFICATES_HTML_VIEW': True}) - def test_certificate_visibility_with_no_cert_config(self): - """ - Verify that certificates are not displayed until there is an active - certificate configuration. - """ - # Add new certificate - cert = self._create_certificate(enrollment_mode=CourseMode.VERIFIED) - cert.download_url = '' - cert.save() - - response = self.client.get(f'/u/{self.user.username}') - self.assertNotContains( - response, f'card certificate-card mode-{CourseMode.VERIFIED}' - ) - - course_overview = CourseOverview.get_from_id(self.course.id) - course_overview.has_any_active_web_certificate = True - course_overview.save() - - response = self.client.get(f'/u/{self.user.username}') - self.assertContains( - response, f'card certificate-card mode-{CourseMode.VERIFIED}' - ) diff --git a/openedx/features/learner_profile/toggles.py b/openedx/features/learner_profile/toggles.py deleted file mode 100644 index 08378b6e904..00000000000 --- a/openedx/features/learner_profile/toggles.py +++ /dev/null @@ -1,29 +0,0 @@ -""" -Toggles for Learner Profile page. -""" - - -from edx_toggles.toggles import WaffleFlag -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers - -# Namespace for learner profile waffle flags. -WAFFLE_FLAG_NAMESPACE = 'learner_profile' - -# Waffle flag to redirect to another learner profile experience. -# .. toggle_name: learner_profile.redirect_to_microfrontend -# .. toggle_implementation: WaffleFlag -# .. toggle_default: False -# .. toggle_description: Supports staged rollout of a new micro-frontend-based implementation of the profile page. -# .. toggle_use_cases: temporary, open_edx -# .. toggle_creation_date: 2019-02-19 -# .. toggle_target_removal_date: 2020-12-31 -# .. toggle_warning: Also set settings.PROFILE_MICROFRONTEND_URL and site's ENABLE_PROFILE_MICROFRONTEND. -# .. toggle_tickets: DEPR-17 -REDIRECT_TO_PROFILE_MICROFRONTEND = WaffleFlag(f'{WAFFLE_FLAG_NAMESPACE}.redirect_to_microfrontend', __name__) - - -def should_redirect_to_profile_microfrontend(): - return ( - configuration_helpers.get_value('ENABLE_PROFILE_MICROFRONTEND') and - REDIRECT_TO_PROFILE_MICROFRONTEND.is_enabled() - ) diff --git a/openedx/features/learner_profile/urls.py b/openedx/features/learner_profile/urls.py deleted file mode 100644 index 0f020765686..00000000000 --- a/openedx/features/learner_profile/urls.py +++ /dev/null @@ -1,24 +0,0 @@ -""" -Defines URLs for the learner profile. -""" - - -from django.conf import settings -from django.urls import path, re_path - -from openedx.features.learner_profile.views.learner_profile import learner_profile - -from .views.learner_achievements import LearnerAchievementsFragmentView - -urlpatterns = [ - re_path( - r'^{username_pattern}$'.format( - username_pattern=settings.USERNAME_PATTERN, - ), - learner_profile, - name='learner_profile', - ), - path('achievements', LearnerAchievementsFragmentView.as_view(), - name='openedx.learner_profile.learner_achievements_fragment_view', - ), -] diff --git a/openedx/features/learner_profile/views/__init__.py b/openedx/features/learner_profile/views/__init__.py deleted file mode 100644 index e69de29bb2d..00000000000 diff --git a/openedx/features/learner_profile/views/learner_achievements.py b/openedx/features/learner_profile/views/learner_achievements.py deleted file mode 100644 index 6a7a07e3392..00000000000 --- a/openedx/features/learner_profile/views/learner_achievements.py +++ /dev/null @@ -1,58 +0,0 @@ -""" -Views to render a learner's achievements. -""" - - -from django.template.loader import render_to_string -from web_fragments.fragment import Fragment - -from lms.djangoapps.certificates import api as certificate_api -from openedx.core.djangoapps.content.course_overviews.models import CourseOverview -from openedx.core.djangoapps.plugin_api.views import EdxFragmentView - - -class LearnerAchievementsFragmentView(EdxFragmentView): - """ - A fragment to render a learner's achievements. - """ - - def render_to_fragment(self, request, username=None, own_profile=False, **kwargs): # lint-amnesty, pylint: disable=arguments-differ - """ - Renders the current learner's achievements. - """ - course_certificates = self._get_ordered_certificates_for_user(request, username) - context = { - 'course_certificates': course_certificates, - 'own_profile': own_profile, - 'disable_courseware_js': True, - } - if course_certificates or own_profile: - html = render_to_string('learner_profile/learner-achievements-fragment.html', context) - return Fragment(html) - else: - return None - - def _get_ordered_certificates_for_user(self, request, username): - """ - Returns a user's certificates sorted by course name. - """ - course_certificates = certificate_api.get_certificates_for_user(username) - passing_certificates = [] - for course_certificate in course_certificates: - if course_certificate.get('is_passing', False): - course_key = course_certificate['course_key'] - try: - course_overview = CourseOverview.get_from_id(course_key) - course_certificate['course'] = course_overview - if certificate_api.certificates_viewable_for_course(course_overview): - # add certificate into passing certificate list only if it's a PDF certificate - # or there is an active certificate configuration. - if course_certificate['is_pdf_certificate'] or course_overview.has_any_active_web_certificate: - passing_certificates.append(course_certificate) - except CourseOverview.DoesNotExist: - # This is unlikely to fail as the course should exist. - # Ideally the cert should have all the information that - # it needs. This might be solved by the Credentials API. - pass - passing_certificates.sort(key=lambda certificate: certificate['course'].display_name_with_default) - return passing_certificates diff --git a/openedx/features/learner_profile/views/learner_profile.py b/openedx/features/learner_profile/views/learner_profile.py deleted file mode 100644 index 6a3a251fde9..00000000000 --- a/openedx/features/learner_profile/views/learner_profile.py +++ /dev/null @@ -1,128 +0,0 @@ -""" Views for a student's profile information. """ - - -from django.conf import settings -from django.contrib.auth.decorators import login_required -from django.contrib.staticfiles.storage import staticfiles_storage -from django.core.exceptions import ObjectDoesNotExist -from django.http import Http404 -from django.shortcuts import redirect, render -from django.urls import reverse -from django.views.decorators.http import require_http_methods -from django_countries import countries - -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 -from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.core.djangoapps.user_api.accounts.api import get_account_settings -from openedx.core.djangoapps.user_api.errors import UserNotAuthorized, UserNotFound -from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences -from openedx.features.learner_profile.toggles import should_redirect_to_profile_microfrontend -from openedx.features.learner_profile.views.learner_achievements import LearnerAchievementsFragmentView -from common.djangoapps.student.models import User - - -@login_required -@require_http_methods(['GET']) -def learner_profile(request, username): - """Render the profile page for the specified username. - - Args: - request (HttpRequest) - username (str): username of user whose profile is requested. - - Returns: - HttpResponse: 200 if the page was sent successfully - HttpResponse: 302 if not logged in (redirect to login page) - HttpResponse: 405 if using an unsupported HTTP method - Raises: - Http404: 404 if the specified user is not authorized or does not exist - - Example usage: - GET /account/profile - """ - if should_redirect_to_profile_microfrontend(): - profile_microfrontend_url = f"{settings.PROFILE_MICROFRONTEND_URL}{username}" - if request.GET: - profile_microfrontend_url += f'?{request.GET.urlencode()}' - return redirect(profile_microfrontend_url) - - try: - context = learner_profile_context(request, username, request.user.is_staff) - return render( - request=request, - template_name='learner_profile/learner_profile.html', - context=context - ) - except (UserNotAuthorized, UserNotFound, ObjectDoesNotExist): - raise Http404 # lint-amnesty, pylint: disable=raise-missing-from - - -def learner_profile_context(request, profile_username, user_is_staff): - """Context for the learner profile page. - - Args: - logged_in_user (object): Logged In user. - profile_username (str): username of user whose profile is requested. - user_is_staff (bool): Logged In user has staff access. - build_absolute_uri_func (): - - Returns: - dict - - Raises: - ObjectDoesNotExist: the specified profile_username does not exist. - """ - profile_user = User.objects.get(username=profile_username) - logged_in_user = request.user - - own_profile = (logged_in_user.username == profile_username) - - account_settings_data = get_account_settings(request, [profile_username])[0] - - preferences_data = get_user_preferences(profile_user, profile_username) - - context = { - 'own_profile': own_profile, - 'platform_name': configuration_helpers.get_value('platform_name', settings.PLATFORM_NAME), - 'data': { - 'profile_user_id': profile_user.id, - 'default_public_account_fields': settings.ACCOUNT_VISIBILITY_CONFIGURATION['public_fields'], - 'default_visibility': settings.ACCOUNT_VISIBILITY_CONFIGURATION['default_visibility'], - 'accounts_api_url': reverse("accounts_api", kwargs={'username': profile_username}), - 'preferences_api_url': reverse('preferences_api', kwargs={'username': profile_username}), - 'preferences_data': preferences_data, - 'account_settings_data': account_settings_data, - 'profile_image_upload_url': reverse('profile_image_upload', kwargs={'username': profile_username}), - 'profile_image_remove_url': reverse('profile_image_remove', kwargs={'username': profile_username}), - 'profile_image_max_bytes': settings.PROFILE_IMAGE_MAX_BYTES, - 'profile_image_min_bytes': settings.PROFILE_IMAGE_MIN_BYTES, - 'account_settings_page_url': reverse('account_settings'), - 'has_preferences_access': (logged_in_user.username == profile_username or user_is_staff), - 'own_profile': own_profile, - 'country_options': list(countries), - 'find_courses_url': marketing_link('COURSES'), - 'language_options': settings.ALL_LANGUAGES, - '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, - 'enable_coppa_compliance': settings.ENABLE_COPPA_COMPLIANCE, - 'parental_consent_age_limit': settings.PARENTAL_CONSENT_AGE_LIMIT - }, - 'show_program_listing': ProgramsApiConfig.is_enabled(), - 'show_dashboard_tabs': True, - 'disable_courseware_js': True, - 'nav_hidden': True, - 'records_url': get_credentials_records_url(), - } - - if own_profile or user_is_staff: - achievements_fragment = LearnerAchievementsFragmentView().render_to_fragment( - request, - username=profile_user.username, - own_profile=own_profile, - ) - context['achievements_fragment'] = achievements_fragment - - return context diff --git a/pylint_django_settings.py b/pylint_django_settings.py index 46abfd81f88..6051d9ab4b5 100644 --- a/pylint_django_settings.py +++ b/pylint_django_settings.py @@ -1,5 +1,5 @@ -from pylint_django.checkers import ForeignKeyStringsChecker -from pylint_plugin_utils import get_checker +import os +import sys class ArgumentCompatibilityError(Exception): @@ -47,6 +47,4 @@ def load_configuration(linter): """ Configures the Django settings module based on the command-line arguments passed to pylint. """ - name_checker = get_checker(linter, ForeignKeyStringsChecker) - arguments = linter.cmdline_parser.parse_args()[1] - name_checker.config.django_settings_module = _get_django_settings_module(arguments) + os.environ.setdefault("DJANGO_SETTINGS_MODULE", _get_django_settings_module(sys.argv[1:])) diff --git a/pylintrc b/pylintrc index 55a9bbab3b9..de88463ddb0 100644 --- a/pylintrc +++ b/pylintrc @@ -64,7 +64,7 @@ # SERIOUSLY. # # ------------------------------ -# Generated by edx-lint version: 5.3.7 +# Generated by edx-lint version: 5.4.1 # ------------------------------ [MASTER] ignore = ,.git,.tox,migrations,node_modules,.pycharm_helpers @@ -72,10 +72,10 @@ persistent = yes load-plugins = edx_lint.pylint,pylint_django_settings,pylint_django,pylint_celery,pylint_pytest [MESSAGES CONTROL] -enable = +enable = blacklisted-name, line-too-long, - + abstract-class-instantiated, abstract-method, access-member-before-definition, @@ -184,26 +184,26 @@ enable = used-before-assignment, using-constant-test, yield-outside-function, - + astroid-error, fatal, method-check-failed, parse-error, raw-checker-failed, - + empty-docstring, invalid-characters-in-docstring, missing-docstring, wrong-spelling-in-comment, wrong-spelling-in-docstring, - + unused-argument, unused-import, unused-variable, - + eval-used, exec-used, - + bad-classmethod-argument, bad-mcs-classmethod-argument, bad-mcs-method-argument, @@ -234,30 +234,30 @@ enable = unneeded-not, useless-else-on-loop, wrong-assert-type, - + deprecated-method, deprecated-module, - + too-many-boolean-expressions, too-many-nested-blocks, too-many-statements, - + wildcard-import, wrong-import-order, wrong-import-position, - + missing-final-newline, mixed-line-endings, trailing-newlines, trailing-whitespace, unexpected-line-ending-format, - + bad-inline-option, bad-option-value, deprecated-pragma, unrecognized-inline-option, useless-suppression, -disable = +disable = bad-indentation, broad-exception-raised, consider-using-f-string, @@ -282,10 +282,10 @@ disable = unspecified-encoding, unused-wildcard-import, use-maxsplit-arg, - + feature-toggle-needs-doc, illegal-waffle-usage, - + logging-fstring-interpolation, import-outside-toplevel, inconsistent-return-statements, @@ -314,6 +314,10 @@ disable = c-extension-no-member, no-name-in-module, unnecessary-lambda-assignment, + too-many-positional-arguments, + possibly-used-before-assignment, + use-dict-literal, + superfluous-parens [REPORTS] output-format = text @@ -356,7 +360,7 @@ ignore-imports = no ignore-mixin-members = yes ignored-classes = SQLObject unsafe-load-any-extension = yes -generated-members = +generated-members = REQUEST, acl_users, aq_parent, @@ -382,7 +386,7 @@ generated-members = [VARIABLES] init-import = no dummy-variables-rgx = _|dummy|unused|.*_unused -additional-builtins = +additional-builtins = [CLASSES] defining-attr-methods = __init__,__new__,setUp @@ -403,11 +407,11 @@ max-public-methods = 20 [IMPORTS] deprecated-modules = regsub,TERMIOS,Bastion,rexec -import-graph = -ext-import-graph = -int-import-graph = +import-graph = +ext-import-graph = +int-import-graph = [EXCEPTIONS] overgeneral-exceptions = builtins.Exception -# e624ea03d8124aa9cf2e577f830632344a0a07d9 +# 5aea7d7fb264005eb373099c856a54cdfa4f311c diff --git a/pylintrc_tweaks b/pylintrc_tweaks index 1633da5c10a..cd8680d4d66 100644 --- a/pylintrc_tweaks +++ b/pylintrc_tweaks @@ -33,6 +33,10 @@ disable+ = c-extension-no-member, no-name-in-module, unnecessary-lambda-assignment, + too-many-positional-arguments, + possibly-used-before-assignment, + use-dict-literal, + superfluous-parens [BASIC] attr-rgx = [a-z_][a-z0-9_]{2,40}$ diff --git a/requirements/constraints.txt b/requirements/constraints.txt index 283ab625f42..a28688eedf4 100644 --- a/requirements/constraints.txt +++ b/requirements/constraints.txt @@ -80,7 +80,7 @@ django-storages<1.14.4 # 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==5.6.4 +edx-enterprise==5.6.11 # Date: 2024-05-09 # This has to be constrained as well because newer versions of edx-i18n-tools need the @@ -131,7 +131,7 @@ optimizely-sdk<5.0 # Date: 2023-09-18 # pinning this version to avoid updates while the library is being developed # Issue for unpinning: https://github.com/openedx/edx-platform/issues/35269 -openedx-learning==0.18.1 +openedx-learning==0.18.3 # Date: 2023-11-29 # Open AI version 1.0.0 dropped support for openai.ChatCompletion which is currently in use in enterprise. @@ -149,11 +149,6 @@ path<16.12.0 # Constraint can be removed once the issue https://github.com/PyCQA/pycodestyle/issues/1090 is fixed. pycodestyle<2.9.0 -# Date: 2021-07-12 -# Issue for unpinning: https://github.com/openedx/edx-platform/issues/33560 -pylint<2.16.0 # greater version failing quality test. Fix them in seperate ticket. -astroid<2.14.0 - # Date: 2021-08-25 # At the time of writing this comment, we do not know whether py2neo>=2022 # will support our currently-deployed Neo4j version (3.5). diff --git a/requirements/edx-sandbox/base.txt b/requirements/edx-sandbox/base.txt index da781eb8458..821664fe9fc 100644 --- a/requirements/edx-sandbox/base.txt +++ b/requirements/edx-sandbox/base.txt @@ -20,7 +20,7 @@ cryptography==44.0.0 # via -r requirements/edx-sandbox/base.in cycler==0.12.1 # via matplotlib -fonttools==4.55.4 +fonttools==4.55.6 # via matplotlib joblib==1.4.2 # via nltk diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 4861b7be1f6..b0215634b08 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -37,7 +37,7 @@ asgiref==3.8.1 # django-countries asn1crypto==1.5.1 # via snowflake-connector-python -attrs==24.3.0 +attrs==25.1.0 # via # -r requirements/edx/kernel.in # aiohttp @@ -72,13 +72,13 @@ bleach[css]==6.2.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/kernel.in -boto3==1.36.3 +boto3==1.36.6 # via # -r requirements/edx/kernel.in # django-ses # fs-s3fs # ora2 -botocore==1.36.3 +botocore==1.36.6 # via # -r requirements/edx/kernel.in # boto3 @@ -87,7 +87,7 @@ bridgekeeper==0.9 # via -r requirements/edx/kernel.in cachecontrol==0.14.2 # via firebase-admin -cachetools==5.5.0 +cachetools==5.5.1 # via google-auth camel-converter[pydantic]==4.0.1 # via meilisearch @@ -221,7 +221,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -402,7 +401,7 @@ drf-yasg==1.21.8 # via # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.3 +edx-ace==1.11.4 # via -r requirements/edx/kernel.in edx-api-doc-tools==2.0.0 # via @@ -468,7 +467,7 @@ edx-drf-extensions==10.5.0 # edx-when # edxval # openedx-learning -edx-enterprise==5.6.4 +edx-enterprise==5.6.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -503,7 +502,7 @@ edx-opaque-keys[django]==2.11.0 # ora2 edx-organizations==6.13.0 # via -r requirements/edx/kernel.in -edx-proctoring==5.0.1 +edx-proctoring==5.1.2 # via # -r requirements/edx/kernel.in # edx-proctoring-proctortrack @@ -514,7 +513,7 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/kernel.in # edx-enterprise # edx-proctoring -edx-search==4.1.1 +edx-search==4.1.2 # via # -r requirements/edx/kernel.in # openedx-forum @@ -538,8 +537,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/kernel.in edx-when==2.5.1 # via # -r requirements/edx/kernel.in @@ -598,7 +595,7 @@ google-api-core[grpc]==2.24.0 # google-cloud-storage google-api-python-client==2.159.0 # via firebase-admin -google-auth==2.37.0 +google-auth==2.38.0 # via # google-api-core # google-api-python-client @@ -626,11 +623,11 @@ googleapis-common-protos==1.66.0 # via # google-api-core # grpcio-status -grpcio==1.69.0 +grpcio==1.70.0 # via # google-api-core # grpcio-status -grpcio-status==1.69.0 +grpcio-status==1.70.0 # via google-api-core gunicorn==23.0.0 # via -r requirements/edx/kernel.in @@ -705,7 +702,7 @@ lazy==1.6 # xblock loremipsum==1.0.5 # via ora2 -lti-consumer-xblock==9.13.1 +lti-consumer-xblock==9.13.2 # via -r requirements/edx/kernel.in lxml[html-clean,html_clean]==5.3.0 # via @@ -814,7 +811,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/kernel.in openedx-django-wiki==2.1.0 # via -r requirements/edx/kernel.in -openedx-events==9.15.2 +openedx-events==9.18.0 # via # -r requirements/edx/kernel.in # edx-enterprise @@ -830,7 +827,7 @@ openedx-filters==1.12.0 # ora2 openedx-forum==0.1.6 # via -r requirements/edx/kernel.in -openedx-learning==0.18.1 +openedx-learning==0.18.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/kernel.in @@ -922,7 +919,7 @@ pycryptodomex==3.21.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.10.5 +pydantic==2.10.6 # via camel-converter pydantic-core==2.27.2 # via pydantic @@ -931,7 +928,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/kernel.in - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via @@ -1044,7 +1040,7 @@ redis==5.2.1 # via # -r requirements/edx/kernel.in # walrus -referencing==0.36.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications @@ -1093,7 +1089,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.11.1 +s3transfer==0.11.2 # via boto3 sailthru-client==2.2.3 # via edx-ace @@ -1139,7 +1135,7 @@ slumber==0.7.1 # -r requirements/edx/kernel.in # edx-bulk-grades # edx-enterprise -snowflake-connector-python==3.12.4 +snowflake-connector-python==3.13.0 # via edx-enterprise social-auth-app-django==5.4.1 # via @@ -1256,7 +1252,7 @@ wheel==0.45.1 # via django-pipeline wrapt==1.17.2 # via -r requirements/edx/kernel.in -xblock[django]==5.1.1 +xblock[django]==5.1.2 # via # -r requirements/edx/kernel.in # acid-xblock @@ -1274,9 +1270,9 @@ xblock[django]==5.1.1 # xblocks-contrib xblock-drag-and-drop-v2==4.0.3 # via -r requirements/edx/bundled.in -xblock-google-drive==0.7.0 +xblock-google-drive==0.7.1 # via -r requirements/edx/bundled.in -xblock-poll==1.14.0 +xblock-poll==1.14.1 # via -r requirements/edx/bundled.in xblock-utils==4.0.0 # via diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index ae377bd5cc7..a16b5d3ac35 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -82,15 +82,14 @@ asn1crypto==1.5.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # snowflake-connector-python -astroid==2.13.5 +astroid==3.3.8 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pylint # pylint-celery # sphinx-autoapi -attrs==24.3.0 +attrs==25.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -145,14 +144,14 @@ boto==2.49.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -boto3==1.36.3 +boto3==1.36.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # django-ses # fs-s3fs # ora2 -botocore==1.36.3 +botocore==1.36.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -171,7 +170,7 @@ cachecontrol==0.14.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -395,7 +394,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -663,7 +661,7 @@ drf-yasg==1.21.8 # -r requirements/edx/testing.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.3 +edx-ace==1.11.4 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -747,7 +745,7 @@ edx-drf-extensions==10.5.0 # edx-when # edxval # openedx-learning -edx-enterprise==5.6.4 +edx-enterprise==5.6.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -767,7 +765,7 @@ edx-i18n-tools==1.5.0 # -r requirements/edx/testing.txt # ora2 # xblocks-contrib -edx-lint==5.4.1 +edx-lint==5.6.0 # via -r requirements/edx/testing.txt edx-milestones==0.6.0 # via @@ -797,7 +795,7 @@ edx-organizations==6.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -edx-proctoring==5.0.1 +edx-proctoring==5.1.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -813,7 +811,7 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/testing.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 +edx-search==4.1.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -845,10 +843,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt edx-when==2.5.1 # via # -r requirements/edx/doc.txt @@ -889,11 +883,11 @@ execnet==2.1.1 # pytest-xdist factory-boy==3.3.1 # via -r requirements/edx/testing.txt -faker==33.3.1 +faker==35.0.0 # via # -r requirements/edx/testing.txt # factory-boy -fastapi==0.115.6 +fastapi==0.115.7 # via # -r requirements/edx/testing.txt # pact-python @@ -967,7 +961,7 @@ google-api-python-client==2.159.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # firebase-admin -google-auth==2.37.0 +google-auth==2.38.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1019,13 +1013,13 @@ grimp==3.5 # via # -r requirements/edx/testing.txt # import-linter -grpcio==1.69.0 +grpcio==1.70.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # google-api-core # grpcio-status -grpcio-status==1.69.0 +grpcio-status==1.70.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1175,11 +1169,6 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lazy-object-proxy==1.10.0 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt - # astroid libsass==0.10.0 # via # -c requirements/edx/../constraints.txt @@ -1189,7 +1178,7 @@ loremipsum==1.0.5 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # ora2 -lti-consumer-xblock==9.13.1 +lti-consumer-xblock==9.13.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1372,7 +1361,7 @@ openedx-django-wiki==2.1.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-events==9.15.2 +openedx-events==9.18.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1392,7 +1381,7 @@ openedx-forum==0.1.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -openedx-learning==0.18.1 +openedx-learning==0.18.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/doc.txt @@ -1424,7 +1413,7 @@ packaging==24.2 # snowflake-connector-python # sphinx # tox -pact-python==2.3.0 +pact-python==2.3.1 # via -r requirements/edx/testing.txt pansi==2024.11.0 # via @@ -1569,7 +1558,7 @@ pycryptodomex==3.21.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.10.5 +pydantic==2.10.6 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1598,7 +1587,6 @@ pyjwkest==1.4.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via @@ -1619,9 +1607,8 @@ pylatexenc==2.10 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # olxcleaner -pylint==2.15.10 +pylint==3.3.3 # via - # -c requirements/edx/../constraints.txt # -r requirements/edx/testing.txt # edx-lint # pylint-celery @@ -1632,7 +1619,7 @@ pylint-celery==0.3 # via # -r requirements/edx/testing.txt # edx-lint -pylint-django==2.5.5 +pylint-django==2.6.1 # via # -r requirements/edx/testing.txt # edx-lint @@ -1641,7 +1628,7 @@ pylint-plugin-utils==0.8.2 # -r requirements/edx/testing.txt # pylint-celery # pylint-django -pylint-pytest==0.3.0 +pylint-pytest==1.1.8 # via -r requirements/edx/testing.txt pylti1p3==2.0.0 # via @@ -1705,7 +1692,7 @@ pysrt==1.1.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # edxval -pytest==8.3.4 +pytest==8.2.0 # via # -r requirements/edx/testing.txt # pylint-pytest @@ -1724,7 +1711,7 @@ pytest-django==4.9.0 # via -r requirements/edx/testing.txt pytest-json-report==1.5.0 # via -r requirements/edx/testing.txt -pytest-metadata==1.8.0 +pytest-metadata==3.1.1 # via # -r requirements/edx/testing.txt # pytest-json-report @@ -1822,7 +1809,7 @@ redis==5.2.1 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # walrus -referencing==0.36.1 +referencing==0.36.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1888,7 +1875,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.11.1 +s3transfer==0.11.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1969,7 +1956,7 @@ snowballstemmer==2.2.0 # via # -r requirements/edx/doc.txt # sphinx -snowflake-connector-python==3.12.4 +snowflake-connector-python==3.13.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2067,7 +2054,7 @@ staff-graded-xblock==3.0.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -starlette==0.41.3 +starlette==0.45.3 # via # -r requirements/edx/testing.txt # fastapi @@ -2252,8 +2239,7 @@ wrapt==1.17.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt - # astroid -xblock[django]==5.1.1 +xblock[django]==5.1.2 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -2274,11 +2260,11 @@ xblock-drag-and-drop-v2==4.0.3 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -xblock-google-drive==0.7.0 +xblock-google-drive==0.7.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt -xblock-poll==1.14.0 +xblock-poll==1.14.1 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 354e85c98f5..e744bea60a3 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -57,11 +57,9 @@ asn1crypto==1.5.1 # via # -r requirements/edx/base.txt # snowflake-connector-python -astroid==2.13.5 - # via - # -c requirements/edx/../constraints.txt - # sphinx-autoapi -attrs==24.3.0 +astroid==3.3.8 + # via sphinx-autoapi +attrs==25.1.0 # via # -r requirements/edx/base.txt # aiohttp @@ -107,13 +105,13 @@ bleach[css]==6.2.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.36.3 +boto3==1.36.6 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.36.3 +botocore==1.36.6 # via # -r requirements/edx/base.txt # boto3 @@ -124,7 +122,7 @@ cachecontrol==0.14.2 # via # -r requirements/edx/base.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.1 # via # -r requirements/edx/base.txt # google-auth @@ -280,7 +278,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -489,7 +486,7 @@ drf-yasg==1.21.8 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.3 +edx-ace==1.11.4 # via -r requirements/edx/base.txt edx-api-doc-tools==2.0.0 # via @@ -555,7 +552,7 @@ edx-drf-extensions==10.5.0 # edx-when # edxval # openedx-learning -edx-enterprise==5.6.4 +edx-enterprise==5.6.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -590,7 +587,7 @@ edx-opaque-keys[django]==2.11.0 # ora2 edx-organizations==6.13.0 # via -r requirements/edx/base.txt -edx-proctoring==5.0.1 +edx-proctoring==5.1.2 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack @@ -603,7 +600,7 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 +edx-search==4.1.2 # via # -r requirements/edx/base.txt # openedx-forum @@ -629,8 +626,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/base.txt edx-when==2.5.1 # via # -r requirements/edx/base.txt @@ -708,7 +703,7 @@ google-api-python-client==2.159.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.37.0 +google-auth==2.38.0 # via # -r requirements/edx/base.txt # google-api-core @@ -748,12 +743,12 @@ googleapis-common-protos==1.66.0 # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio==1.69.0 +grpcio==1.70.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.69.0 +grpcio-status==1.70.0 # via # -r requirements/edx/base.txt # google-api-core @@ -854,13 +849,11 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lazy-object-proxy==1.10.0 - # via astroid loremipsum==1.0.5 # via # -r requirements/edx/base.txt # ora2 -lti-consumer-xblock==9.13.1 +lti-consumer-xblock==9.13.2 # via -r requirements/edx/base.txt lxml[html-clean]==5.3.0 # via @@ -991,7 +984,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.15.2 +openedx-events==9.18.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1007,7 +1000,7 @@ openedx-filters==1.12.0 # ora2 openedx-forum==0.1.6 # via -r requirements/edx/base.txt -openedx-learning==0.18.1 +openedx-learning==0.18.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1126,7 +1119,7 @@ pycryptodomex==3.21.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.10.5 +pydantic==2.10.6 # via # -r requirements/edx/base.txt # camel-converter @@ -1147,7 +1140,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/base.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via @@ -1275,7 +1267,7 @@ redis==5.2.1 # via # -r requirements/edx/base.txt # walrus -referencing==0.36.1 +referencing==0.36.2 # via # -r requirements/edx/base.txt # jsonschema @@ -1332,7 +1324,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.11.1 +s3transfer==0.11.2 # via # -r requirements/edx/base.txt # boto3 @@ -1390,7 +1382,7 @@ smmap==5.0.2 # via gitdb snowballstemmer==2.2.0 # via sphinx -snowflake-connector-python==3.12.4 +snowflake-connector-python==3.13.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1580,10 +1572,8 @@ wheel==0.45.1 # -r requirements/edx/base.txt # django-pipeline wrapt==1.17.2 - # via - # -r requirements/edx/base.txt - # astroid -xblock[django]==5.1.1 + # via -r requirements/edx/base.txt +xblock[django]==5.1.2 # via # -r requirements/edx/base.txt # acid-xblock @@ -1601,9 +1591,9 @@ xblock[django]==5.1.1 # xblocks-contrib xblock-drag-and-drop-v2==4.0.3 # via -r requirements/edx/base.txt -xblock-google-drive==0.7.0 +xblock-google-drive==0.7.1 # via -r requirements/edx/base.txt -xblock-poll==1.14.0 +xblock-poll==1.14.1 # via -r requirements/edx/base.txt xblock-utils==4.0.0 # via diff --git a/requirements/edx/kernel.in b/requirements/edx/kernel.in index d1a13277813..a17b9db4c86 100644 --- a/requirements/edx/kernel.in +++ b/requirements/edx/kernel.in @@ -84,7 +84,6 @@ edx-rest-api-client edx-search edx-submissions edx-toggles # Feature toggles management -edx-token-utils # Validate exam access tokens edx-when edxval event-tracking diff --git a/requirements/edx/semgrep.txt b/requirements/edx/semgrep.txt index 65a0b794b9e..9d25000148f 100644 --- a/requirements/edx/semgrep.txt +++ b/requirements/edx/semgrep.txt @@ -4,7 +4,7 @@ # # make upgrade # -attrs==24.3.0 +attrs==25.1.0 # via # glom # jsonschema @@ -34,7 +34,7 @@ colorama==0.4.6 # via semgrep defusedxml==0.7.1 # via semgrep -deprecated==1.2.15 +deprecated==1.2.18 # via # opentelemetry-api # opentelemetry-exporter-otlp-proto-http @@ -92,13 +92,13 @@ packaging==24.2 # via semgrep peewee==3.17.8 # via semgrep -protobuf==4.25.5 +protobuf==4.25.6 # via # googleapis-common-protos # opentelemetry-proto pygments==2.19.1 # via rich -referencing==0.36.1 +referencing==0.36.2 # via # jsonschema # jsonschema-specifications @@ -116,7 +116,7 @@ ruamel-yaml==0.18.10 # via semgrep ruamel-yaml-clib==0.2.12 # via ruamel-yaml -semgrep==1.103.0 +semgrep==1.104.0 # via -r requirements/edx/semgrep.in tomli==2.0.2 # via semgrep diff --git a/requirements/edx/testing.in b/requirements/edx/testing.in index cf57aeb0fc4..14a0c781da8 100644 --- a/requirements/edx/testing.in +++ b/requirements/edx/testing.in @@ -37,13 +37,13 @@ pytest-attrib # Select tests based on attributes pytest-cov # pytest plugin for measuring code coverage pytest-django # Django support for pytest pytest-json-report # Output json formatted warnings after running pytest -pytest-metadata==1.8.0 # To prevent 'make upgrade' failure, dependency of pytest-json-report +pytest-metadata # To prevent 'make upgrade' failure, dependency of pytest-json-report pytest-randomly # pytest plugin to randomly order tests pytest-xdist[psutil] # Parallel execution of tests on multiple CPU cores or hosts singledispatch # Backport of functools.singledispatch from Python 3.4+, used in tests of XBlock rendering testfixtures # Provides a LogCapture utility used by several tests tox # virtualenv management for tests unidiff # Required by coverage_pytest_plugin -pylint-pytest==0.3.0 # A Pylint plugin to suppress pytest-related false positives. +pylint-pytest # A Pylint plugin to suppress pytest-related false positives. pact-python # Library for contract testing py # Needed for pytest configurations, was previously been fetched through tox diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 799f8b16ed2..e34d3103a51 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -55,12 +55,11 @@ asn1crypto==1.5.1 # via # -r requirements/edx/base.txt # snowflake-connector-python -astroid==2.13.5 +astroid==3.3.8 # via - # -c requirements/edx/../constraints.txt # pylint # pylint-celery -attrs==24.3.0 +attrs==25.1.0 # via # -r requirements/edx/base.txt # aiohttp @@ -104,13 +103,13 @@ bleach[css]==6.2.0 # xblock-poll boto==2.49.0 # via -r requirements/edx/base.txt -boto3==1.36.3 +boto3==1.36.6 # via # -r requirements/edx/base.txt # django-ses # fs-s3fs # ora2 -botocore==1.36.3 +botocore==1.36.6 # via # -r requirements/edx/base.txt # boto3 @@ -121,7 +120,7 @@ cachecontrol==0.14.2 # via # -r requirements/edx/base.txt # firebase-admin -cachetools==5.5.0 +cachetools==5.5.1 # via # -r requirements/edx/base.txt # google-auth @@ -306,7 +305,6 @@ django==4.2.18 # edx-search # edx-submissions # edx-toggles - # edx-token-utils # edx-when # edxval # enmerkar @@ -510,7 +508,7 @@ drf-yasg==1.21.8 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.11.3 +edx-ace==1.11.4 # via -r requirements/edx/base.txt edx-api-doc-tools==2.0.0 # via @@ -576,7 +574,7 @@ edx-drf-extensions==10.5.0 # edx-when # edxval # openedx-learning -edx-enterprise==5.6.4 +edx-enterprise==5.6.11 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -590,7 +588,7 @@ edx-i18n-tools==1.5.0 # -r requirements/edx/base.txt # ora2 # xblocks-contrib -edx-lint==5.4.1 +edx-lint==5.6.0 # via -r requirements/edx/testing.in edx-milestones==0.6.0 # via -r requirements/edx/base.txt @@ -613,7 +611,7 @@ edx-opaque-keys[django]==2.11.0 # ora2 edx-organizations==6.13.0 # via -r requirements/edx/base.txt -edx-proctoring==5.0.1 +edx-proctoring==5.1.2 # via # -r requirements/edx/base.txt # edx-proctoring-proctortrack @@ -626,7 +624,7 @@ edx-rest-api-client==6.0.0 # -r requirements/edx/base.txt # edx-enterprise # edx-proctoring -edx-search==4.1.1 +edx-search==4.1.2 # via # -r requirements/edx/base.txt # openedx-forum @@ -652,8 +650,6 @@ edx-toggles==5.2.0 # edxval # event-tracking # ora2 -edx-token-utils==0.2.1 - # via -r requirements/edx/base.txt edx-when==2.5.1 # via # -r requirements/edx/base.txt @@ -684,9 +680,9 @@ execnet==2.1.1 # via pytest-xdist factory-boy==3.3.1 # via -r requirements/edx/testing.in -faker==33.3.1 +faker==35.0.0 # via factory-boy -fastapi==0.115.6 +fastapi==0.115.7 # via pact-python fastavro==1.10.0 # via @@ -739,7 +735,7 @@ google-api-python-client==2.159.0 # via # -r requirements/edx/base.txt # firebase-admin -google-auth==2.37.0 +google-auth==2.38.0 # via # -r requirements/edx/base.txt # google-api-core @@ -781,12 +777,12 @@ googleapis-common-protos==1.66.0 # grpcio-status grimp==3.5 # via import-linter -grpcio==1.69.0 +grpcio==1.70.0 # via # -r requirements/edx/base.txt # google-api-core # grpcio-status -grpcio-status==1.69.0 +grpcio-status==1.70.0 # via # -r requirements/edx/base.txt # google-api-core @@ -897,13 +893,11 @@ lazy==1.6 # lti-consumer-xblock # ora2 # xblock -lazy-object-proxy==1.10.0 - # via astroid loremipsum==1.0.5 # via # -r requirements/edx/base.txt # ora2 -lti-consumer-xblock==9.13.1 +lti-consumer-xblock==9.13.2 # via -r requirements/edx/base.txt lxml[html-clean]==5.3.0 # via @@ -1038,7 +1032,7 @@ openedx-django-require==2.1.0 # via -r requirements/edx/base.txt openedx-django-wiki==2.1.0 # via -r requirements/edx/base.txt -openedx-events==9.15.2 +openedx-events==9.18.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1054,7 +1048,7 @@ openedx-filters==1.12.0 # ora2 openedx-forum==0.1.6 # via -r requirements/edx/base.txt -openedx-learning==0.18.1 +openedx-learning==0.18.3 # via # -c requirements/edx/../constraints.txt # -r requirements/edx/base.txt @@ -1076,7 +1070,7 @@ packaging==24.2 # pytest # snowflake-connector-python # tox -pact-python==2.3.0 +pact-python==2.3.1 # via -r requirements/edx/testing.in pansi==2024.11.0 # via @@ -1193,7 +1187,7 @@ pycryptodomex==3.21.0 # edx-proctoring # lti-consumer-xblock # pyjwkest -pydantic==2.10.5 +pydantic==2.10.6 # via # -r requirements/edx/base.txt # camel-converter @@ -1211,7 +1205,6 @@ pygments==2.19.1 pyjwkest==1.4.2 # via # -r requirements/edx/base.txt - # edx-token-utils # lti-consumer-xblock pyjwt[crypto]==2.10.1 # via @@ -1230,9 +1223,8 @@ pylatexenc==2.10 # via # -r requirements/edx/base.txt # olxcleaner -pylint==2.15.10 +pylint==3.3.3 # via - # -c requirements/edx/../constraints.txt # edx-lint # pylint-celery # pylint-django @@ -1240,13 +1232,13 @@ pylint==2.15.10 # pylint-pytest pylint-celery==0.3 # via edx-lint -pylint-django==2.5.5 +pylint-django==2.6.1 # via edx-lint pylint-plugin-utils==0.8.2 # via # pylint-celery # pylint-django -pylint-pytest==0.3.0 +pylint-pytest==1.1.8 # via -r requirements/edx/testing.in pylti1p3==2.0.0 # via -r requirements/edx/base.txt @@ -1291,7 +1283,7 @@ pysrt==1.1.2 # via # -r requirements/edx/base.txt # edxval -pytest==8.3.4 +pytest==8.2.0 # via # -r requirements/edx/testing.in # pylint-pytest @@ -1310,7 +1302,7 @@ pytest-django==4.9.0 # via -r requirements/edx/testing.in pytest-json-report==1.5.0 # via -r requirements/edx/testing.in -pytest-metadata==1.8.0 +pytest-metadata==3.1.1 # via # -r requirements/edx/testing.in # pytest-json-report @@ -1388,7 +1380,7 @@ redis==5.2.1 # via # -r requirements/edx/base.txt # walrus -referencing==0.36.1 +referencing==0.36.2 # via # -r requirements/edx/base.txt # jsonschema @@ -1445,7 +1437,7 @@ rules==3.5 # edx-enterprise # edx-proctoring # openedx-learning -s3transfer==0.11.1 +s3transfer==0.11.2 # via # -r requirements/edx/base.txt # boto3 @@ -1504,7 +1496,7 @@ slumber==0.7.1 # edx-enterprise sniffio==1.3.1 # via anyio -snowflake-connector-python==3.12.4 +snowflake-connector-python==3.13.0 # via # -r requirements/edx/base.txt # edx-enterprise @@ -1536,7 +1528,7 @@ sqlparse==0.5.3 # django staff-graded-xblock==3.0.0 # via -r requirements/edx/base.txt -starlette==0.41.3 +starlette==0.45.3 # via fastapi stevedore==5.4.0 # via @@ -1670,10 +1662,8 @@ wheel==0.45.1 # -r requirements/edx/base.txt # django-pipeline wrapt==1.17.2 - # via - # -r requirements/edx/base.txt - # astroid -xblock[django]==5.1.1 + # via -r requirements/edx/base.txt +xblock[django]==5.1.2 # via # -r requirements/edx/base.txt # acid-xblock @@ -1691,9 +1681,9 @@ xblock[django]==5.1.1 # xblocks-contrib xblock-drag-and-drop-v2==4.0.3 # via -r requirements/edx/base.txt -xblock-google-drive==0.7.0 +xblock-google-drive==0.7.1 # via -r requirements/edx/base.txt -xblock-poll==1.14.0 +xblock-poll==1.14.1 # via -r requirements/edx/base.txt xblock-utils==4.0.0 # via diff --git a/scripts/user_retirement/requirements/base.txt b/scripts/user_retirement/requirements/base.txt index 5629aee43ee..35cf225d6b1 100644 --- a/scripts/user_retirement/requirements/base.txt +++ b/scripts/user_retirement/requirements/base.txt @@ -6,17 +6,17 @@ # asgiref==3.8.1 # via django -attrs==24.3.0 +attrs==25.1.0 # via zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.in -boto3==1.36.3 +boto3==1.36.6 # via -r scripts/user_retirement/requirements/base.in -botocore==1.36.3 +botocore==1.36.6 # via # boto3 # s3transfer -cachetools==5.5.0 +cachetools==5.5.1 # via google-auth certifi==2024.12.14 # via requests @@ -54,7 +54,7 @@ google-api-core==2.24.0 # via google-api-python-client google-api-python-client==2.159.0 # via -r scripts/user_retirement/requirements/base.in -google-auth==2.37.0 +google-auth==2.38.0 # via # google-api-core # google-api-python-client @@ -136,7 +136,7 @@ requests-toolbelt==1.0.0 # via zeep rsa==4.9 # via google-auth -s3transfer==0.11.1 +s3transfer==0.11.2 # via boto3 simple-salesforce==1.12.6 # via -r scripts/user_retirement/requirements/base.in diff --git a/scripts/user_retirement/requirements/testing.txt b/scripts/user_retirement/requirements/testing.txt index ad69c332b70..4f13157aee0 100644 --- a/scripts/user_retirement/requirements/testing.txt +++ b/scripts/user_retirement/requirements/testing.txt @@ -8,23 +8,23 @@ asgiref==3.8.1 # via # -r scripts/user_retirement/requirements/base.txt # django -attrs==24.3.0 +attrs==25.1.0 # via # -r scripts/user_retirement/requirements/base.txt # zeep backoff==2.2.1 # via -r scripts/user_retirement/requirements/base.txt -boto3==1.36.3 +boto3==1.36.6 # via # -r scripts/user_retirement/requirements/base.txt # moto -botocore==1.36.3 +botocore==1.36.6 # via # -r scripts/user_retirement/requirements/base.txt # boto3 # moto # s3transfer -cachetools==5.5.0 +cachetools==5.5.1 # via # -r scripts/user_retirement/requirements/base.txt # google-auth @@ -78,7 +78,7 @@ google-api-core==2.24.0 # google-api-python-client google-api-python-client==2.159.0 # via -r scripts/user_retirement/requirements/base.txt -google-auth==2.37.0 +google-auth==2.38.0 # via # -r scripts/user_retirement/requirements/base.txt # google-api-core @@ -235,7 +235,7 @@ rsa==4.9 # via # -r scripts/user_retirement/requirements/base.txt # google-auth -s3transfer==0.11.1 +s3transfer==0.11.2 # via # -r scripts/user_retirement/requirements/base.txt # boto3 diff --git a/scripts/xblock/list-installed.py b/scripts/xblock/list-installed.py index 9537ac6c49b..fb19bf6db92 100644 --- a/scripts/xblock/list-installed.py +++ b/scripts/xblock/list-installed.py @@ -13,7 +13,7 @@ def get_without_builtins(): xblocks = [ entry_point.name for entry_point in entry_points(group='xblock.v1') - if not entry_point.module.startswith('xmodule') + if not entry_point.value.startswith('xmodule') ] return sorted(xblocks) diff --git a/webpack-config/file-lists.js b/webpack-config/file-lists.js index 7167a6f5ddd..d9e818f9127 100644 --- a/webpack-config/file-lists.js +++ b/webpack-config/file-lists.js @@ -79,9 +79,6 @@ module.exports = { path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/program_header_view.js'), path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/sidebar_view.js'), path.resolve(__dirname, '../lms/static/js/learner_dashboard/views/upgrade_message_view.js'), - path.resolve(__dirname, '../lms/static/js/student_account/views/account_section_view.js'), - path.resolve(__dirname, '../lms/static/js/student_account/views/account_settings_fields.js'), - path.resolve(__dirname, '../lms/static/js/student_account/views/account_settings_view.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/FormView.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/LoginView.js'), path.resolve(__dirname, '../lms/static/js/student_account/views/RegisterView.js'), diff --git a/xmodule/capa/tests/test_input_templates.py b/xmodule/capa/tests/test_input_templates.py index 4b14bd5ef86..3048f62095d 100644 --- a/xmodule/capa/tests/test_input_templates.py +++ b/xmodule/capa/tests/test_input_templates.py @@ -76,8 +76,7 @@ def render_to_xml(self, context_dict): except Exception as exc: raise TemplateError("Could not parse XML from '{0}': {1}".format( # lint-amnesty, pylint: disable=raise-missing-from xml_str, str(exc))) - else: - return xml + return xml def assert_has_xpath(self, xml_root, xpath, context_dict, exact_num=1): """ diff --git a/xmodule/modulestore/django.py b/xmodule/modulestore/django.py index 270aa104314..c15f03a4e33 100644 --- a/xmodule/modulestore/django.py +++ b/xmodule/modulestore/django.py @@ -6,10 +6,10 @@ from contextlib import contextmanager from importlib import import_module +import importlib.resources as resources import gettext import logging -from importlib.resources import path as resources_path import re # lint-amnesty, pylint: disable=wrong-import-order from django.conf import settings @@ -422,10 +422,9 @@ def get_python_locale(self, block): return 'django', xblock_locale_path # Pre-OEP-58 translations within the XBlock pip packages are deprecated but supported. - deprecated_xblock_locale_path = str(resources_path(xblock_module_name, 'translations')) - - # The `text` domain was used for XBlocks pre-OEP-58. - return 'text', deprecated_xblock_locale_path + with resources.as_file(resources.files(xblock_module_name) / 'translations') as deprecated_xblock_locale_path: + # The `text` domain was used for XBlocks pre-OEP-58. + return 'text', str(deprecated_xblock_locale_path) def get_javascript_i18n_catalog_url(self, block): """ diff --git a/xmodule/modulestore/tests/test_django_utils.py b/xmodule/modulestore/tests/test_django_utils.py index 9d0ed78ea33..f8fba427b4e 100644 --- a/xmodule/modulestore/tests/test_django_utils.py +++ b/xmodule/modulestore/tests/test_django_utils.py @@ -2,6 +2,7 @@ Tests for the modulestore.django module """ +from pathlib import Path from unittest.mock import patch import django.utils.translation @@ -23,19 +24,19 @@ def test_get_python_locale_with_atlas_oep58_translations(mock_modern_xblock): assert domain == 'django', 'Uses django domain when atlas locale is found.' -@patch('xmodule.modulestore.django.resources_path', return_value='/lib/my_legacy_xblock/translations') +@patch('importlib.resources.files', return_value=Path('/lib/my_legacy_xblock')) def test_get_python_locale_with_bundled_translations(mock_modern_xblock): """ Ensure that get_python_locale() falls back to XBlock internal translations if atlas translations weren't pulled. Pre-OEP-58 translations were stored in the `translations` directory of the XBlock which is - accessible via the `importlib.resources.path` function. + accessible via the `importlib.resources.files` function. """ i18n_service = XBlockI18nService() block = mock_modern_xblock['legacy_xblock'] domain, path = i18n_service.get_python_locale(block) - assert path == '/lib/my_legacy_xblock/translations', 'Backward compatible with pe-OEP-58.' + assert path == '/lib/my_legacy_xblock/translations', 'Backward compatible with pre-OEP-58.' assert domain == 'text', 'Use the legacy `text` domain for backward compatibility with old XBlocks.' diff --git a/xmodule/modulestore/tests/test_mixed_modulestore.py b/xmodule/modulestore/tests/test_mixed_modulestore.py index 0928ab253b9..bbc91f832f9 100644 --- a/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -164,6 +164,19 @@ def setUp(self): self.course_locations = {} self.user_id = ModuleStoreEnum.UserID.test + # mock and ignore publishable link entity related tasks to avoid unnecessary + # errors as it is tested separately + if settings.ROOT_URLCONF == 'cms.urls': + create_or_update_xblock_upstream_link_patch = patch( + 'cms.djangoapps.contentstore.signals.handlers.handle_create_or_update_xblock_upstream_link' + ) + create_or_update_xblock_upstream_link_patch.start() + self.addCleanup(create_or_update_xblock_upstream_link_patch.stop) + publishableEntityLinkPatch = patch( + 'cms.djangoapps.contentstore.signals.handlers.PublishableEntityLink' + ) + publishableEntityLinkPatch.start() + self.addCleanup(publishableEntityLinkPatch.stop) def _check_connection(self): """ diff --git a/xmodule/modulestore/xml_importer.py b/xmodule/modulestore/xml_importer.py index 5b880b4ade2..05f0a9954ef 100644 --- a/xmodule/modulestore/xml_importer.py +++ b/xmodule/modulestore/xml_importer.py @@ -27,6 +27,7 @@ import os import re from abc import abstractmethod +from datetime import datetime, timezone import xblock from django.core.exceptions import ObjectDoesNotExist @@ -34,12 +35,15 @@ from lxml import etree from opaque_keys.edx.keys import UsageKey from opaque_keys.edx.locator import LibraryLocator +from openedx_events.content_authoring.data import CourseData +from openedx_events.content_authoring.signals import COURSE_IMPORT_COMPLETED from path import Path as path from xblock.core import XBlockMixin from xblock.fields import Reference, ReferenceList, ReferenceValueDict, Scope from xblock.runtime import DictKeyValueStore, KvsFieldData from common.djangoapps.util.monitoring import monitor_import_failure +from openedx.core.djangoapps.content_tagging.api import import_course_tags_from_csv from xmodule.assetstore import AssetMetadata from xmodule.contentstore.content import StaticContent from xmodule.errortracker import make_error_tracker @@ -52,7 +56,6 @@ from xmodule.tabs import CourseTabList from xmodule.util.misc import escape_invalid_characters from xmodule.x_module import XModuleMixin -from openedx.core.djangoapps.content_tagging.api import import_course_tags_from_csv from .inheritance import own_metadata from .store_utilities import rewrite_nonportable_content_links @@ -548,6 +551,11 @@ def depth_first(subtree): # pylint: disable=raise-missing-from raise BlockFailedToImport(leftover.display_name, leftover.location) + def post_course_import(self, dest_id): + """ + Tasks that need to triggered after a course is imported. + """ + def run_imports(self): """ Iterate over the given directories and yield courses. @@ -589,6 +597,7 @@ def run_imports(self): logging.info(f'Course import {dest_id}: No tags.csv file present.') except ValueError as e: logging.info(f'Course import {dest_id}: {str(e)}') + self.post_course_import(dest_id) yield courselike @@ -717,6 +726,18 @@ def import_tags(self, data_path, dest_id): csv_path = path(data_path) / 'tags.csv' import_course_tags_from_csv(csv_path, dest_id) + def post_course_import(self, dest_id): + """ + Trigger celery task to create upstream links for newly imported blocks. + """ + # .. event_implemented_name: COURSE_IMPORT_COMPLETED + COURSE_IMPORT_COMPLETED.send_event( + time=datetime.now(timezone.utc), + course=CourseData( + course_key=dest_id + ) + ) + class LibraryImportManager(ImportManager): """ diff --git a/xmodule/split_test_block.py b/xmodule/split_test_block.py index 05ca3a5db45..52f40547879 100644 --- a/xmodule/split_test_block.py +++ b/xmodule/split_test_block.py @@ -419,9 +419,8 @@ def log_child_render(self, request, suffix=''): # lint-amnesty, pylint: disable ) ) raise - else: - self.runtime.publish(self, 'xblock.split_test.child_render', {'child_id': child_id}) - return Response() + self.runtime.publish(self, 'xblock.split_test.child_render', {'child_id': child_id}) + return Response() def get_icon_class(self): return self.child.get_icon_class() if self.child else 'other' diff --git a/xmodule/static/css-builtin-blocks/AnnotatableBlockDisplay.css b/xmodule/static/css-builtin-blocks/AnnotatableBlockDisplay.css index 804d84a7717..0a17543285c 100644 --- a/xmodule/static/css-builtin-blocks/AnnotatableBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/AnnotatableBlockDisplay.css @@ -11,7 +11,7 @@ .xmodule_display.xmodule_AnnotatableBlock .annotatable-section { position: relative; padding: 0.5em 1em; - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); border-radius: 0.5em; margin-bottom: 0.5em; } @@ -29,7 +29,7 @@ } .xmodule_display.xmodule_AnnotatableBlock .annotatable-section .annotatable-section-body { - border-top: 1px solid var(--gray-l3); + border-top: 1px solid var(--gray-l3, #c8c8c8); margin-top: 0.5em; padding-top: 0.5em; } @@ -151,7 +151,7 @@ border: 1px solid #333; border-radius: 1em; background-color: rgba(0, 0, 0, 0.85); - color: var(--white); + color: var(--white, #fff); -webkit-font-smoothing: antialiased; } @@ -159,12 +159,12 @@ font-size: 1em; color: inherit; background-color: transparent; - padding: calc((var(--baseline) / 4)) calc((var(--baseline) / 2)); + padding: calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); border: none; } .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-tooltip-title { - padding: calc((var(--baseline) / 4)) 0; + padding: calc((var(--baseline, 20px) / 4)) 0; border-bottom: 2px solid #333; font-weight: bold; } @@ -176,7 +176,7 @@ .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-titlebar .ui-state-hover { color: inherit; - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip .ui-tooltip-content { @@ -184,7 +184,7 @@ font-size: 0.875em; text-align: left; font-weight: 400; - padding: 0 calc((var(--baseline) / 2)) calc((var(--baseline) / 2)) calc((var(--baseline) / 2)); + padding: 0 calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 2)); background-color: transparent; border-color: transparent; } @@ -199,12 +199,12 @@ } .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content { - padding: 0 calc((var(--baseline) / 2)); + padding: 0 calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content .annotatable-comment { display: block; - margin: 0 0 calc((var(--baseline) / 2)) 0; + margin: 0 0 calc((var(--baseline, 20px) / 2)) 0; max-height: 225px; overflow: auto; line-height: normal; @@ -213,7 +213,7 @@ .xmodule_display.xmodule_AnnotatableBlock .ui-tooltip.qtip.ui-tooltip-annotatable .ui-tooltip-content .annotatable-reply { display: block; border-top: 2px solid #333; - padding: calc((var(--baseline) / 4)) 0; + padding: calc((var(--baseline, 20px) / 4)) 0; margin: 0; text-align: center; } @@ -226,7 +226,7 @@ left: 50%; height: 0; width: 0; - margin-left: calc(-1 * (var(--baseline) / 4)); + margin-left: calc(-1 * (var(--baseline, 20px) / 4)); border: 10px solid transparent; border-top-color: rgba(0, 0, 0, 0.85); } diff --git a/xmodule/static/css-builtin-blocks/HtmlBlockDisplay.css b/xmodule/static/css-builtin-blocks/HtmlBlockDisplay.css index 0e7a2001709..ff8b7c7fae3 100644 --- a/xmodule/static/css-builtin-blocks/HtmlBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/HtmlBlockDisplay.css @@ -11,7 +11,7 @@ .xmodule_display.xmodule_CourseInfoBlock h1, .xmodule_display.xmodule_HtmlBlock h1, .xmodule_display.xmodule_StaticTabBlock h1 { - color: var(--body-color); + color: var(--body-color, #313131); font: normal 2em/1.4em var(--font-family-sans-serif); letter-spacing: 1px; margin: 0 0 1.416em; @@ -24,7 +24,7 @@ color: #646464; font: normal 1.2em/1.2em var(--font-family-sans-serif); letter-spacing: 1px; - margin-bottom: calc((var(--baseline) * 0.75)); + margin-bottom: calc((var(--baseline, 20px) * 0.75)); -webkit-font-smoothing: antialiased; } @@ -44,7 +44,7 @@ .xmodule_display.xmodule_StaticTabBlock h4, .xmodule_display.xmodule_StaticTabBlock h5, .xmodule_display.xmodule_StaticTabBlock h6 { - margin: 0 0 calc((var(--baseline) / 2)); + margin: 0 0 calc((var(--baseline, 20px) / 2)); font-weight: 600; } @@ -83,7 +83,7 @@ margin-bottom: 1.416em; font-size: 1em; line-height: 1.6em !important; - color: var(--body-color); + color: var(--body-color, #313131); } .xmodule_display.xmodule_AboutBlock em, @@ -142,14 +142,14 @@ .xmodule_display.xmodule_StaticTabBlock p+p, .xmodule_display.xmodule_StaticTabBlock ul+p, .xmodule_display.xmodule_StaticTabBlock ol+p { - margin-top: var(--baseline); + margin-top: var(--baseline, 20px); } .xmodule_display.xmodule_AboutBlock blockquote, .xmodule_display.xmodule_CourseInfoBlock blockquote, .xmodule_display.xmodule_HtmlBlock blockquote, .xmodule_display.xmodule_StaticTabBlock blockquote { - margin: 1em calc((var(--baseline) * 2)); + margin: 1em calc((var(--baseline, 20px) * 2)); } .xmodule_display.xmodule_AboutBlock ol, @@ -162,7 +162,7 @@ .xmodule_display.xmodule_StaticTabBlock ul { padding: 0 0 0 1em; margin: 1em 0; - color: var(--body-color); + color: var(--body-color, #313131); } .xmodule_display.xmodule_AboutBlock ol li, @@ -210,7 +210,7 @@ .xmodule_display.xmodule_StaticTabBlock a:hover, .xmodule_display.xmodule_StaticTabBlock a:active, .xmodule_display.xmodule_StaticTabBlock a:focus { - color: var(--blue); + color: var(--blue, #0075b4); } .xmodule_display.xmodule_AboutBlock img, @@ -226,7 +226,7 @@ .xmodule_display.xmodule_HtmlBlock pre, .xmodule_display.xmodule_StaticTabBlock pre { margin: 1em 0; - color: var(--body-color); + color: var(--body-color, #313131); font-family: monospace, serif; font-size: 1em; white-space: pre-wrap; @@ -237,7 +237,7 @@ .xmodule_display.xmodule_CourseInfoBlock code, .xmodule_display.xmodule_HtmlBlock code, .xmodule_display.xmodule_StaticTabBlock code { - color: var(--body-color); + color: var(--body-color, #313131); font-family: monospace, serif; background: none; padding: 0; @@ -248,7 +248,7 @@ .xmodule_display.xmodule_HtmlBlock table, .xmodule_display.xmodule_StaticTabBlock table { width: 100%; - margin: var(--baseline) 0; + margin: var(--baseline, 20px) 0; border-collapse: collapse; font-size: 16px; } @@ -261,9 +261,9 @@ .xmodule_display.xmodule_HtmlBlock table th, .xmodule_display.xmodule_StaticTabBlock table td, .xmodule_display.xmodule_StaticTabBlock table th { - margin: var(--baseline) 0; - padding: calc((var(--baseline) / 2)); - border: 1px solid var(--gray-l3); + margin: var(--baseline, 20px) 0; + padding: calc((var(--baseline, 20px) / 2)); + border: 1px solid var(--gray-l3, #c8c8c8); font-size: 14px; } @@ -314,12 +314,12 @@ .xmodule_display.xmodule_StaticTabBlock .wrapper-modal-image .modal-ui-icon { position: absolute; display: block; - padding: calc((var(--baseline) / 4)) 7px; + padding: calc((var(--baseline, 20px) / 4)) 7px; border-radius: 5px; opacity: 0.9; - background: var(--white); - color: var(--black); - border: 2px solid var(--black); + background: var(--white, #fff); + color: var(--black, #000); + border: 2px solid var(--black, #000); } .xmodule_display.xmodule_AboutBlock .wrapper-modal-image .modal-ui-icon .label, @@ -446,14 +446,14 @@ .xmodule_display.xmodule_CourseInfoBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-in, .xmodule_display.xmodule_HtmlBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-in, .xmodule_display.xmodule_StaticTabBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-in { - margin-right: calc((var(--baseline) / 4)); + margin-right: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_AboutBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-out, .xmodule_display.xmodule_CourseInfoBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-out, .xmodule_display.xmodule_HtmlBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-out, .xmodule_display.xmodule_StaticTabBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.action-zoom-out { - margin-left: calc((var(--baseline) / 4)); + margin-left: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_AboutBlock .wrapper-modal-image .image-modal .image-content .image-controls .image-control .modal-ui-icon.is-disabled, diff --git a/xmodule/static/css-builtin-blocks/HtmlBlockEditor.css b/xmodule/static/css-builtin-blocks/HtmlBlockEditor.css index c7ef3b7ec14..acb9079c816 100644 --- a/xmodule/static/css-builtin-blocks/HtmlBlockEditor.css +++ b/xmodule/static/css-builtin-blocks/HtmlBlockEditor.css @@ -80,7 +80,7 @@ background-image: -webkit-linear-gradient(top, #d4dee8, #c9d5e2); background-image: linear-gradient(to bottom, #d4dee8, #c9d5e2); position: relative; - padding: calc(var(--baseline) / 4); + padding: calc(var(--baseline, 20px) / 4); border-bottom-color: #a5aaaf; } @@ -99,7 +99,7 @@ .xmodule_edit.xmodule_StaticTabBlock .editor .editor-bar button { display: inline-block; float: left; - padding: 3px calc(var(--baseline) / 2) 5px; + padding: 3px calc(var(--baseline, 20px) / 2) 5px; margin-left: 7px; border: 0; border-radius: 2px; @@ -140,7 +140,7 @@ .xmodule_edit.xmodule_HtmlBlock .editor .editor-tabs li, .xmodule_edit.xmodule_StaticTabBlock .editor .editor-tabs li { float: left; - margin-right: calc(var(--baseline) / 4); + margin-right: calc(var(--baseline, 20px) / 4); } .xmodule_edit.xmodule_AboutBlock .editor .editor-tabs li:last-child, @@ -159,9 +159,9 @@ padding: 7px 20px 3px; border: 1px solid #a5aaaf; border-radius: 3px 3px 0 0; - background-color: var(--transparent); - background-image: -webkit-linear-gradient(top, var(--transparent) 87%, rgba(0, 0, 0, 0.06)); - background-image: linear-gradient(to bottom, var(--transparent) 87%, rgba(0, 0, 0, 0.06)); + background-color: var(--transparent, transparent); + background-image: -webkit-linear-gradient(top, var(--transparent, transparent) 87%, rgba(0, 0, 0, 0.06)); + background-image: linear-gradient(to bottom, var(--transparent, transparent) 87%, rgba(0, 0, 0, 0.06)); background-color: #e5ecf3; font-size: 13px; color: #3c3c3c; @@ -172,8 +172,8 @@ .xmodule_edit.xmodule_CourseInfoBlock .editor .editor-tabs .tab.current, .xmodule_edit.xmodule_HtmlBlock .editor .editor-tabs .tab.current, .xmodule_edit.xmodule_StaticTabBlock .editor .editor-tabs .tab.current { - background: var(--white); - border-bottom-color: var(--white); + background: var(--white, #fff); + border-bottom-color: var(--white, #fff); } .xmodule_edit.xmodule_AboutBlock .html-editor:after, diff --git a/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css b/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css index 5ca27a4c4a0..ab39520a5f2 100644 --- a/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/LTIBlockDisplay.css @@ -7,7 +7,7 @@ .xmodule_display.xmodule_LTIBlock div.problem-progress { display: inline-block; - padding-left: calc((var(--baseline) / 4)); + padding-left: calc((var(--baseline, 20px) / 4)); color: #666; font-weight: 100; font-size: 1em; @@ -19,8 +19,8 @@ .xmodule_display.xmodule_LTIBlock div.lti .wrapper-lti-link { font-size: 14px; - background-color: var(--sidebar-color); - padding: var(--baseline); + background-color: var(--sidebar-color, #f6f6f6); + padding: var(--baseline, 20px); } .xmodule_display.xmodule_LTIBlock div.lti .wrapper-lti-link .lti-link { @@ -51,6 +51,6 @@ } .xmodule_display.xmodule_LTIBlock div.lti div.problem-feedback { - margin-top: calc((var(--baseline) / 4)); - margin-bottom: calc((var(--baseline) / 4)); + margin-top: calc((var(--baseline, 20px) / 4)); + margin-bottom: calc((var(--baseline, 20px) / 4)); } diff --git a/xmodule/static/css-builtin-blocks/PollBlockDisplay.css b/xmodule/static/css-builtin-blocks/PollBlockDisplay.css index cf9058281dd..0615ecf123d 100644 --- a/xmodule/static/css-builtin-blocks/PollBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/PollBlockDisplay.css @@ -19,13 +19,13 @@ .xmodule_display.xmodule_PollBlock div.poll_question h3 { margin-top: 0; - margin-bottom: calc((var(--baseline) * 0.75)); + margin-bottom: calc((var(--baseline, 20px) * 0.75)); color: #fe57a1; font-size: 1.9em; } .xmodule_display.xmodule_PollBlock div.poll_question h3.problem-header div.staff { - margin-top: calc((var(--baseline) * 1.5)); + margin-top: calc((var(--baseline, 20px) * 1.5)); font-size: 80%; } @@ -43,7 +43,7 @@ } .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer { - margin-bottom: var(--baseline); + margin-bottom: var(--baseline, 20px); } .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer.short { @@ -105,7 +105,7 @@ font-weight: bold; letter-spacing: normal; line-height: 25.59375px; - margin-bottom: calc((var(--baseline) * 0.75)); + margin-bottom: calc((var(--baseline, 20px) * 0.75)); margin: 0; padding: 0px; text-align: center; @@ -141,9 +141,9 @@ width: 80%; text-align: left; min-height: 30px; - margin-left: var(--baseline); + margin-left: var(--baseline, 20px); height: auto; - margin-bottom: var(--baseline); + margin-bottom: var(--baseline, 20px); } .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .question .text.short { @@ -152,7 +152,7 @@ .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats { min-height: 40px; - margin-top: var(--baseline); + margin-top: var(--baseline, 20px); clear: both; } @@ -170,7 +170,7 @@ border: 1px solid black; display: inline; float: left; - margin-right: calc((var(--baseline) / 2)); + margin-right: calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_PollBlock div.poll_question .poll_answer .stats .bar.short { diff --git a/xmodule/static/css-builtin-blocks/ProblemBlockDisplay.css b/xmodule/static/css-builtin-blocks/ProblemBlockDisplay.css index 3cd20ab7608..aa731796f44 100644 --- a/xmodule/static/css-builtin-blocks/ProblemBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/ProblemBlockDisplay.css @@ -41,7 +41,7 @@ .xmodule_display.xmodule_ProblemBlock h2 { margin-top: 0; - margin-bottom: calc((var(--baseline) * 0.75)); + margin-bottom: calc((var(--baseline, 20px) * 0.75)); } .xmodule_display.xmodule_ProblemBlock h2.problem-header { @@ -49,7 +49,7 @@ } .xmodule_display.xmodule_ProblemBlock h2.problem-header section.staff { - margin-top: calc((var(--baseline) * 1.5)); + margin-top: calc((var(--baseline, 20px) * 1.5)); font-size: 80%; } @@ -68,22 +68,22 @@ .xmodule_display.xmodule_ProblemBlock .feedback-hint-incorrect, .xmodule_display.xmodule_ProblemBlock .feedback-hint-partially-correct, .xmodule_display.xmodule_ProblemBlock .feedback-hint-correct { - margin-top: calc((var(--baseline) / 4)); + margin-top: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_ProblemBlock .feedback-hint-incorrect .icon, .xmodule_display.xmodule_ProblemBlock .feedback-hint-partially-correct .icon, .xmodule_display.xmodule_ProblemBlock .feedback-hint-correct .icon { - margin-right: calc((var(--baseline) / 4)); + margin-right: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_ProblemBlock .feedback-hint-incorrect .icon { - color: var(--incorrect); + color: var(--incorrect, #b20610); } .xmodule_display.xmodule_ProblemBlock .feedback-hint-partially-correct .icon, .xmodule_display.xmodule_ProblemBlock .feedback-hint-correct .icon { - color: var(--correct); + color: var(--correct, #008100); } .xmodule_display.xmodule_ProblemBlock .feedback-hint-text { @@ -116,17 +116,17 @@ } .xmodule_display.xmodule_ProblemBlock .inline-error { - color: var(--error-color-dark); + color: var(--error-color-dark, #95050d); } .xmodule_display.xmodule_ProblemBlock div.problem-progress { display: inline-block; - color: var(--gray-d1); + color: var(--gray-d1, #5e5e5e); font-size: 0.875em; } .xmodule_display.xmodule_ProblemBlock div.problem { - padding-top: var(--baseline); + padding-top: var(--baseline, 20px); } @media print { @@ -151,30 +151,30 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .inline+p { - margin-top: var(--baseline); + margin-top: var(--baseline, 20px); } .xmodule_display.xmodule_ProblemBlock div.problem .question-description { - color: var(--gray-d1); - font-size: var(--small-font-size); + color: var(--gray-d1, #5e5e5e); + font-size: var(--small-font-size, 80%); } .xmodule_display.xmodule_ProblemBlock div.problem form>label, .xmodule_display.xmodule_ProblemBlock div.problem .problem-group-label { display: block; - margin-bottom: var(--baseline); + margin-bottom: var(--baseline, 20px); font: inherit; color: inherit; -webkit-font-smoothing: initial; } .xmodule_display.xmodule_ProblemBlock div.problem .problem-group-label+.question-description { - margin-top: calc(-1 * var(--baseline)); + margin-top: calc(-1 * var(--baseline, 20px)); } .xmodule_display.xmodule_ProblemBlock .wrapper-problem-response+.wrapper-problem-response, .xmodule_display.xmodule_ProblemBlock .wrapper-problem-response+p { - margin-top: calc((var(--baseline) * 1.5)); + margin-top: calc((var(--baseline, 20px) * 1.5)); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup, @@ -196,16 +196,16 @@ box-sizing: border-box; display: inline-block; clear: both; - margin-bottom: calc((var(--baseline) / 2)); - border: 2px solid var(--gray-l4); + margin-bottom: calc((var(--baseline, 20px) / 2)); + border: 2px solid var(--gray-l4, #e4e4e4); border-radius: 3px; - padding: calc((var(--baseline) / 2)); + padding: calc((var(--baseline, 20px) / 2)); width: 100%; } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup label::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label::after { - margin-left: calc((var(--baseline) * 0.75)); + margin-left: calc((var(--baseline, 20px) * 0.75)); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup .indicator-container, @@ -224,15 +224,15 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input[type="radio"], .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input[type="checkbox"], .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input[type="checkbox"] { - margin: calc((var(--baseline) / 4)); - margin-right: calc((var(--baseline) / 2)); + margin: calc((var(--baseline, 20px) / 4)); + margin-right: calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:focus+label, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:focus+label, .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input:hover+label, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label { - border: 2px solid var(--blue); + border: 2px solid var(--blue, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_correct, @@ -253,7 +253,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_correct { - border: 2px solid var(--correct); + border: 2px solid var(--correct, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_correct .status-icon::after, @@ -274,7 +274,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_correct .status-icon::after { - color: var(--correct); + color: var(--correct, #008100); font-size: 1.2em; content: ""; } @@ -297,7 +297,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_partially-correct, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_partially-correct { - border: 2px solid var(--partially-correct); + border: 2px solid var(--partially-correct, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_partially-correct .status-icon::after, @@ -318,7 +318,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_partially-correct .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_partially-correct .status-icon::after { - color: var(--partially-correct); + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } @@ -341,7 +341,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_incorrect, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_incorrect { - border: 2px solid var(--incorrect); + border: 2px solid var(--incorrect, #b20610); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup input+label.choicegroup_incorrect .status-icon::after, @@ -362,7 +362,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_incorrect .status-icon::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_incorrect .status-icon::after { - color: var(--incorrect); + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } @@ -385,7 +385,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+label.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup .choicegroup input:hover+section.choicetextgroup_submitted, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup input:hover+section.choicetextgroup_submitted { - border: 2px solid var(--submitted); + border: 2px solid var(--submitted, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup .field { @@ -393,10 +393,10 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup label { - padding: calc((var(--baseline) / 2)); - padding-left: calc((var(--baseline) * 2.3)); + padding: calc((var(--baseline, 20px) / 2)); + padding-left: calc((var(--baseline, 20px) * 2.3)); position: relative; - font-size: var(--base-font-size); + font-size: var(--base-font-size, 18px); line-height: normal; cursor: pointer; } @@ -406,45 +406,45 @@ left: 0.5625em; position: absolute; top: 0.35em; - width: calc(var(--baseline) * 1.1); - height: calc(var(--baseline) * 1.1); + width: calc(var(--baseline, 20px) * 1.1); + height: calc(var(--baseline, 20px) * 1.1); z-index: 1; } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup legend { - margin-bottom: var(--baseline); + margin-bottom: var(--baseline, 20px); max-width: 100%; white-space: normal; } .xmodule_display.xmodule_ProblemBlock div.problem .choicegroup legend+.question-description { - margin-top: calc(-1 * var(--baseline)); + margin-top: calc(-1 * var(--baseline, 20px)); max-width: 100%; white-space: normal; } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container { - margin-left: calc((var(--baseline) * 0.75)); + margin-left: calc((var(--baseline, 20px) * 0.75)); } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status { - width: var(--baseline); + width: var(--baseline, 20px); } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.correct .status-icon::after { - color: var(--correct); + color: var(--correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.partially-correct .status-icon::after { - color: var(--partially-correct); + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .indicator-container .status.incorrect .status-icon::after { - color: var(--incorrect); + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } @@ -463,7 +463,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .solution-span>span { - margin: var(--baseline) 0; + margin: var(--baseline, 20px) 0; display: block; position: relative; } @@ -490,17 +490,17 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div p span.clarification i:hover { - color: var(--blue); + color: var(--blue, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem div.correct input, .xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { - border-color: var(--correct); + border-color: var(--correct, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem div.partially-correct input, .xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { - border-color: var(--partially-correct); + border-color: var(--partially-correct, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem div.processing input { @@ -508,22 +508,22 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-close input { - border-color: var(--incorrect); + border-color: var(--incorrect, #b20610); } .xmodule_display.xmodule_ProblemBlock div.problem div.incorrect input, .xmodule_display.xmodule_ProblemBlock div.problem div.incomplete input { - border-color: var(--incorrect); + border-color: var(--incorrect, #b20610); } .xmodule_display.xmodule_ProblemBlock div.problem div.submitted input, .xmodule_display.xmodule_ProblemBlock div.problem div.ui-icon-check input { - border-color: var(--submitted); + border-color: var(--submitted, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem div p.answer { display: inline-block; - margin-top: calc((var(--baseline) / 2)); + margin-top: calc((var(--baseline, 20px) / 2)); margin-bottom: 0; } @@ -546,7 +546,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div div.equation img.loading { - padding-left: calc((var(--baseline) / 2)); + padding-left: calc((var(--baseline, 20px) / 2)); display: inline-block; } @@ -577,7 +577,7 @@ top: 4px; width: 14px; height: 14px; - background: url("var(--static-path)/images/unanswered-icon.png") center center no-repeat; + background: var(--icon-unanswered) center center no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem div span.processing, @@ -587,7 +587,7 @@ top: 6px; width: 25px; height: 20px; - background: url("var(--static-path)/images/spinner.gif") center center no-repeat; + background: var(--icon-spinner) center center no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem div span.ui-icon-check { @@ -596,7 +596,7 @@ top: 3px; width: 25px; height: 20px; - background: url("var(--static-path)/images/correct-icon.png") center center no-repeat; + background: var(--icon-correct) center center no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem div span.incomplete, @@ -606,19 +606,19 @@ top: 3px; width: 20px; height: 20px; - background: url("var(--static-path)/images/incorrect-icon.png") center center no-repeat; + background: var(--icon-incorrect) center center no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem div .reload { float: right; - margin: calc((var(--baseline) / 2)); + margin: calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status { - margin: calc(var(--baseline) / 2) 0; - padding: calc(var(--baseline) / 2); + margin: calc(var(--baseline, 20px) / 2) 0; + padding: calc(var(--baseline, 20px) / 2); border-radius: 5px; - background: var(--gray-l6); + background: var(--gray-l6, #f8f8f8); } .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status:after { @@ -638,7 +638,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status .grading { margin: 0px 7px 0 0; padding-left: 25px; - background: url("var(--static-path)/images/info-icon.png") left center no-repeat; + background: var(--icon-info) left center no-repeat; text-indent: 0px; } @@ -650,11 +650,11 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status.file { - margin-top: var(--baseline); - padding: var(--baseline) 0 0 0; + margin-top: var(--baseline, 20px); + padding: var(--baseline, 20px) 0 0 0; border: 0; border-top: 1px solid #eee; - background: var(--white); + background: var(--white, #fff); } .xmodule_display.xmodule_ProblemBlock div.problem div .grader-status.file p.debug { @@ -666,11 +666,11 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div .evaluation p { - margin-bottom: calc((var(--baseline) / 5)); + margin-bottom: calc((var(--baseline, 20px) / 5)); } .xmodule_display.xmodule_ProblemBlock div.problem div .feedback-on-feedback { - margin-right: var(--baseline); + margin-right: var(--baseline, 20px); height: 100px; } @@ -701,7 +701,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div .submit-message-container { - margin: var(--baseline) 0px; + margin: var(--baseline, 20px) 0px; } .xmodule_display.xmodule_ProblemBlock div.problem div.inline>span { @@ -803,17 +803,17 @@ padding: 0px 5px; border: 1px solid #eaeaea; border-radius: 3px; - background-color: var(--gray-l6); + background-color: var(--gray-l6, #f8f8f8); white-space: nowrap; font-size: .9em; } .xmodule_display.xmodule_ProblemBlock div.problem pre { overflow: auto; - padding: 6px calc(var(--baseline) / 2); - border: 1px solid var(--gray-l3); + padding: 6px calc(var(--baseline, 20px) / 2); + border: 1px solid var(--gray-l3, #c8c8c8); border-radius: 3px; - background-color: var(--gray-l6); + background-color: var(--gray-l6, #f8f8f8); font-size: .9em; line-height: 1.4; } @@ -829,7 +829,7 @@ .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput input { box-sizing: border-box; - border: 2px solid var(--gray-l4); + border: 2px solid var(--gray-l4, #e4e4e4); border-radius: 3px; min-width: 160px; height: 46px; @@ -838,49 +838,49 @@ .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline .status, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput .status { display: inline-block; - margin-top: calc((var(--baseline) / 2)); + margin-top: calc((var(--baseline, 20px) / 2)); background: none; } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.incorrect input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.incorrect input { - border: 2px solid var(--incorrect); + border: 2px solid var(--incorrect, #b20610); } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.incorrect .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.incorrect .status .status-icon::after { - color: var(--incorrect); + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.partially-correct input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.partially-correct input { - border: 2px solid var(--partially-correct); + border: 2px solid var(--partially-correct, #008100); } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.partially-correct .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.partially-correct .status .status-icon::after { - color: var(--partially-correct); + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.correct input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.correct input { - border: 2px solid var(--correct); + border: 2px solid var(--correct, #008100); } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.correct .status .status-icon::after, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.correct .status .status-icon::after { - color: var(--correct); + color: var(--correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.submitted input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.submitted input { - border: 2px solid var(--submitted); + border: 2px solid var(--submitted, #0075b4); } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.submitted .status, @@ -892,7 +892,7 @@ .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.unsubmitted input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.unanswered input, .xmodule_display.xmodule_ProblemBlock .problem .inputtype.formulaequationinput>.unsubmitted input { - border: 2px solid var(--gray-l4); + border: 2px solid var(--gray-l4, #e4e4e4); } .xmodule_display.xmodule_ProblemBlock .problem .capa_inputtype.textline>.unanswered .status .status-icon::after, @@ -909,7 +909,7 @@ } .xmodule_display.xmodule_ProblemBlock .problem .trailing_text { - margin-right: calc((var(--baseline) / 2)); + margin-right: calc((var(--baseline, 20px) / 2)); display: inline-block; } @@ -961,7 +961,7 @@ visibility: hidden; width: 0; border-right: none; - border-left: 1px solid var(--black); + border-left: 1px solid var(--black, #000); } .xmodule_display.xmodule_ProblemBlock div.problem .CodeMirror-focused pre.CodeMirror-cursor { @@ -980,12 +980,12 @@ .xmodule_display.xmodule_ProblemBlock .capa-message { display: inline-block; - color: var(--gray-d1); + color: var(--gray-d1, #5e5e5e); -webkit-font-smoothing: antialiased; } .xmodule_display.xmodule_ProblemBlock div.problem .action { - min-height: var(--baseline); + min-height: var(--baseline, 20px); width: 100%; display: flex; display: -ms-flexbox; @@ -999,11 +999,11 @@ display: inline-flex; justify-content: flex-end; width: 100%; - padding-bottom: var(--baseline); + padding-bottom: var(--baseline, 20px); } .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-button-wrapper { - border-right: 1px solid var(--gray-300); + border-right: 1px solid var(--gray-300, #d9d9d9); padding: 0 13px; display: inline-block; } @@ -1021,11 +1021,11 @@ .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:hover, .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:focus, .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn:active { - color: var(--primary) !important; + color: var(--primary, #0075b4) !important; } .xmodule_display.xmodule_ProblemBlock div.problem .action .problem-action-btn .icon { - margin-bottom: calc(var(--baseline) / 10); + margin-bottom: calc(var(--baseline, 20px) / 10); display: block; } @@ -1036,42 +1036,42 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container { - padding-bottom: var(--baseline); + padding-bottom: var(--baseline, 20px); flex-grow: 1; display: flex; align-items: center; } -@media (max-width: var(--bp-screen-lg)) { +@media (max-width: var(--bp-screen-lg, 1024px)) { .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container { max-width: 100%; - padding-bottom: var(--baseline); + padding-bottom: var(--baseline, 20px); } } .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container .submit { - margin-right: calc((var(--baseline) / 2)); + margin-right: calc((var(--baseline, 20px) / 2)); float: left; white-space: nowrap; } .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container .submit-cta-description { - color: var(--primary); + color: var(--primary, #0075b4); font-size: small; - padding-right: calc(var(--baseline) / 2); + padding-right: calc(var(--baseline, 20px) / 2); } .xmodule_display.xmodule_ProblemBlock div.problem .action .submit-attempt-container .submit-cta-link-button { - color: var(--primary); - padding-right: calc(var(--baseline) / 4); + color: var(--primary, #0075b4); + padding-right: calc(var(--baseline, 20px) / 4); } .xmodule_display.xmodule_ProblemBlock div.problem .action .submission-feedback { - margin-right: calc((var(--baseline) / 2)); - margin-top: calc(var(--baseline) / 2); + margin-right: calc((var(--baseline, 20px) / 2)); + margin-top: calc(var(--baseline, 20px) / 2); display: inline-block; - color: var(--gray-d1); - font-size: var(--medium-font-size); + color: var(--gray-d1, #5e5e5e); + font-size: var(--medium-font-size, 0.9em); -webkit-font-smoothing: antialiased; vertical-align: middle; } @@ -1105,7 +1105,7 @@ display: block; margin: lh() 0; padding: lh(); - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_ProblemBlock div.problem .message { @@ -1128,79 +1128,79 @@ } .xmodule_display.xmodule_ProblemBlock div.problem div.capa_alert { - margin-top: var(--baseline); + margin-top: var(--baseline, 20px); padding: 8px 12px; - border: 1px solid var(--warning-color); + border: 1px solid var(--warning-color, #ffc01f); border-radius: 3px; - background: var(--warning-color-accent); + background: var(--warning-color-accent, #fffcdd); font-size: 0.9em; } .xmodule_display.xmodule_ProblemBlock div.problem .notification { float: left; - margin-top: calc(var(--baseline) / 2); - padding: calc((var(--baseline) / 2.5)) calc((var(--baseline) / 2)) calc((var(--baseline) / 5)) calc((var(--baseline) / 2)); - line-height: var(--base-line-height); + margin-top: calc(var(--baseline, 20px) / 2); + padding: calc((var(--baseline, 20px) / 2.5)) calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 5)) calc((var(--baseline, 20px) / 2)); + line-height: var(--base-line-height, 1.5em); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.success { - border-top: 3px solid var(--success); + border-top: 3px solid var(--success, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.success .icon { margin-right: 15px; - color: var(--success); + color: var(--success, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.error { - border-top: 3px solid var(--danger); + border-top: 3px solid var(--danger, #b20610); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.error .icon { margin-right: 15px; - color: var(--danger); + color: var(--danger, #b20610); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.warning { - border-top: 3px solid var(--warning); + border-top: 3px solid var(--warning, #e2c01f); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.warning .icon { margin-right: 15px; - color: var(--warning); + color: var(--warning, #e2c01f); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.general { - border-top: 3px solid var(--general-color-accent); + border-top: 3px solid var(--general-color-accent, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.general .icon { margin-right: 15px; - color: var(--general-color-accent); + color: var(--general-color-accent, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.problem-hint { - border: 1px solid var(--uxpl-gray-background); + border: 1px solid var(--uxpl-gray-background, #d9d9d9); border-radius: 6px; } .xmodule_display.xmodule_ProblemBlock div.problem .notification.problem-hint .icon { - margin-right: calc(3 * var(--baseline) / 4); - color: var(--uxpl-gray-dark); + margin-right: calc(3 * var(--baseline, 20px) / 4); + color: var(--uxpl-gray-dark, #111111); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.problem-hint li { - color: var(--uxpl-gray-base); + color: var(--uxpl-gray-base, #414141); } .xmodule_display.xmodule_ProblemBlock div.problem .notification.problem-hint li strong { - color: var(--uxpl-gray-dark); + color: var(--uxpl-gray-dark, #111111); } .xmodule_display.xmodule_ProblemBlock div.problem .notification .icon { float: left; position: relative; - top: calc(var(--baseline) / 5); + top: calc(var(--baseline, 20px) / 5); } .xmodule_display.xmodule_ProblemBlock div.problem .notification .notification-message { @@ -1216,7 +1216,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .notification .notification-message ol li:not(:last-child) { - margin-bottom: calc(var(--baseline) / 4); + margin-bottom: calc(var(--baseline, 20px) / 4); } .xmodule_display.xmodule_ProblemBlock div.problem .notification .notification-btn-wrapper { @@ -1225,14 +1225,14 @@ .xmodule_display.xmodule_ProblemBlock div.problem .notification-btn { float: right; - padding: calc((var(--baseline) / 10)) calc((var(--baseline) / 4)); - min-width: calc((var(--baseline) * 3)); + padding: calc((var(--baseline, 20px) / 10)) calc((var(--baseline, 20px) / 4)); + min-width: calc((var(--baseline, 20px) * 3)); display: block; clear: both; } .xmodule_display.xmodule_ProblemBlock div.problem .notification-btn:first-child { - margin-bottom: calc(var(--baseline) / 4); + margin-bottom: calc(var(--baseline, 20px) / 4); } .xmodule_display.xmodule_ProblemBlock div.problem button:hover { @@ -1249,25 +1249,25 @@ } .xmodule_display.xmodule_ProblemBlock div.problem button.btn-brand:hover { - background-color: var(--btn-brand-focus-background); + background-color: var(--btn-brand-focus-background, #065683); } .xmodule_display.xmodule_ProblemBlock div.problem .review-btn { - color: var(--blue); + color: var(--blue, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem .review-btn.sr { - color: var(--blue); + color: var(--blue, #0075b4); } .xmodule_display.xmodule_ProblemBlock div.problem div.capa_reset { padding: 25px; - background-color: var(--error-color-light); - border: 1px solid var(--error-color); + background-color: var(--error-color-light, #f95861); + border: 1px solid var(--error-color, #cb0712); border-radius: 3px; font-size: 1em; - margin-top: calc(var(--baseline) / 2); - margin-bottom: calc(var(--baseline) / 2); + margin-top: calc(var(--baseline, 20px) / 2); + margin-bottom: calc(var(--baseline, 20px) / 2); } .xmodule_display.xmodule_ProblemBlock div.problem .capa_reset>h2 { @@ -1279,14 +1279,14 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .hints { - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_ProblemBlock div.problem .hints h3 { padding: 9px; border-bottom: 1px solid #e3e3e3; background: #eee; - text-shadow: 0 1px 0 var(--white); + text-shadow: 0 1px 0 var(--white, #fff); font-size: 1em; } @@ -1305,8 +1305,8 @@ .xmodule_display.xmodule_ProblemBlock div.problem .hints div header a { display: block; padding: 9px; - background: var(--gray-l6); - box-shadow: inset 0 0 0 1px var(--white); + background: var(--gray-l6, #f8f8f8); + box-shadow: inset 0 0 0 1px var(--white, #fff); } .xmodule_display.xmodule_ProblemBlock div.problem .hints div>section { @@ -1329,11 +1329,11 @@ .xmodule_display.xmodule_ProblemBlock div.problem .test>section { position: relative; - margin-bottom: calc((var(--baseline) / 2)); - padding: 9px 9px var(--baseline); + margin-bottom: calc((var(--baseline, 20px) / 2)); + padding: 9px 9px var(--baseline, 20px); border: 1px solid #ddd; border-radius: 3px; - background: var(--white); + background: var(--white, #fff); box-shadow: inset 0 0 0 1px #eee; } @@ -1353,8 +1353,8 @@ left: 0; box-sizing: border-box; display: block; - padding: calc((var(--baseline) / 5)); - background: var(--gray-l4); + padding: calc((var(--baseline, 20px) / 5)); + background: var(--gray-l4, #e4e4e4); text-align: right; font-size: 1em; } @@ -1376,8 +1376,8 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section { - padding-top: calc((var(--baseline) * 1.5)); - padding-left: var(--baseline); + padding-top: calc((var(--baseline, 20px) * 1.5)); + padding-left: var(--baseline, 20px); background-color: #fafafa; color: #2c2c2c; font-size: 1em; @@ -1394,9 +1394,9 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-errors { - margin: calc((var(--baseline) / 4)); - padding: calc((var(--baseline) / 2)) calc((var(--baseline) / 2)) calc((var(--baseline) / 2)) calc((var(--baseline) * 2)); - background: url("var(--static-path)/images/incorrect-icon.png") center left no-repeat; + margin: calc((var(--baseline, 20px) / 4)); + padding: calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) * 2)); + background: var(--icon-incorrect) center left no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-errors li { @@ -1404,10 +1404,10 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-output { - margin: calc(var(--baseline) / 4); - padding: var(--baseline) 0 calc((var(--baseline) * 0.75)) 50px; + margin: calc(var(--baseline, 20px) / 4); + padding: var(--baseline, 20px) 0 calc((var(--baseline, 20px) * 0.75)) 50px; border-top: 1px solid #ddd; - border-left: var(--baseline) solid #fafafa; + border-left: var(--baseline, 20px) solid #fafafa; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-output h4 { @@ -1420,7 +1420,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-output dt { - margin-top: var(--baseline); + margin-top: var(--baseline, 20px); } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-output dd { @@ -1428,7 +1428,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-correct { - background: url("var(--static-path)/images/correct-icon.png") left 20px no-repeat; + background: var(--icon-correct) left 20px no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-correct .result-actual-output { @@ -1436,7 +1436,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-partially-correct { - background: url("var(--static-path)/images/partially-correct-icon.png") left 20px no-repeat; + background: var(--icon-partially-correct) left 20px no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-partially-correct .result-actual-output { @@ -1444,7 +1444,7 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-incorrect { - background: url("var(--static-path)/images/incorrect-icon.png") left 20px no-repeat; + background: var(--icon-incorrect) left 20px no-repeat; } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .result-incorrect .result-actual-output { @@ -1452,8 +1452,8 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .external-grader-message section .longform .markup-text { - margin: calc((var(--baseline) / 4)); - padding: var(--baseline) 0 15px 50px; + margin: calc((var(--baseline, 20px) / 4)); + padding: var(--baseline, 20px) 0 15px 50px; border-top: 1px solid #ddd; border-left: 20px solid #fafafa; } @@ -1467,19 +1467,19 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .rubric tr { - margin: calc((var(--baseline) / 2)) 0; + margin: calc((var(--baseline, 20px) / 2)) 0; height: 100%; } .xmodule_display.xmodule_ProblemBlock div.problem .rubric td { - margin: calc((var(--baseline) / 2)) 0; - padding: var(--baseline) 0; + margin: calc((var(--baseline, 20px) / 2)) 0; + padding: var(--baseline, 20px) 0; height: 100%; } .xmodule_display.xmodule_ProblemBlock div.problem .rubric th { - margin: calc((var(--baseline) / 4)); - padding: calc((var(--baseline) / 4)); + margin: calc((var(--baseline, 20px) / 4)); + padding: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_ProblemBlock div.problem .rubric label, @@ -1487,12 +1487,12 @@ position: relative; display: inline-block; margin: 3px; - padding: calc((var(--baseline) * 0.75)); + padding: calc((var(--baseline, 20px) * 0.75)); min-width: 50px; min-height: 50px; width: 150px; height: 100%; - background-color: var(--gray-l3); + background-color: var(--gray-l3, #c8c8c8); font-size: .9em; } @@ -1500,7 +1500,7 @@ position: absolute; right: 0; bottom: 0; - margin: calc((var(--baseline) / 2)); + margin: calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_ProblemBlock div.problem .rubric .selected-grade { @@ -1519,14 +1519,14 @@ .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input { margin: 0 0 1em 0; - border: 1px solid var(--gray-l3); + border: 1px solid var(--gray-l3, #c8c8c8); border-radius: 1em; /* for debugging the input value field. enable the debug flag on the inputtype */ } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .annotation-header { padding: .5em 1em; - border-bottom: 1px solid var(--gray-l3); + border-bottom: 1px solid var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .annotation-body { @@ -1575,7 +1575,7 @@ .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input ul.tags li .tag { display: inline-block; - margin-left: calc((var(--baseline) * 2)); + margin-left: calc((var(--baseline, 20px) * 2)); border: 1px solid #666666; } @@ -1608,9 +1608,9 @@ .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .debug-value { margin: 1em 0; padding: 1em; - border: 1px solid var(--black); + border: 1px solid var(--black, #000); background-color: #999; - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .debug-value input[type="text"] { @@ -1618,8 +1618,8 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .debug-value pre { - background-color: var(--gray-l3); - color: var(--black); + background-color: var(--gray-l3, #c8c8c8); + color: var(--black, #000); } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .debug-value::before { @@ -1634,18 +1634,18 @@ .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_correct input[type="text"], .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_correct input[type="text"] { - border-color: var(--correct); + border-color: var(--correct, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_partially-correct input[type="text"], .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_partially-correct input[type="text"] { - border-color: var(--partially-correct); + border-color: var(--partially-correct, #008100); } .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup label.choicetextgroup_show_correct::after, .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup section.choicetextgroup_show_correct::after { - margin-left: calc((var(--baseline) * 0.75)); - content: url("var(--static-path)/images/correct-icon.png"); + margin-left: calc((var(--baseline, 20px) * 0.75)); + content: var(--icon-correct); } .xmodule_display.xmodule_ProblemBlock div.problem .choicetextgroup span.mock_label { @@ -1671,19 +1671,19 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .correct .status-icon::after { - color: var(--correct); + color: var(--correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .incorrect .status-icon::after { - color: var(--incorrect); + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .imageinput.capa_inputtype .partially-correct .status-icon::after { - color: var(--partially-correct); + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } @@ -1711,19 +1711,19 @@ } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .correct .status-icon::after { - color: var(--correct); + color: var(--correct, #008100); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .incorrect .status-icon::after { - color: var(--incorrect); + color: var(--incorrect, #b20610); font-size: 1.2em; content: ""; } .xmodule_display.xmodule_ProblemBlock div.problem .annotation-input .partially-correct .status-icon::after { - color: var(--partially-correct); + color: var(--partially-correct, #008100); font-size: 1.2em; content: ""; } @@ -1734,5 +1734,5 @@ .xmodule_display.xmodule_ProblemBlock .problems-wrapper .loading-spinner { text-align: center; - color: var(--gray-d1); + color: var(--gray-d1, #5e5e5e); } diff --git a/xmodule/static/css-builtin-blocks/ProblemBlockEditor.css b/xmodule/static/css-builtin-blocks/ProblemBlockEditor.css index 467717a4076..e766c8a3e7f 100644 --- a/xmodule/static/css-builtin-blocks/ProblemBlockEditor.css +++ b/xmodule/static/css-builtin-blocks/ProblemBlockEditor.css @@ -49,7 +49,7 @@ background-image: -webkit-linear-gradient(top, #d4dee8, #c9d5e2); background-image: linear-gradient(to bottom, #d4dee8, #c9d5e2); position: relative; - padding: calc(var(--baseline) / 4); + padding: calc(var(--baseline, 20px) / 4); border-bottom-color: #a5aaaf; } @@ -62,7 +62,7 @@ .xmodule_edit.xmodule_ProblemBlock .editor .editor-bar button { display: inline-block; float: left; - padding: 3px calc(var(--baseline) / 2) 5px; + padding: 3px calc(var(--baseline, 20px) / 2) 5px; margin-left: 7px; border: 0; border-radius: 2px; @@ -88,7 +88,7 @@ .xmodule_edit.xmodule_ProblemBlock .editor .editor-tabs li { float: left; - margin-right: calc(var(--baseline) / 4); + margin-right: calc(var(--baseline, 20px) / 4); } .xmodule_edit.xmodule_ProblemBlock .editor .editor-tabs li:last-child { @@ -101,9 +101,9 @@ padding: 7px 20px 3px; border: 1px solid #a5aaaf; border-radius: 3px 3px 0 0; - background-color: var(--transparent); - background-image: -webkit-linear-gradient(top, var(--transparent) 87%, rgba(0, 0, 0, 0.06)); - background-image: linear-gradient(to bottom, var(--transparent) 87%, rgba(0, 0, 0, 0.06)); + background-color: var(--transparent, transparent); + background-image: -webkit-linear-gradient(top, var(--transparent, transparent) 87%, rgba(0, 0, 0, 0.06)); + background-image: linear-gradient(to bottom, var(--transparent, transparent) 87%, rgba(0, 0, 0, 0.06)); background-color: #e5ecf3; font-size: 13px; color: #3c3c3c; @@ -111,8 +111,8 @@ } .xmodule_edit.xmodule_ProblemBlock .editor .editor-tabs .tab.current { - background: var(--white); - border-bottom-color: var(--white); + background: var(--white, #fff); + border-bottom-color: var(--white, #fff); } .xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle { @@ -120,14 +120,14 @@ margin-top: -4px; padding: 3px 9px; font-size: 12px; - color: var(--link-color); + color: var(--link-color, #1b6d99); } .xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle.current { - border: 1px solid var(--lightGrey) !important; + border: 1px solid var(--lightGrey, #edf1f5) !important; border-radius: 3px !important; - background: var(--lightGrey) !important; - color: var(--darkGrey) !important; + background: var(--lightGrey, #edf1f5) !important; + color: var(--darkGrey, #8891a1) !important; pointer-events: none; cursor: none; } @@ -135,7 +135,7 @@ .xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle.current:hover, .xmodule_edit.xmodule_ProblemBlock .editor-bar .editor-tabs .advanced-toggle.current:focus { box-shadow: 0 0 0 0 !important; - background-color: var(--white); + background-color: var(--white, #fff); } .xmodule_edit.xmodule_ProblemBlock .simple-editor-cheatsheet { @@ -143,8 +143,8 @@ top: 41px; left: 70%; width: 0; - border-left: 1px solid var(--gray-l2); - background-color: var(--lightGrey); + border-left: 1px solid var(--gray-l2, #adadad); + background-color: var(--lightGrey, #edf1f5); overflow: hidden; } @@ -194,7 +194,7 @@ } .xmodule_edit.xmodule_ProblemBlock .simple-editor-cheatsheet .col.sample .icon { - height: calc(var(--baseline) * 1.5); + height: calc(var(--baseline, 20px) * 1.5); } .xmodule_edit.xmodule_ProblemBlock .simple-editor-cheatsheet pre { @@ -217,5 +217,5 @@ width: 26px; height: 21px; vertical-align: middle; - color: var(--body-color); + color: var(--body-color, #313131); } diff --git a/xmodule/static/css-builtin-blocks/SequenceBlockDisplay.css b/xmodule/static/css-builtin-blocks/SequenceBlockDisplay.css index 24d9b8ae6c3..53671879f6e 100644 --- a/xmodule/static/css-builtin-blocks/SequenceBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/SequenceBlockDisplay.css @@ -1,7 +1,7 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); .xmodule_display.xmodule_SequenceBlock .block-link { - border-left: 1px solid var(--border-color); + border-left: 1px solid var(--border-color, #e7e7e7); display: block; } @@ -12,7 +12,7 @@ .xmodule_display.xmodule_SequenceBlock .topbar, .xmodule_display.xmodule_SequenceBlock .sequence-nav { - border-bottom: 1px solid var(--border-color); + border-bottom: 1px solid var(--border-color, #e7e7e7); } .xmodule_display.xmodule_SequenceBlock .topbar:after, @@ -32,7 +32,7 @@ .xmodule_display.xmodule_SequenceBlock .topbar a.block-link, .xmodule_display.xmodule_SequenceBlock .sequence-nav a.block-link { - border-left: 1px solid var(--border-color); + border-left: 1px solid var(--border-color, #e7e7e7); display: block; } @@ -59,7 +59,7 @@ } .xmodule_display.xmodule_SequenceBlock .sequence-nav { - margin: 0 auto var(--baseline); + margin: 0 auto var(--baseline, 20px); position: relative; border-bottom: none; z-index: 0; @@ -95,7 +95,7 @@ box-sizing: border-box; min-width: 40px; flex-grow: 1; - border-color: var(--border-color); + border-color: var(--border-color, #e7e7e7); border-width: 1px; border-top-style: solid; } @@ -112,7 +112,7 @@ padding: 0; display: block; text-align: center; - border-color: var(--border-color); + border-color: var(--border-color, #e7e7e7); border-width: 1px; border-bottom-style: solid; box-sizing: border-box; @@ -127,7 +127,7 @@ } .xmodule_display.xmodule_SequenceBlock .sequence-nav ol li button .fa-bookmark { - color: var(--link-color); + color: var(--link-color, #1b6d99); } .xmodule_display.xmodule_SequenceBlock .sequence-nav ol li button.seq_video .icon::before { @@ -150,14 +150,14 @@ text-align: left; margin-top: 12px; background: #333333; - color: var(--white); + color: var(--white, #fff); font-family: var(--font-family-sans-serif); line-height: lh(); right: 0; padding: 6px; position: absolute; top: 48px; - text-shadow: 0 -1px 0 var(--black); + text-shadow: 0 -1px 0 var(--black, #000); white-space: pre; pointer-events: none; } @@ -196,7 +196,7 @@ body.touch-based-device .xmodule_display.xmodule_SequenceBlock .sequence-nav ol text-shadow: none; background: none; background-color: #fff; - border-color: var(--border-color); + border-color: var(--border-color, #e7e7e7); box-shadow: none; font-size: inherit; font-weight: normal; @@ -213,7 +213,7 @@ body.touch-based-device .xmodule_display.xmodule_SequenceBlock .sequence-nav ol } .xmodule_display.xmodule_SequenceBlock .sequence-nav-button span:not(:last-child) { - padding-right: calc((var(--baseline) / 2)); + padding-right: calc((var(--baseline, 20px) / 2)); } } @@ -342,7 +342,7 @@ body.touch-based-device .xmodule_display.xmodule_SequenceBlock .sequence-nav ol .xmodule_display.xmodule_SequenceBlock .sequence-nav button:hover, .xmodule_display.xmodule_SequenceBlock .sequence-nav button:active, .xmodule_display.xmodule_SequenceBlock .sequence-nav button.active { - border-bottom: 3px solid var(--link-color); + border-bottom: 3px solid var(--link-color, #1b6d99); background-color: #fff; } diff --git a/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css b/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css index 29e9fce6ef5..844ebb3018b 100644 --- a/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/VideoBlockDisplay.css @@ -1,7 +1,7 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); .xmodule_display.xmodule_VideoBlock { - margin-bottom: calc((var(--baseline) * 1.5)); + margin-bottom: calc((var(--baseline, 20px) * 1.5)); } .xmodule_display.xmodule_VideoBlock .is-hidden, @@ -81,8 +81,8 @@ .xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .branding, .xmodule_display.xmodule_VideoBlock .video .wrapper-video-bottom-section .wrapper-transcript-feedback { flex: 1; - margin-top: var(--baseline); - padding-right: var(--baseline); + margin-top: var(--baseline, 20px); + padding-right: var(--baseline, 20px); vertical-align: top; } @@ -125,14 +125,14 @@ left: -9999em; display: inline-block; vertical-align: middle; - color: var(--body-color); + color: var(--body-color, #313131); } .xmodule_display.xmodule_VideoBlock .video .wrapper-downloads .branding .brand-logo { display: inline-block; max-width: 100%; - max-height: calc((var(--baseline) * 2)); - padding: calc((var(--baseline) / 4)) 0; + max-height: calc((var(--baseline, 20px) * 2)); + padding: calc((var(--baseline, 20px) / 4)) 0; vertical-align: middle; } @@ -157,8 +157,8 @@ .xmodule_display.xmodule_VideoBlock .video .google-disclaimer { display: none; - margin-top: var(--baseline); - padding-right: var(--baseline); + margin-top: var(--baseline, 20px); + padding-right: var(--baseline, 20px); vertical-align: top; } @@ -219,7 +219,7 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .btn-play::after { - background: var(--white); + background: var(--white, #fff); position: absolute; width: 50%; height: 50%; @@ -242,22 +242,22 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible { - max-height: calc((var(--baseline) * 3)); - border-radius: calc((var(--baseline) / 5)); - padding: 8px calc((var(--baseline) / 2)) 8px calc((var(--baseline) * 1.5)); + max-height: calc((var(--baseline, 20px) * 3)); + border-radius: calc((var(--baseline, 20px) / 5)); + padding: 8px calc((var(--baseline, 20px) / 2)) 8px calc((var(--baseline, 20px) * 1.5)); background: rgba(0, 0, 0, 0.75); - color: var(--yellow); + color: var(--yellow, #e2c01f); } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .closed-captions.is-visible::before { position: absolute; display: inline-block; top: 50%; - left: var(--baseline); + left: var(--baseline, 20px); margin-top: -0.6em; font-family: 'FontAwesome'; content: "\f142"; - color: var(--white); + color: var(--white, #fff); opacity: 0.5; } @@ -287,7 +287,7 @@ .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player .video-error, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-player .video-hls-error { - padding: calc((var(--baseline) / 5)); + padding: calc((var(--baseline, 20px) / 5)); background: black; color: white !important; } @@ -336,7 +336,7 @@ margin: 0; border: 0; border-radius: 0; - padding: calc((var(--baseline) / 2)) calc((var(--baseline) / 1.5)); + padding: calc((var(--baseline, 20px) / 2)) calc((var(--baseline, 20px) / 1.5)); background: #282c2e; box-shadow: none; text-shadow: none; @@ -371,7 +371,7 @@ left: 0; right: 0; z-index: 1; - height: calc((var(--baseline) / 4)); + height: calc((var(--baseline, 20px) / 4)); margin-left: 0; border: 1px solid #4f595d; border-radius: 0; @@ -402,11 +402,11 @@ transition: all 0.7s ease-in-out 0s; box-sizing: border-box; top: -1px; - height: calc((var(--baseline) / 4)); - width: calc((var(--baseline) / 4)); - margin-left: calc(-1 * (var(--baseline) / 8)); + height: calc((var(--baseline, 20px) / 4)); + width: calc((var(--baseline, 20px) / 4)); + margin-left: calc(-1 * (var(--baseline, 20px) / 8)); border: 1px solid #cb598d; - border-radius: calc((var(--baseline) / 5)); + border-radius: calc((var(--baseline, 20px) / 5)); padding: 0; background: #cb598d; box-shadow: none; @@ -483,7 +483,7 @@ transition: none; position: absolute; display: none; - bottom: calc((var(--baseline) * 2)); + bottom: calc((var(--baseline, 20px) * 2)); right: 0; width: 120px; margin: 0; @@ -525,8 +525,8 @@ .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li.is-active .speed-option, .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .menu-container .menu li.is-active .control-lang { - border-left: calc(var(--baseline) / 10) solid #0ea6ec; - font-weight: var(--font-bold); + border-left: calc(var(--baseline, 20px) / 10) solid #0ea6ec; + font-weight: var(--font-bold, 700); color: #0ea6ec; } @@ -545,7 +545,7 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .speeds .speed-button .label { - padding: 0 calc((var(--baseline) / 3)) 0 0; + padding: 0 calc((var(--baseline, 20px) / 3)) 0 0; font-family: var(--font-family-sans-serif); color: #e7ecee; } @@ -570,8 +570,8 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang .language-menu { - width: var(--baseline); - padding: calc((var(--baseline) / 2)) 0; + width: var(--baseline, 20px); + padding: calc((var(--baseline, 20px) / 2)) 0; } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .lang.is-opened .control .icon { @@ -596,7 +596,7 @@ transition: none; display: none; position: absolute; - bottom: calc((var(--baseline) * 2)); + bottom: calc((var(--baseline, 20px) * 2)); right: 0; width: 41px; height: 120px; @@ -605,7 +605,7 @@ .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider { height: 100px; - width: calc((var(--baseline) / 4)); + width: calc((var(--baseline, 20px) / 4)); margin: 14px auto; box-sizing: border-box; border: 1px solid #4f595d; @@ -613,13 +613,13 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .secondary-controls .volume .volume-slider-container .volume-slider .ui-slider-handle { - transition: height var(--tmg-s2) ease-in-out 0s, width var(--tmg-s2) ease-in-out 0s; + transition: height var(--tmg-s2, 2s) ease-in-out 0s, width var(--tmg-s2, 2s) ease-in-out 0s; left: -5px; box-sizing: border-box; height: 13px; width: 13px; border: 1px solid #cb598d; - border-radius: calc((var(--baseline) / 5)); + border-radius: calc((var(--baseline, 20px) / 5)); padding: 0; background: #cb598d; box-shadow: none; @@ -661,12 +661,12 @@ } .xmodule_display.xmodule_VideoBlock .video .video-wrapper:hover .video-controls .slider { - height: calc((var(--baseline) / 1.5)); + height: calc((var(--baseline, 20px) / 1.5)); } .xmodule_display.xmodule_VideoBlock .video .video-wrapper:hover .video-controls .slider .ui-slider-handle { - height: calc((var(--baseline) / 1.5)); - width: calc((var(--baseline) / 1.5)); + height: calc((var(--baseline, 20px) / 1.5)); + width: calc((var(--baseline, 20px) / 1.5)); } .xmodule_display.xmodule_VideoBlock .video.video-fullscreen .closed-captions { @@ -768,7 +768,7 @@ bottom: 0; top: 0; width: 275px; - padding: 0 var(--baseline); + padding: 0 var(--baseline, 20px); display: none; } @@ -844,7 +844,7 @@ padding: lh(); box-sizing: border-box; transition: none; - background: var(--black); + background: var(--black, #000); visibility: visible; } @@ -853,7 +853,7 @@ } .xmodule_display.xmodule_VideoBlock .video.video-fullscreen .subtitles li.current { - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .video.is-touch .tc-wrapper .video-wrapper object, @@ -872,7 +872,7 @@ background-position: 50% 50%; background-repeat: no-repeat; background-size: 100%; - background-color: var(--black); + background-color: var(--black, #000); } .xmodule_display.xmodule_VideoBlock .video .video-pre-roll.is-html5 { @@ -880,10 +880,10 @@ } .xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll { - padding: var(--baseline); + padding: var(--baseline, 20px); border: none; - border-radius: var(--baseline); - background: var(--black-t2); + border-radius: var(--baseline, 20px); + background: var(--black-t2, rgba(0, 0, 0, 0.5)); box-shadow: none; } @@ -892,13 +892,13 @@ } .xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll img { - height: calc((var(--baseline) * 4)); - width: calc((var(--baseline) * 4)); + height: calc((var(--baseline, 20px) * 4)); + width: calc((var(--baseline, 20px) * 4)); } .xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll:hover, .xmodule_display.xmodule_VideoBlock .video .video-pre-roll .btn-play.btn-pre-roll:focus { - background: var(--blue); + background: var(--blue, #0075b4); } .xmodule_display.xmodule_VideoBlock .video .video-wrapper .video-controls .slider .ui-slider-handle, @@ -956,7 +956,7 @@ display: none; position: absolute; list-style: none; - background-color: var(--white); + background-color: var(--white, #fff); border: 1px solid #eee; } @@ -964,7 +964,7 @@ margin: 0; padding: 0; border-bottom: 1px solid #eee; - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a { @@ -972,14 +972,14 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - color: var(--gray-l2); + color: var(--gray-l2, #adadad); font-size: 14px; line-height: 23px; } .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a:hover, .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li a:focus { - color: var(--gray-d1); + color: var(--gray-d1, #5e5e5e); } .xmodule_display.xmodule_VideoBlock .a11y-menu-container .a11y-menu-list li.active a { @@ -999,22 +999,22 @@ } .xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container.open>a { - background-color: var(--action-primary-active-bg); - color: var(--very-light-text); + background-color: var(--action-primary-active-bg, #0075b4); + color: var(--very-light-text, white); } .xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container.open>a::after { - color: var(--very-light-text); + color: var(--very-light-text, white); } .xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container>a { - transition: all var(--tmg-f2) ease-in-out 0s; + transition: all var(--tmg-f2, 0.25s) ease-in-out 0s; font-size: 12px; display: block; border-radius: 0 3px 3px 0; - background-color: var(--very-light-text); - padding: calc((var(--baseline) * 0.75)) calc((var(--baseline) * 1.25)) calc((var(--baseline) * 0.75)) calc((var(--baseline) * 0.75)); - color: var(--gray-l2); + background-color: var(--very-light-text, white); + padding: calc((var(--baseline, 20px) * 0.75)) calc((var(--baseline, 20px) * 1.25)) calc((var(--baseline, 20px) * 0.75)) calc((var(--baseline, 20px) * 0.75)); + color: var(--gray-l2, #adadad); min-width: 1.5em; line-height: 14px; text-align: center; @@ -1025,9 +1025,9 @@ .xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container>a::after { content: "\f0d7"; position: absolute; - right: calc((var(--baseline) * 0.5)); + right: calc((var(--baseline, 20px) * 0.5)); top: 33%; - color: var(--lighter-base-font-color); + color: var(--lighter-base-font-color, #646464); } .xmodule_display.xmodule_VideoBlock .video-tracks .a11y-menu-container .a11y-menu-list { @@ -1050,7 +1050,7 @@ .xmodule_display.xmodule_VideoBlock .contextmenu, .xmodule_display.xmodule_VideoBlock .submenu { border: 1px solid #333; - background: var(--white); + background: var(--white, #fff); color: #333; padding: 0; margin: 0; @@ -1072,8 +1072,8 @@ .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item, .xmodule_display.xmodule_VideoBlock .submenu .menu-item, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item { - border-top: 1px solid var(--gray-l3); - padding: calc((var(--baseline) / 4)) calc((var(--baseline) / 2)); + border-top: 1px solid var(--gray-l3, #c8c8c8); + padding: calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); outline: none; } @@ -1096,20 +1096,20 @@ .xmodule_display.xmodule_VideoBlock .submenu .menu-item:focus, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item:focus { background: #333; - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .contextmenu .menu-item:focus>span, .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item:focus>span, .xmodule_display.xmodule_VideoBlock .submenu .menu-item:focus>span, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item:focus>span { - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item { position: relative; - padding: calc((var(--baseline) / 4)) var(--baseline) calc((var(--baseline) / 4)) calc((var(--baseline) / 2)); + padding: calc((var(--baseline, 20px) / 4)) var(--baseline, 20px) calc((var(--baseline, 20px) / 4)) calc((var(--baseline, 20px) / 2)); } .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item::after, @@ -1129,12 +1129,12 @@ .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened { background: #333; - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened>span, .xmodule_display.xmodule_VideoBlock .submenu .submenu-item.is-opened>span { - color: var(--white); + color: var(--white, #fff); } .xmodule_display.xmodule_VideoBlock .contextmenu .submenu-item.is-opened>.submenu, @@ -1150,7 +1150,7 @@ .xmodule_display.xmodule_VideoBlock .contextmenu .is-disabled, .xmodule_display.xmodule_VideoBlock .submenu .is-disabled { pointer-events: none; - color: var(--gray-l3); + color: var(--gray-l3, #c8c8c8); } .xmodule_display.xmodule_VideoBlock .overlay { diff --git a/xmodule/static/css-builtin-blocks/VideoBlockEditor.css b/xmodule/static/css-builtin-blocks/VideoBlockEditor.css index c509a017ba6..f995a9e85d6 100644 --- a/xmodule/static/css-builtin-blocks/VideoBlockEditor.css +++ b/xmodule/static/css-builtin-blocks/VideoBlockEditor.css @@ -61,12 +61,12 @@ .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header { box-sizing: border-box; - padding: 18px var(--baseline); + padding: 18px var(--baseline, 20px); top: 0 !important; right: 0; - background-color: var(--blue); - border-bottom: 1px solid var(--blue-d2); - color: var(--white); + background-color: var(--blue, #0075b4); + border-bottom: 1px solid var(--blue-d2, #00466c); + color: var(--white, #fff); } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .component-name { @@ -74,23 +74,23 @@ top: 0; left: 0; width: 50%; - color: var(--white); + color: var(--white, #fff); font-weight: 600; } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .component-name em { display: inline-block; - margin-right: calc((var(--baseline) / 4)); + margin-right: calc((var(--baseline, 20px) / 4)); font-weight: 400; - color: var(--white); + color: var(--white, #fff); } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs { list-style: none; right: 0; - top: calc((var(--baseline) / 4)); + top: calc((var(--baseline, 20px) / 4)); position: absolute; - padding: 12px calc((var(--baseline) * 0.75)); + padding: 12px calc((var(--baseline, 20px) * 0.75)); } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap { @@ -104,27 +104,27 @@ background-color: rgba(255, 255, 255, 0.3); background-image: -webkit-linear-gradient(top, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); background-image: linear-gradient(to bottom, rgba(255, 255, 255, 0.3), rgba(255, 255, 255, 0)); - border: 1px solid var(--blue-d1); + border: 1px solid var(--blue-d1, #005e90); border-radius: 3px; - padding: calc((var(--baseline) / 4)) var(--baseline); - background-color: var(--blue); + padding: calc((var(--baseline, 20px) / 4)) var(--baseline, 20px); + background-color: var(--blue, #0075b4); font-weight: bold; - color: var(--white); + color: var(--white, #fff); } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap a.tab.current { - background-color: var(--blue); - background-image: -webkit-linear-gradient(var(--blue), var(--blue)); - background-image: linear-gradient(to, var(--blue)); - color: var(--blue-d1); - box-shadow: inset 0 1px 2px 1px var(--shadow-l1); - background-color: var(--blue-d4); + background-color: var(--blue, #0075b4); + background-image: -webkit-linear-gradient(var(--blue, #0075b4), var(--blue, #0075b4)); + background-image: linear-gradient(to, var(--blue, #0075b4)); + color: var(--blue-d1, #005e90); + box-shadow: inset 0 1px 2px 1px var(--shadow-l1, rgba(0, 0, 0, 0.1)); + background-color: var(--blue-d4, #001724); cursor: default; } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap a.tab:hover, .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .edit-header .editor-tabs .inner_tab_wrap a.tab:focus { - box-shadow: inset 0 1px 2px 1px var(--shadow); + box-shadow: inset 0 1px 2px 1px var(--shadow, rgba(0, 0, 0, 0.2)); background-image: linear-gradient(#009fe6, #009fe6) !important; } @@ -142,7 +142,7 @@ .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .comp-subtitles-entry .comp-subtitles-import-list>li { display: block; - margin: calc(var(--baseline) / 2) 0; + margin: calc(var(--baseline, 20px) / 2) 0; } .xmodule_edit.xmodule_VideoBlock .editor-with-tabs .comp-subtitles-entry .comp-subtitles-import-list .blue-button { @@ -154,7 +154,7 @@ } .xmodule_edit.xmodule_VideoBlock .component-tab { - background: var(--white); + background: var(--white, #fff); position: relative; border-top: 1px solid #8891a1; } diff --git a/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css b/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css index 8be5f77ede0..85ca354eb99 100644 --- a/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css +++ b/xmodule/static/css-builtin-blocks/WordCloudBlockDisplay.css @@ -1,7 +1,7 @@ @import url("https://fonts.googleapis.com/css?family=Open+Sans:300,400,400i,600,700"); .xmodule_display.xmodule_WordCloudBlock .input-cloud { - margin: calc((var(--baseline) / 4)); + margin: calc((var(--baseline, 20px) / 4)); } .xmodule_display.xmodule_WordCloudBlock .result_cloud_section { diff --git a/xmodule/x_module.py b/xmodule/x_module.py index 00fcc920304..d1d23342b7a 100644 --- a/xmodule/x_module.py +++ b/xmodule/x_module.py @@ -1,5 +1,6 @@ # lint-amnesty, pylint: disable=missing-module-docstring +import importlib.resources as resources import logging import os import time @@ -13,8 +14,6 @@ from lxml import etree from opaque_keys.edx.asides import AsideDefinitionKeyV2, AsideUsageKeyV2 from opaque_keys.edx.keys import UsageKey -from importlib.resources import files, as_file -from pathlib import Path as P from web_fragments.fragment import Fragment from webob import Response from webob.multidict import MultiDict @@ -856,35 +855,35 @@ def templates(cls): @classmethod def get_template_dir(cls): # lint-amnesty, pylint: disable=missing-function-docstring if getattr(cls, 'template_dir_name', None): - dirname = os.path.join('templates', cls.template_dir_name) - if not os.path.isdir(os.path.join(os.path.dirname(__file__), dirname)): + dirname = os.path.join('templates', cls.template_dir_name) # lint-amnesty, pylint: disable=no-member + template_path = resources.files(__name__.rsplit('.', 1)[0]) / dirname + + if not template_path.is_dir(): log.warning("No resource directory {dir} found when loading {cls_name} templates".format( dir=dirname, cls_name=cls.__name__, )) - return None + return return dirname - return None + return @classmethod def get_template_dirpaths(cls): """ - Returns a list of directories containing resource templates. + Returns of list of directories containing resource templates. """ template_dirpaths = [] template_dirname = cls.get_template_dir() - package, module_path = __name__.split('.', 1) - module_dir = str(P(module_path).parent) - module_dir = "" if module_dir == "." else module_dir - file_dirs = files(package).joinpath(module_dir, template_dirname or "") - if template_dirname and file_dirs.is_dir(): - with as_file(file_dirs) as path: - template_dirpaths.append(path) + if template_dirname: + template_path = resources.files(__name__.rsplit('.', 1)[0]) / template_dirname + if template_path.is_dir(): + with resources.as_file(template_path) as template_real_path: + template_dirpaths.append(str(template_real_path)) custom_template_dir = cls.get_custom_template_dir() if custom_template_dir: template_dirpaths.append(custom_template_dir) - return [str(td) for td in template_dirpaths] + return template_dirpaths @classmethod def get_custom_template_dir(cls):