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
This commit is contained in:
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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):
|
||||
|
||||
257
openedx/features/course_experience/tests/test_url_helpers.py
Normal file
257
openedx/features/course_experience/tests/test_url_helpers.py
Normal file
@@ -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": <course block for Split Mongo course>,
|
||||
"section": <chapter block in course run>
|
||||
"subsection": <sequence block in section>
|
||||
"unit": <vertical block in subsection>
|
||||
"component": <problem block in unit>
|
||||
},
|
||||
"mongo": {
|
||||
"course_run": <course block for (deprecated) Old Mongo course>,
|
||||
... 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)
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user