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:
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