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,
|
PendingNameChange,
|
||||||
)
|
)
|
||||||
from common.djangoapps.student.tests.factories import AccountRecoveryFactory, CourseEnrollmentFactory, UserFactory
|
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.django_utils import SharedModuleStoreTestCase
|
||||||
from xmodule.modulestore.tests.factories import CourseFactory
|
from xmodule.modulestore.tests.factories import CourseFactory
|
||||||
|
|
||||||
@@ -258,7 +259,7 @@ class UserCelebrationTests(SharedModuleStoreTestCase):
|
|||||||
@classmethod
|
@classmethod
|
||||||
def setUpClass(cls):
|
def setUpClass(cls):
|
||||||
super().setUpClass()
|
super().setUpClass()
|
||||||
cls.course = CourseFactory()
|
cls.course = CourseFactory(default_store=ModuleStoreEnum.Type.split)
|
||||||
cls.course_key = cls.course.id # pylint: disable=no-member
|
cls.course_key = cls.course.id # pylint: disable=no-member
|
||||||
|
|
||||||
def setUp(self):
|
def setUp(self):
|
||||||
|
|||||||
@@ -258,7 +258,7 @@ class TestJumpTo(ModuleStoreTestCase):
|
|||||||
)
|
)
|
||||||
expected_url += "?{}".format(urlencode({'activate_block_id': six.text_type(staff_only_vertical.location)}))
|
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
|
@ddt.ddt
|
||||||
@@ -548,9 +548,9 @@ class ViewsTestCase(BaseViewsTestCase):
|
|||||||
|
|
||||||
def test_get_redirect_url(self):
|
def test_get_redirect_url(self):
|
||||||
# test the course location
|
# 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
|
# 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):
|
def test_invalid_course_id(self):
|
||||||
response = self.client.get('/courses/MITx/3.091X/')
|
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(global_staff_user, False, new_course_key)
|
||||||
assert show_courseware_mfe_link(regular_user, True, new_course_key)
|
assert show_courseware_mfe_link(regular_user, True, new_course_key)
|
||||||
|
|
||||||
# Regular users don't see the link.
|
# (Regular users would see the link, but they can't see the Legacy
|
||||||
assert not show_courseware_mfe_link(regular_user, False, new_course_key)
|
# experience, so it doesn't matter.)
|
||||||
|
|
||||||
with override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=False):
|
with override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=False):
|
||||||
# (preview=on, redirect=off)
|
# (preview=on, redirect=off)
|
||||||
# Global and Course Staff can see the link.
|
# Global and Course Staff can see the link.
|
||||||
@@ -3305,8 +3306,9 @@ class TestShowCoursewareMFE(TestCase):
|
|||||||
# if preview=off.
|
# if preview=off.
|
||||||
assert show_courseware_mfe_link(regular_user, True, new_course_key)
|
assert show_courseware_mfe_link(regular_user, True, new_course_key)
|
||||||
|
|
||||||
# Regular users don't see the link.
|
# (Regular users would see the link, but they can't see the Legacy
|
||||||
assert not show_courseware_mfe_link(regular_user, False, new_course_key)
|
# experience, so it doesn't matter.)
|
||||||
|
|
||||||
with override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=False):
|
with override_waffle_flag(REDIRECT_TO_COURSEWARE_MICROFRONTEND, active=False):
|
||||||
# (preview=off, redirect=off)
|
# (preview=off, redirect=off)
|
||||||
# Global staff see the link anyway
|
# 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)
|
self.setup_user(admin=True, enroll=True, login=True)
|
||||||
|
|
||||||
with check_mongo_calls(mongo_calls):
|
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)
|
response = self.client.get(url)
|
||||||
expected_elements = self.block_specific_chrome_html_elements + self.COURSEWARE_CHROME_HTML_ELEMENTS
|
expected_elements = self.block_specific_chrome_html_elements + self.COURSEWARE_CHROME_HTML_ELEMENTS
|
||||||
for chrome_element in expected_elements:
|
for chrome_element in expected_elements:
|
||||||
|
|||||||
@@ -3,8 +3,11 @@ Toggles for courseware in-course experience.
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
from edx_toggles.toggles import LegacyWaffleFlagNamespace
|
from edx_toggles.toggles import LegacyWaffleFlagNamespace
|
||||||
|
from opaque_keys.edx.keys import CourseKey
|
||||||
|
|
||||||
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
|
from openedx.core.djangoapps.waffle_utils import CourseWaffleFlag
|
||||||
|
|
||||||
|
|
||||||
# Namespace for courseware waffle flags.
|
# Namespace for courseware waffle flags.
|
||||||
WAFFLE_FLAG_NAMESPACE = LegacyWaffleFlagNamespace(name='courseware')
|
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):
|
def course_exit_page_is_active(course_key):
|
||||||
return (
|
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)
|
COURSEWARE_MICROFRONTEND_COURSE_EXIT_PAGE.is_enabled(course_key)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def courseware_mfe_progress_milestones_are_active(course_key):
|
def courseware_mfe_progress_milestones_are_active(course_key):
|
||||||
return (
|
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)
|
COURSEWARE_MICROFRONTEND_PROGRESS_MILESTONES.is_enabled(course_key)
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
def streak_celebration_is_active(course_key):
|
def streak_celebration_is_active(course_key):
|
||||||
return (
|
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)
|
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 ..model_data import FieldDataCache
|
||||||
from ..module_render import get_module_for_descriptor, toc_for_course
|
from ..module_render import get_module_for_descriptor, toc_for_course
|
||||||
from ..permissions import MASQUERADE_AS_STUDENT
|
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
|
from .views import CourseTabView
|
||||||
|
|
||||||
log = logging.getLogger("edx.courseware.views.index")
|
log = logging.getLogger("edx.courseware.views.index")
|
||||||
@@ -170,21 +170,24 @@ class CoursewareIndex(View):
|
|||||||
|
|
||||||
def _redirect_to_learning_mfe(self):
|
def _redirect_to_learning_mfe(self):
|
||||||
"""
|
"""
|
||||||
Redirect to the new courseware micro frontend,
|
Can the user access this sequence in Legacy courseware? If not, redirect to MFE.
|
||||||
unless this is a time limited exam.
|
|
||||||
|
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
|
# STAY: if the course run as a whole is visible in the Legacy experience.
|
||||||
if self.is_staff:
|
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
|
return
|
||||||
# DENY: Old Mongo courses, until removed from platform
|
# STAY: if we are in a special (ie proctored/timed) exam, which isn't yet
|
||||||
if self.course_key.deprecated:
|
# supported on the MFE.
|
||||||
return
|
|
||||||
# DENY: Timed Exams, until supported
|
|
||||||
if getattr(self.section, 'is_time_limited', False):
|
if getattr(self.section, 'is_time_limited', False):
|
||||||
return
|
return
|
||||||
# ALLOW: when flag set for course
|
# REDIRECT otherwise.
|
||||||
if REDIRECT_TO_COURSEWARE_MICROFRONTEND.is_enabled(self.course_key):
|
raise Redirect(self.microfrontend_url)
|
||||||
raise Redirect(self.microfrontend_url)
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def microfrontend_url(self):
|
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.
|
Return whether to display the button to switch to the Courseware MFE.
|
||||||
"""
|
"""
|
||||||
# MFE does not work for Old Mongo courses.
|
return courseware_mfe_is_visible(
|
||||||
if course_key.deprecated:
|
course_key=course_key,
|
||||||
return False
|
is_global_staff=user.is_staff,
|
||||||
|
is_course_staff=staff_access,
|
||||||
# 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
|
|
||||||
|
|||||||
@@ -396,7 +396,7 @@ def jump_to(_request, course_id, location):
|
|||||||
except InvalidKeyError:
|
except InvalidKeyError:
|
||||||
raise Http404(u"Invalid course_key or usage_key") # lint-amnesty, pylint: disable=raise-missing-from
|
raise Http404(u"Invalid course_key or usage_key") # lint-amnesty, pylint: disable=raise-missing-from
|
||||||
try:
|
try:
|
||||||
redirect_url = get_legacy_courseware_url(course_key, usage_key, _request)
|
redirect_url = get_legacy_courseware_url(usage_key, _request)
|
||||||
except ItemNotFoundError:
|
except ItemNotFoundError:
|
||||||
raise Http404(u"No data at this location: {0}".format(usage_key)) # lint-amnesty, pylint: disable=raise-missing-from
|
raise Http404(u"No data at this location: {0}".format(usage_key)) # lint-amnesty, pylint: disable=raise-missing-from
|
||||||
except NoPathToItem:
|
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.masquerade import setup_masquerade
|
||||||
from lms.djangoapps.courseware.module_render import get_module_by_usage_id
|
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.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.courseware.views.views import get_cert_data
|
||||||
from lms.djangoapps.grades.api import CourseGradeFactory
|
from lms.djangoapps.grades.api import CourseGradeFactory
|
||||||
from lms.djangoapps.verify_student.services import IDVerificationService
|
from lms.djangoapps.verify_student.services import IDVerificationService
|
||||||
@@ -69,6 +69,7 @@ class CoursewareMeta:
|
|||||||
course_key,
|
course_key,
|
||||||
)
|
)
|
||||||
self.original_user_is_staff = has_access(self.request.user, 'staff', self.overview).has_access
|
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_key = course_key
|
||||||
self.course_masquerade, self.effective_user = setup_masquerade(
|
self.course_masquerade, self.effective_user = setup_masquerade(
|
||||||
self.request,
|
self.request,
|
||||||
@@ -85,25 +86,13 @@ class CoursewareMeta:
|
|||||||
|
|
||||||
def is_microfrontend_enabled_for_user(self):
|
def is_microfrontend_enabled_for_user(self):
|
||||||
"""
|
"""
|
||||||
This method is the "opposite" of _redirect_to_learning_mfe in
|
Can this user see the MFE for this course?
|
||||||
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.
|
|
||||||
"""
|
"""
|
||||||
# REDIRECT: Old Mongo courses, until removed from platform
|
return courseware_mfe_is_visible(
|
||||||
if self.course_key.deprecated:
|
course_key=self.course_key,
|
||||||
return False
|
is_global_staff=self.original_user_is_global_staff,
|
||||||
# REDIRECT: If the user isn't staff, redirect if they're bucketed into the old LMS experience.
|
is_course_staff=self.original_user_is_staff
|
||||||
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
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def enrollment(self):
|
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
|
Centralizdd in openedx/features/course_experience instead of lms/djangoapps/courseware
|
||||||
because the Studio course outline may need these utilities.
|
because the Studio course outline may need these utilities.
|
||||||
"""
|
"""
|
||||||
|
from typing import Optional, Tuple
|
||||||
|
|
||||||
import six
|
import six
|
||||||
from django.conf import settings
|
from django.conf import settings
|
||||||
|
from django.contrib.auth import get_user_model
|
||||||
|
from django.http import HttpRequest
|
||||||
from django.urls import reverse
|
from django.urls import reverse
|
||||||
|
from opaque_keys.edx.keys import CourseKey, UsageKey
|
||||||
from six.moves.urllib.parse import urlencode
|
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.django import modulestore
|
||||||
from xmodule.modulestore.search import navigation_index, path_to_location
|
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:
|
We choose between either the Legacy frontend or Learning MFE depending on the
|
||||||
course_id(str): Course Id string
|
course that the block is in, the requesting user, and the state of
|
||||||
usage_key(str): The location id of course component
|
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:
|
Raises:
|
||||||
ItemNotFoundError if no data at the location or NoPathToItem if location not in any class
|
* ItemNotFoundError if no data at the `usage_key`.
|
||||||
|
* NoPathToItem if we cannot build a path to the `usage_key`.
|
||||||
Returns:
|
|
||||||
Redirect url string
|
|
||||||
"""
|
"""
|
||||||
|
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,
|
course_key, chapter, section, vertical_unused,
|
||||||
position, final_target_id
|
position, final_target_id
|
||||||
@@ -57,18 +135,27 @@ def get_legacy_courseware_url(course_key, usage_key, request=None):
|
|||||||
return redirect_url
|
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
|
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,
|
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
|
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
|
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
|
a sequence_key, the value will just be ignored and you'll get a URL pointing
|
||||||
to just the course_key.
|
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
|
It is also capable of determining our section and vertical if they're not
|
||||||
present. Fully specifying it all is preferable, though, as the
|
present. Fully specifying it all is preferable, though, as the
|
||||||
micro-frontend can save itself some work, resulting in a better user
|
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
|
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.
|
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
|
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.
|
Returns whether the given request was made by the frontend-app-learning MFE.
|
||||||
"""
|
"""
|
||||||
|
|||||||
Reference in New Issue
Block a user