From 9b37e7d0fed6c3ef00ad2e6c738ae3d5e01588e8 Mon Sep 17 00:00:00 2001 From: Kyle McCormick Date: Mon, 8 Mar 2021 15:24:16 -0500 Subject: [PATCH] refactor: centralize checks for canonical courseware experience & URL (#26815) Centralize the logic for choosing between MFE and Legacy-frontend courseware within three new functions: * courseware_mfe_is_active * courseware_mfe_is_visible * courseware_legacy_is_visible This allows us to create another new function: * get_courseware_url which can be called anywhere in LMS/Studio to get the canonical URL to courseware content (whether it be MFE or Legacy). In future commits we we begin using get_courseware_url throughout the platform. TNL-7796 --- .../djangoapps/student/tests/test_models.py | 3 +- lms/djangoapps/courseware/tests/test_views.py | 16 +- lms/djangoapps/courseware/testutils.py | 2 +- lms/djangoapps/courseware/toggles.py | 66 ++++- lms/djangoapps/courseware/views/index.py | 53 ++-- lms/djangoapps/courseware/views/views.py | 2 +- .../core/djangoapps/courseware_api/views.py | 27 +- .../tests/test_url_helpers.py | 257 ++++++++++++++++++ .../features/course_experience/url_helpers.py | 117 +++++++- 9 files changed, 464 insertions(+), 79 deletions(-) create mode 100644 openedx/features/course_experience/tests/test_url_helpers.py diff --git a/common/djangoapps/student/tests/test_models.py b/common/djangoapps/student/tests/test_models.py index fcc93e412d..cb4e444b1d 100644 --- a/common/djangoapps/student/tests/test_models.py +++ b/common/djangoapps/student/tests/test_models.py @@ -40,6 +40,7 @@ from common.djangoapps.student.models import ( PendingNameChange, ) from common.djangoapps.student.tests.factories import AccountRecoveryFactory, CourseEnrollmentFactory, UserFactory +from xmodule.modulestore import ModuleStoreEnum from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -258,7 +259,7 @@ class UserCelebrationTests(SharedModuleStoreTestCase): @classmethod def setUpClass(cls): super().setUpClass() - cls.course = CourseFactory() + cls.course = CourseFactory(default_store=ModuleStoreEnum.Type.split) cls.course_key = cls.course.id # pylint: disable=no-member def setUp(self): diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index a166eee551..461f6b286c 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -258,7 +258,7 @@ class TestJumpTo(ModuleStoreTestCase): ) expected_url += "?{}".format(urlencode({'activate_block_id': six.text_type(staff_only_vertical.location)})) - assert expected_url == get_legacy_courseware_url(course_key, usage_key, request) + assert expected_url == get_legacy_courseware_url(usage_key, request) @ddt.ddt @@ -548,9 +548,9 @@ class ViewsTestCase(BaseViewsTestCase): def test_get_redirect_url(self): # test the course location - assert u'/courses/{course_key}/courseware?{activate_block_id}'.format(course_key=text_type(self.course_key), activate_block_id=urlencode({'activate_block_id': text_type(self.course.location)})) == get_legacy_courseware_url(self.course_key, self.course.location) # pylint: disable=line-too-long + assert u'/courses/{course_key}/courseware?{activate_block_id}'.format(course_key=text_type(self.course_key), activate_block_id=urlencode({'activate_block_id': text_type(self.course.location)})) == get_legacy_courseware_url(self.course.location) # pylint: disable=line-too-long # test a section location - assert u'/courses/{course_key}/courseware/Chapter_1/Sequential_1/?{activate_block_id}'.format(course_key=text_type(self.course_key), activate_block_id=urlencode({'activate_block_id': text_type(self.section.location)})) == get_legacy_courseware_url(self.course_key, self.section.location) # pylint: disable=line-too-long + assert u'/courses/{course_key}/courseware/Chapter_1/Sequential_1/?{activate_block_id}'.format(course_key=text_type(self.course_key), activate_block_id=urlencode({'activate_block_id': text_type(self.section.location)})) == get_legacy_courseware_url(self.section.location) # pylint: disable=line-too-long def test_invalid_course_id(self): response = self.client.get('/courses/MITx/3.091X/') @@ -3282,8 +3282,9 @@ class TestShowCoursewareMFE(TestCase): assert show_courseware_mfe_link(global_staff_user, False, new_course_key) assert show_courseware_mfe_link(regular_user, True, new_course_key) - # Regular users don't see the link. - assert not show_courseware_mfe_link(regular_user, False, new_course_key) + # (Regular users would see the link, but they can't see the Legacy + # experience, so it doesn't matter.) + with override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=False): # (preview=on, redirect=off) # Global and Course Staff can see the link. @@ -3305,8 +3306,9 @@ class TestShowCoursewareMFE(TestCase): # if preview=off. assert show_courseware_mfe_link(regular_user, True, new_course_key) - # Regular users don't see the link. - assert not show_courseware_mfe_link(regular_user, False, new_course_key) + # (Regular users would see the link, but they can't see the Legacy + # experience, so it doesn't matter.) + with override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=False): # (preview=off, redirect=off) # Global staff see the link anyway diff --git a/lms/djangoapps/courseware/testutils.py b/lms/djangoapps/courseware/testutils.py index ef41ce7e67..b45e874c8e 100644 --- a/lms/djangoapps/courseware/testutils.py +++ b/lms/djangoapps/courseware/testutils.py @@ -175,7 +175,7 @@ class RenderXBlockTestMixin(six.with_metaclass(ABCMeta, object)): self.setup_user(admin=True, enroll=True, login=True) with check_mongo_calls(mongo_calls): - url = get_legacy_courseware_url(self.course.id, self.block_to_be_tested.location) + url = get_legacy_courseware_url(self.block_to_be_tested.location) response = self.client.get(url) expected_elements = self.block_specific_chrome_html_elements + self.COURSEWARE_CHROME_HTML_ELEMENTS for chrome_element in expected_elements: diff --git a/lms/djangoapps/courseware/toggles.py b/lms/djangoapps/courseware/toggles.py index 9702d948ba..13aa3dd923 100644 --- a/lms/djangoapps/courseware/toggles.py +++ b/lms/djangoapps/courseware/toggles.py @@ -3,8 +3,11 @@ Toggles for courseware in-course experience. """ from edx_toggles.toggles import LegacyWaffleFlagNamespace +from opaque_keys.edx.keys import CourseKey + from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag + # Namespace for courseware waffle flags. WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='courseware') @@ -129,22 +132,79 @@ COURSEWARE_OPTIMIZED_RENDER_XBLOCK = CourseWaffleFlag( ) +def courseware_mfe_is_active(course_key: CourseKey) -> bool: + """ + Should we serve the Learning MFE as the canonical courseware experience? + """ + # NO: Old Mongo courses are always served in the Legacy frontend, + # regardless of configuration. + if course_key.deprecated: + return False + # OTHERWISE: Defer to value of waffle flag for this course run and user. + return REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(course_key) + + +def courseware_mfe_is_visible( + course_key: CourseKey, + is_global_staff=False, + is_course_staff=False, +) -> bool: + """ + Can we see a course run's content in the Learning MFE? + """ + # DENY: Old Mongo courses don't work in the MFE. + if course_key.deprecated: + return False + # ALLOW: Where techincally possible, global staff may always see the MFE. + if is_global_staff: + return True + # ALLOW: If course team preview is enabled, then course staff may see their + # course in the MFE. + if is_course_staff and COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW.is_enabled(course_key): + return True + # OTHERWISE: The MFE is only visible if it's the active (ie canonical) experience. + return courseware_mfe_is_active(course_key) + + +def courseware_legacy_is_visible( + course_key: CourseKey, + is_global_staff=False, + is_course_staff=False, +) -> bool: + """ + Can we see a course run's content in the Legacy frontend? + + Note: This function will always return True for Old Mongo courses, + since `courseware_mfe_is_active` will always return False for them. + """ + # ALLOW: Global staff may always see the Legacy experience. + if is_global_staff: + return True + # ALLOW: The course team may always see their course in the Legacy experience. + if is_course_staff: + return True + # OTHERWISE: Legacy is only visible if it's the active (ie canonical) experience. + # Note that Old Mongo courses are never the active experience, + # so we effectively always ALLOW them to be viewed in Legacy. + return not courseware_mfe_is_active(course_key) + + def course_exit_page_is_active(course_key): return ( - REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(course_key) and + courseware_mfe_is_active(course_key) and COURSEWARE_MICROFRONTEND_COURSE_EXIT_PAGE.is_enabled(course_key) ) def courseware_mfe_progress_milestones_are_active(course_key): return ( - REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(course_key) and + courseware_mfe_is_active(course_key) and COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES.is_enabled(course_key) ) def streak_celebration_is_active(course_key): return ( - COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES.is_enabled(course_key) and + courseware_mfe_is_active(course_key) and COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES_STREAK_CELEBRATION.is_enabled(course_key) ) diff --git a/lms/djangoapps/courseware/views/index.py b/lms/djangoapps/courseware/views/index.py index 5438ada7d8..70527b26b0 100644 --- a/lms/djangoapps/courseware/views/index.py +++ b/lms/djangoapps/courseware/views/index.py @@ -65,7 +65,7 @@ from ..masquerade import check_content_start_date_for_masquerade_user, setup_mas from ..model_data import FieldDataCache from ..module_render import get_module_for_descriptor, toc_for_course from ..permissions import MASQUERADE_AS_STUDENT -from ..toggles import COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW, REDIRECT_TO_COURSEWARE_MICROFRONTEND +from ..toggles import courseware_legacy_is_visible, courseware_mfe_is_visible from .views import CourseTabView log = logging.getLogger("edx.courseware.views.index") @@ -170,21 +170,24 @@ class CoursewareIndex(View): def _redirect_to_learning_mfe(self): """ - Redirect to the new courseware micro frontend, - unless this is a time limited exam. + Can the user access this sequence in Legacy courseware? If not, redirect to MFE. + + We specifically allow users to stay in the Legacy frontend for special + (ie timed/proctored) exams since they're not yet supported by the MFE. """ - # DENY: staff access - if self.is_staff: + # STAY: if the course run as a whole is visible in the Legacy experience. + if courseware_legacy_is_visible( + course_key=self.course_key, + is_global_staff=self.request.user.is_staff, + is_course_staff=self.is_staff, + ): return - # DENY: Old Mongo courses, until removed from platform - if self.course_key.deprecated: - return - # DENY: Timed Exams, until supported + # STAY: if we are in a special (ie proctored/timed) exam, which isn't yet + # supported on the MFE. if getattr(self.section, 'is_time_limited', False): return - # ALLOW: when flag set for course - if REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(self.course_key): - raise Redirect(self.microfrontend_url) + # REDIRECT otherwise. + raise Redirect(self.microfrontend_url) @property def microfrontend_url(self): @@ -622,24 +625,8 @@ def show_courseware_mfe_link(user, staff_access, course_key): """ Return whether to display the button to switch to the Courseware MFE. """ - # MFE does not work for Old Mongo courses. - if course_key.deprecated: - return False - - # Global staff members always get to see the courseware MFE button if the - # platform and course are capable, regardless of rollout waffle flags. - if user.is_staff: - return True - - # If you have course staff access, you can see this link if... - if staff_access: - # (a) we've turned on the redirect for your students, or... - mfe_enabled_for_course = REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(course_key) - if mfe_enabled_for_course: - return True - # (b) we've enabled the course team preview. - mfe_enabled_for_course_team = COURSEWARE_MICROFRONTEND_COURSE_TEAM_PREVIEW.is_enabled(course_key) - if mfe_enabled_for_course_team: - return True - - return False + return courseware_mfe_is_visible( + course_key=course_key, + is_global_staff=user.is_staff, + is_course_staff=staff_access, + ) diff --git a/lms/djangoapps/courseware/views/views.py b/lms/djangoapps/courseware/views/views.py index 7b6b3f4fca..9287598770 100644 --- a/lms/djangoapps/courseware/views/views.py +++ b/lms/djangoapps/courseware/views/views.py @@ -396,7 +396,7 @@ def jump_to(_request, course_id, location): except InvalidKeyError: raise Http404(u"Invalid course_key or usage_key") # lint-amnesty, pylint: disable=raise-missing-from try: - redirect_url = get_legacy_courseware_url(course_key, usage_key, _request) + redirect_url = get_legacy_courseware_url(usage_key, _request) except ItemNotFoundError: raise Http404(u"No data at this location: {0}".format(usage_key)) # lint-amnesty, pylint: disable=raise-missing-from except NoPathToItem: diff --git a/openedx/core/djangoapps/courseware_api/views.py b/openedx/core/djangoapps/courseware_api/views.py index 0594840b94..767780f24b 100644 --- a/openedx/core/djangoapps/courseware_api/views.py +++ b/openedx/core/djangoapps/courseware_api/views.py @@ -32,7 +32,7 @@ from lms.djangoapps.courseware.courses import check_course_access, get_course_by from lms.djangoapps.courseware.masquerade import setup_masquerade from lms.djangoapps.courseware.module_render import get_module_by_usage_id from lms.djangoapps.courseware.tabs import get_course_tab_list -from lms.djangoapps.courseware.toggles import REDIRECT_TO_COURSEWARE_MICROFRONTEND, course_exit_page_is_active +from lms.djangoapps.courseware.toggles import courseware_mfe_is_visible, course_exit_page_is_active from lms.djangoapps.courseware.views.views import get_cert_data from lms.djangoapps.grades.api import CourseGradeFactory from lms.djangoapps.verify_student.services import IDVerificationService @@ -69,6 +69,7 @@ class CoursewareMeta: course_key, ) self.original_user_is_staff = has_access(self.request.user, 'staff', self.overview).has_access + self.original_user_is_global_staff = self.request.user.is_staff self.course_key = course_key self.course_masquerade, self.effective_user = setup_masquerade( self.request, @@ -85,25 +86,13 @@ class CoursewareMeta: def is_microfrontend_enabled_for_user(self): """ - This method is the "opposite" of _redirect_to_learning_mfe in - lms/djangoapps/courseware/views/index.py. But not exactly... - - 1. It needs to redirect for old Mongo courses. - 2. It does NOT need to worry about exams - the MFE will handle - those on its own. As of this writing, it will redirect back to - the LMS experience, but that may change soon. - 3. Finally, it needs to redirect users who are bucketed out of - the MFE experience, but who aren't staff. Staff are allowed to - stay. + Can this user see the MFE for this course? """ - # REDIRECT: Old Mongo courses, until removed from platform - if self.course_key.deprecated: - return False - # REDIRECT: If the user isn't staff, redirect if they're bucketed into the old LMS experience. - if not self.original_user_is_staff and not REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(self.course_key): - return False - # STAY: If the user has made it past all the above, they're good to stay! - return True + return courseware_mfe_is_visible( + course_key=self.course_key, + is_global_staff=self.original_user_is_global_staff, + is_course_staff=self.original_user_is_staff + ) @property def enrollment(self): diff --git a/openedx/features/course_experience/tests/test_url_helpers.py b/openedx/features/course_experience/tests/test_url_helpers.py new file mode 100644 index 0000000000..31c469022f --- /dev/null +++ b/openedx/features/course_experience/tests/test_url_helpers.py @@ -0,0 +1,257 @@ +""" +Test some of the functions in url_helpers +""" +from unittest import mock + +import ddt + +from xmodule.modulestore import ModuleStoreEnum +from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory + +from .. import url_helpers + + +def _patch_courseware_mfe_is_active(ret_val): + return mock.patch.object( + url_helpers, + 'courseware_mfe_is_active', + return_value=ret_val, + ) + + +@ddt.ddt +class GetCoursewareUrlTests(SharedModuleStoreTestCase): + """ + Test get_courseware_url. + + Mock out `courseware_mfe_is_active`; that is tested elseware. + """ + + @classmethod + def setUpClass(cls): + """ + Set up data used across test functions. + """ + # pylint: disable=super-method-not-called + with super().setUpClassAndTestData(): + cls.items = cls.create_test_courses() + + @classmethod + def create_test_courses(cls): + """ + We build two simple course structures (one using Split, the other Old Mongo). + Each course structure is a non-branching tree from the root Course block down + to the Component-level problem block; that is, we make one item for each course + hierarchy level. + + For easy access in the test functions, we return them in a dict like this: + { + "split": { + "course_run": , + "section": + "subsection": + "unit": + "component": + }, + "mongo": { + "course_run": , + ... etc ... + } + } + """ + + # Make Split Mongo course. + with cls.store.default_store(ModuleStoreEnum.Type.split): + course_run = CourseFactory.create( + org='TestX', + number='UrlHelpers', + run='split', + display_name='URL Helpers Test Course', + ) + with cls.store.bulk_operations(course_run.id): + section = ItemFactory.create( + parent_location=course_run.location, + category='chapter', + display_name="Generated Section", + ) + subsection = ItemFactory.create( + parent_location=section.location, + category='sequential', + display_name="Generated Subsection", + ) + unit = ItemFactory.create( + parent_location=subsection.location, + category='vertical', + display_name="Generated Unit", + ) + component = ItemFactory.create( + parent_location=unit.location, + category='problem', + display_name="Generated Problem Component", + ) + + # Make (deprecated) Old Mongo course. + with cls.store.default_store(ModuleStoreEnum.Type.mongo): + deprecated_course_run = CourseFactory.create( + org='TestX', + number='UrlHelpers', + run='mongo', + display_name='URL Helpers Test Course (Deprecated)', + ) + with cls.store.bulk_operations(deprecated_course_run.id): + deprecated_section = ItemFactory.create( + parent_location=deprecated_course_run.location, + category='chapter', + display_name="Generated Section", + ) + deprecated_subsection = ItemFactory.create( + parent_location=deprecated_section.location, + category='sequential', + display_name="Generated Subsection", + ) + deprecated_unit = ItemFactory.create( + parent_location=deprecated_subsection.location, + category='vertical', + display_name="Generated Unit", + ) + deprecated_component = ItemFactory.create( + parent_location=deprecated_unit.location, + category='problem', + display_name="Generated Problem Component", + ) + + return { + ModuleStoreEnum.Type.split: { + 'course_run': course_run, + 'section': section, + 'subsection': subsection, + 'unit': unit, + 'component': component, + }, + ModuleStoreEnum.Type.mongo: { + 'course_run': deprecated_course_run, + 'section': deprecated_section, + 'subsection': deprecated_subsection, + 'unit': deprecated_unit, + 'component': deprecated_component, + } + } + + @ddt.data( + ( + ModuleStoreEnum.Type.split, + 'mfe', + 'course_run', + 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + ), + ( + ModuleStoreEnum.Type.split, + 'mfe', + 'section', + ( + 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + + '/block-v1:TestX+UrlHelpers+split+type@chapter+block@Generated_Section' + ), + ), + ( + ModuleStoreEnum.Type.split, + 'mfe', + 'subsection', + ( + 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + + '/block-v1:TestX+UrlHelpers+split+type@sequential+block@Generated_Subsection' + ), + ), + ( + ModuleStoreEnum.Type.split, + 'mfe', + 'unit', + ( + 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + + '/block-v1:TestX+UrlHelpers+split+type@sequential+block@Generated_Subsection' + + '/block-v1:TestX+UrlHelpers+split+type@vertical+block@Generated_Unit' + ), + ), + ( + ModuleStoreEnum.Type.split, + 'mfe', + 'component', + ( + 'http://learning-mfe/course/course-v1:TestX+UrlHelpers+split' + + '/block-v1:TestX+UrlHelpers+split+type@sequential+block@Generated_Subsection' + + '/block-v1:TestX+UrlHelpers+split+type@vertical+block@Generated_Unit' + ), + ), + ( + ModuleStoreEnum.Type.split, + 'legacy', + 'course_run', + '/courses/course-v1:TestX+UrlHelpers+split/courseware', + ), + ( + ModuleStoreEnum.Type.split, + 'legacy', + 'subsection', + '/courses/course-v1:TestX+UrlHelpers+split/courseware/Generated_Section/Generated_Subsection/', + ), + ( + ModuleStoreEnum.Type.split, + 'legacy', + 'unit', + '/courses/course-v1:TestX+UrlHelpers+split/courseware/Generated_Section/Generated_Subsection/1', + ), + ( + ModuleStoreEnum.Type.split, + 'legacy', + 'component', + '/courses/course-v1:TestX+UrlHelpers+split/courseware/Generated_Section/Generated_Subsection/1', + ), + ( + ModuleStoreEnum.Type.mongo, + 'legacy', + 'course_run', + '/courses/TestX/UrlHelpers/mongo/courseware', + ), + ( + ModuleStoreEnum.Type.mongo, + 'legacy', + 'subsection', + '/courses/TestX/UrlHelpers/mongo/courseware/Generated_Section/Generated_Subsection/', + ), + ( + ModuleStoreEnum.Type.mongo, + 'legacy', + 'unit', + '/courses/TestX/UrlHelpers/mongo/courseware/Generated_Section/Generated_Subsection/1', + ), + ( + ModuleStoreEnum.Type.mongo, + 'legacy', + 'component', + '/courses/TestX/UrlHelpers/mongo/courseware/Generated_Section/Generated_Subsection/1', + ), + ) + @ddt.unpack + def test_get_courseware_url( + self, + store_type, + active_experience, + structure_level, + expected_path, + ): + """ + Given: + * a `store_type` ('split' or [old] 'mongo'), + * an `active_experience` ('mfe' or 'legacy'), + * and a `structure_level` ('course_run', 'section', 'subsection', 'unit', or 'component'), + + check that the expected path (URL without querystring) is returned by `get_courseware_url`. + """ + block = self.items[store_type][structure_level] + with _patch_courseware_mfe_is_active(active_experience == 'mfe') as mock_mfe_is_active: + url = url_helpers.get_courseware_url(block.location) + path = url.split('?')[0] + assert path == expected_path + course_run = self.items[store_type]['course_run'] + mock_mfe_is_active.assert_called_once_with(course_run.id) diff --git a/openedx/features/course_experience/url_helpers.py b/openedx/features/course_experience/url_helpers.py index 776e5686ef..d25fdae17e 100644 --- a/openedx/features/course_experience/url_helpers.py +++ b/openedx/features/course_experience/url_helpers.py @@ -4,30 +4,108 @@ Helper functions for logic related to learning (courseare & course home) URLs. Centralizdd in openedx/features/course_experience instead of lms/djangoapps/courseware because the Studio course outline may need these utilities. """ +from typing import Optional, Tuple + import six from django.conf import settings +from django.contrib.auth import get_user_model +from django.http import HttpRequest from django.urls import reverse +from opaque_keys.edx.keys import CourseKey, UsageKey from six.moves.urllib.parse import urlencode +from lms.djangoapps.courseware.toggles import courseware_mfe_is_active from xmodule.modulestore.django import modulestore from xmodule.modulestore.search import navigation_index, path_to_location +User = get_user_model() -def get_legacy_courseware_url(course_key, usage_key, request=None): + +def get_courseware_url( + usage_key: UsageKey, + request: Optional[HttpRequest] = None, +) -> str: """ - Return a str with the URL for the specified legacy (LMS-rendered) coursweare content. + Return the URL to the canonical learning experience for a given block. - Args: - course_id(str): Course Id string - usage_key(str): The location id of course component + We choose between either the Legacy frontend or Learning MFE depending on the + course that the block is in, the requesting user, and the state of + the 'courseware' waffle flags. + + If you know that you want a Learning MFE URL, regardless of configuration, + then it is more performant to call `get_learning_mfe_courseware_url` directly. Raises: - ItemNotFoundError if no data at the location or NoPathToItem if location not in any class - - Returns: - Redirect url string + * ItemNotFoundError if no data at the `usage_key`. + * NoPathToItem if we cannot build a path to the `usage_key`. """ + course_key = usage_key.course_key.replace(version_guid=None, branch=None) + if courseware_mfe_is_active(course_key): + sequence_key, unit_key = _get_sequence_and_unit_keys( + usage_key=usage_key, + request=request, + ) + return get_learning_mfe_courseware_url( + course_key=course_key, + sequence_key=sequence_key, + unit_key=unit_key, + ) + else: + return get_legacy_courseware_url( + usage_key=usage_key, + request=request + ) + +def _get_sequence_and_unit_keys( + usage_key: UsageKey, + request: Optional[HttpRequest] = None, +) -> Tuple[Optional[UsageKey], Optional[UsageKey]]: + """ + Find the sequence and unit containg a block within a course run. + + Performance consideration: Currently, this function incurs a modulestore query. + + Raises: + * ItemNotFoundError if no data at the `usage_key`. + * NoPathToItem if we cannot build a path to the `usage_key`. + + Returns: (sequence_key|None, unit_key|None) + * sequence_key points to a Section (ie chapter) or Subsection (ie sequential). + * unit_key points to Unit (ie vertical). + Either of these may be None if we are above that level in the course hierarchy. + For example, if `usage_key` points to a Subsection, then unit_key will be None. + """ + path = path_to_location(modulestore(), usage_key, request, full_path=True) + if len(path) <= 1: + # Course-run-level block: + # We have no Sequence or Unit to return. + return None, None + elif len(path) == 2: + # Section-level (ie chapter) block: + # The Section is the Sequence. We have no Unit to return. + return path[1], None + elif len(path) == 3: + # Subsection-level block: + # The Subsection is the Sequence. We still have no Unit to return. + return path[2], None + else: + # Unit-level (or lower) block: + # The Subsection is the Sequence, and the next level down is the Unit. + return path[2], path[3] + + +def get_legacy_courseware_url( + usage_key: UsageKey, + request: Optional[HttpRequest] = None, +) -> str: + """ + Return the URL to Legacy (LMS-rendered) courseware content. + + Raises: + * ItemNotFoundError if no data at the usage_key. + * NoPathToItem if location not in any class. + """ ( course_key, chapter, section, vertical_unused, position, final_target_id @@ -57,18 +135,27 @@ def get_legacy_courseware_url(course_key, usage_key, request=None): return redirect_url -def get_learning_mfe_courseware_url(course_key, sequence_key=None, unit_key=None): +def get_learning_mfe_courseware_url( + course_key: CourseKey, + sequence_key: Optional[UsageKey] = None, + unit_key: Optional[UsageKey] = None, +) -> str: """ - Return a str with the URL for the specified coursweare content in the Learning MFE. + Return a str with the URL for the specified courseware content in the Learning MFE. The micro-frontend determines the user's position in the vertical via - a separate API call, so all we need here is the course_key, section, and + a separate API call, so all we need here is the course_key, sequence, and vertical IDs to format it's URL. For simplicity and performance reasons, this method does not inspect the modulestore to try to figure out what Unit/Vertical a sequence is in. If you try to pass in a unit_key without a sequence_key, the value will just be ignored and you'll get a URL pointing to just the course_key. + Note that `sequence_key` may either point to a Section (ie chapter) or + Subsection (ie sequential), as those are both abstractly understood as + "sequences". If you pass in a Section-level `sequence_key`, then the MFE + will replace it with key of the first Subsection in that Section. + It is also capable of determining our section and vertical if they're not present. Fully specifying it all is preferable, though, as the micro-frontend can save itself some work, resulting in a better user @@ -92,7 +179,9 @@ def get_learning_mfe_courseware_url(course_key, sequence_key=None, unit_key=None return mfe_link -def get_learning_mfe_home_url(course_key, view_name=None): +def get_learning_mfe_home_url( + course_key: CourseKey, view_name: Optional[str] = None +) -> str: """ Given a course run key and view name, return the appropriate course home (MFE) URL. @@ -111,7 +200,7 @@ def get_learning_mfe_home_url(course_key, view_name=None): return mfe_link -def is_request_from_learning_mfe(request): +def is_request_from_learning_mfe(request: HttpRequest): """ Returns whether the given request was made by the frontend-app-learning MFE. """