feat: support unit preview in learning MFE (#35747)

* feat: update preview url to direct to mfe

* fix: use url builder instead of string formatter

* fix: url redirect for never published units

* fix: remove 404 error when  not a preview or staff

* feat: update sequence metadata to allow draft branch
This commit is contained in:
Kristin Aoki
2024-11-01 11:03:06 -04:00
committed by GitHub
parent bd22449d73
commit e13d66d1e4
10 changed files with 301 additions and 265 deletions

View File

@@ -117,7 +117,7 @@ class HomePageCoursesViewTest(CourseTestCase):
"courses": [{
"course_key": course_id,
"display_name": self.course.display_name,
"lms_link": f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}',
"lms_link": f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}',
"number": self.course.number,
"org": self.course.org,
"rerun_link": f'/course_rerun/{course_id}',
@@ -144,7 +144,7 @@ class HomePageCoursesViewTest(CourseTestCase):
OrderedDict([
("course_key", course_id),
("display_name", self.course.display_name),
("lms_link", f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}'),
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}'),
("number", self.course.number),
("org", self.course.org),
("rerun_link", f'/course_rerun/{course_id}'),

View File

@@ -62,7 +62,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
OrderedDict([
("course_key", course_id),
("display_name", self.course.display_name),
("lms_link", f'//{settings.LMS_BASE}/courses/{course_id}/jump_to/{self.course.location}'),
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{course_id}/jump_to/{self.course.location}'),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
("number", self.course.number),
("org", self.course.org),
@@ -76,7 +76,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
("display_name", self.archived_course.display_name),
(
"lms_link",
f'//{settings.LMS_BASE}/courses/{archived_course_id}/jump_to/{self.archived_course.location}'
f'{settings.LMS_ROOT_URL}/courses/{archived_course_id}/jump_to/{self.archived_course.location}'
),
(
"cms_link",
@@ -139,7 +139,7 @@ class HomePageCoursesViewV2Test(CourseTestCase):
self.assertEqual(response.data["results"]["courses"], [OrderedDict([
("course_key", str(self.course.id)),
("display_name", self.course.display_name),
("lms_link", f'//{settings.LMS_BASE}/courses/{str(self.course.id)}/jump_to/{self.course.location}'),
("lms_link", f'{settings.LMS_ROOT_URL}/courses/{str(self.course.id)}/jump_to/{self.course.location}'),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.course.id)}'),
("number", self.course.number),
("org", self.course.org),
@@ -164,7 +164,11 @@ class HomePageCoursesViewV2Test(CourseTestCase):
("display_name", self.archived_course.display_name),
(
"lms_link",
f'//{settings.LMS_BASE}/courses/{str(self.archived_course.id)}/jump_to/{self.archived_course.location}',
'{url_root}/courses/{course_id}/jump_to/{location}'.format(
url_root=settings.LMS_ROOT_URL,
course_id=str(self.archived_course.id),
location=self.archived_course.location
),
),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
("number", self.archived_course.number),
@@ -190,7 +194,11 @@ class HomePageCoursesViewV2Test(CourseTestCase):
("display_name", self.archived_course.display_name),
(
"lms_link",
f'//{settings.LMS_BASE}/courses/{str(self.archived_course.id)}/jump_to/{self.archived_course.location}',
'{url_root}/courses/{course_id}/jump_to/{location}'.format(
url_root=settings.LMS_ROOT_URL,
course_id=str(self.archived_course.id),
location=self.archived_course.location
),
),
("cms_link", f'//{settings.CMS_BASE}{reverse_course_url("course_handler", self.archived_course.id)}'),
("number", self.archived_course.number),

View File

@@ -9,7 +9,7 @@ import re
from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime, timezone
from urllib.parse import quote_plus
from urllib.parse import quote_plus, urlencode, urlunparse, urlparse
from uuid import uuid4
from bs4 import BeautifulSoup
@@ -193,31 +193,30 @@ def get_lms_link_for_item(location, preview=False):
"""
assert isinstance(location, UsageKey)
# checks LMS_BASE value in site configuration for the given course_org_filter(org)
# if not found returns settings.LMS_BASE
# checks LMS_ROOT_URL value in site configuration for the given course_org_filter(org)
# if not found returns settings.LMS_ROOT_URL
lms_base = SiteConfiguration.get_value_for_org(
location.org,
"LMS_BASE",
settings.LMS_BASE
"LMS_ROOT_URL",
settings.LMS_ROOT_URL
)
query_string = ''
if lms_base is None:
return None
if preview:
# checks PREVIEW_LMS_BASE value in site configuration for the given course_org_filter(org)
# if not found returns settings.FEATURES.get('PREVIEW_LMS_BASE')
lms_base = SiteConfiguration.get_value_for_org(
location.org,
"PREVIEW_LMS_BASE",
settings.FEATURES.get('PREVIEW_LMS_BASE')
)
params = {'preview': '1'}
query_string = urlencode(params)
return "//{lms_base}/courses/{course_key}/jump_to/{location}".format(
lms_base=lms_base,
url_parts = list(urlparse(lms_base))
url_parts[2] = '/courses/{course_key}/jump_to/{location}'.format(
course_key=str(location.course_key),
location=str(location),
)
url_parts[4] = query_string
return urlunparse(url_parts)
def get_lms_link_for_certificate_web_view(course_key, mode):

View File

@@ -132,38 +132,6 @@ class TestJumpTo(ModuleStoreTestCase):
"""
Check the jumpto link for a course.
"""
@ddt.data(
(True, False), # preview -> Legacy experience
(False, True), # no preview -> MFE experience
)
@ddt.unpack
def test_jump_to_legacy_vs_mfe(self, preview_mode, expect_mfe):
"""
Test that jump_to and jump_to_id correctly choose which courseware frontend to redirect to.
Can be removed when the MFE supports a preview mode.
"""
course = CourseFactory.create()
chapter = BlockFactory.create(category='chapter', parent_location=course.location)
if expect_mfe:
expected_url = f'http://learning-mfe/course/{course.id}/{chapter.location}'
else:
expected_url = f'/courses/{course.id}/courseware/{chapter.url_name}/'
jumpto_url = f'/courses/{course.id}/jump_to/{chapter.location}'
with set_preview_mode(preview_mode):
response = self.client.get(jumpto_url)
assert response.status_code == 302
# Check the response URL, but chop off the querystring; we don't care here.
assert response.url.split('?')[0] == expected_url
jumpto_id_url = f'/courses/{course.id}/jump_to_id/{chapter.url_name}'
with set_preview_mode(preview_mode):
response = self.client.get(jumpto_id_url)
assert response.status_code == 302
# Check the response URL, but chop off the querystring; we don't care here.
assert response.url.split('?')[0] == expected_url
@ddt.data(
(False, ModuleStoreEnum.Type.split),
(True, ModuleStoreEnum.Type.split),
@@ -174,32 +142,34 @@ class TestJumpTo(ModuleStoreTestCase):
with self.store.default_store(store_type):
course = CourseFactory.create()
location = course.id.make_usage_key(None, 'NoSuchPlace')
expected_redirect_url = (
f'/courses/{course.id}/courseware?' + urlencode({'activate_block_id': str(course.location)})
expected_redirect_url = f'http://learning-mfe/course/{course.id}'
jumpto_url = (
f'/courses/{course.id}/jump_to/{location}?preview=1'
) if preview_mode else (
f'http://learning-mfe/course/{course.id}'
f'/courses/{course.id}/jump_to/{location}'
)
# This is fragile, but unfortunately the problem is that within the LMS we
# can't use the reverse calls from the CMS
jumpto_url = f'/courses/{course.id}/jump_to/{location}'
with set_preview_mode(preview_mode):
with set_preview_mode(False):
response = self.client.get(jumpto_url)
assert response.status_code == 302
assert response.url == expected_redirect_url
@set_preview_mode(True)
def test_jump_to_legacy_from_sequence(self):
@set_preview_mode(False)
def test_jump_to_preview_from_sequence(self):
with self.store.default_store(ModuleStoreEnum.Type.split):
course = CourseFactory.create()
chapter = BlockFactory.create(category='chapter', parent_location=course.location)
sequence = BlockFactory.create(category='sequential', parent_location=chapter.location)
activate_block_id = urlencode({'activate_block_id': str(sequence.location)})
jumpto_url = f'/courses/{course.id}/jump_to/{sequence.location}?preview=1'
expected_redirect_url = (
f'/courses/{course.id}/courseware/{chapter.url_name}/{sequence.url_name}/?{activate_block_id}'
f'http://learning-mfe/preview/course/{course.id}/{sequence.location}'
)
jumpto_url = f'/courses/{course.id}/jump_to/{sequence.location}'
response = self.client.get(jumpto_url)
self.assertRedirects(response, expected_redirect_url, status_code=302, target_status_code=302)
assert response.status_code == 302
assert response.url == expected_redirect_url
@set_preview_mode(False)
def test_jump_to_mfe_from_sequence(self):
@@ -214,8 +184,8 @@ class TestJumpTo(ModuleStoreTestCase):
assert response.status_code == 302
assert response.url == expected_redirect_url
@set_preview_mode(True)
def test_jump_to_legacy_from_block(self):
@set_preview_mode(False)
def test_jump_to_preview_from_block(self):
with self.store.default_store(ModuleStoreEnum.Type.split):
course = CourseFactory.create()
chapter = BlockFactory.create(category='chapter', parent_location=course.location)
@@ -225,21 +195,21 @@ class TestJumpTo(ModuleStoreTestCase):
block1 = BlockFactory.create(category='html', parent_location=vertical1.location)
block2 = BlockFactory.create(category='html', parent_location=vertical2.location)
activate_block_id = urlencode({'activate_block_id': str(block1.location)})
jumpto_url = f'/courses/{course.id}/jump_to/{block1.location}?preview=1'
expected_redirect_url = (
f'/courses/{course.id}/courseware/{chapter.url_name}/{sequence.url_name}/1?{activate_block_id}'
f'http://learning-mfe/preview/course/{course.id}/{sequence.location}/{vertical1.location}'
)
jumpto_url = f'/courses/{course.id}/jump_to/{block1.location}'
response = self.client.get(jumpto_url)
self.assertRedirects(response, expected_redirect_url, status_code=302, target_status_code=302)
assert response.status_code == 302
assert response.url == expected_redirect_url
activate_block_id = urlencode({'activate_block_id': str(block2.location)})
jumpto_url = f'/courses/{course.id}/jump_to/{block2.location}?preview=1'
expected_redirect_url = (
f'/courses/{course.id}/courseware/{chapter.url_name}/{sequence.url_name}/2?{activate_block_id}'
f'http://learning-mfe/preview/course/{course.id}/{sequence.location}/{vertical2.location}'
)
jumpto_url = f'/courses/{course.id}/jump_to/{block2.location}'
response = self.client.get(jumpto_url)
self.assertRedirects(response, expected_redirect_url, status_code=302, target_status_code=302)
assert response.status_code == 302
assert response.url == expected_redirect_url
@set_preview_mode(False)
def test_jump_to_mfe_from_block(self):
@@ -300,8 +270,12 @@ class TestJumpTo(ModuleStoreTestCase):
def test_jump_to_id_invalid_location(self, preview_mode, store_type):
with self.store.default_store(store_type):
course = CourseFactory.create()
jumpto_url = f'/courses/{course.id}/jump_to/NoSuchPlace'
with set_preview_mode(preview_mode):
jumpto_url = (
f'/courses/{course.id}/jump_to/NoSuchPlace?preview=1'
) if preview_mode else (
f'/courses/{course.id}/jump_to/NoSuchPlace'
)
with set_preview_mode(False):
response = self.client.get(jumpto_url)
assert response.status_code == 404
@@ -3359,7 +3333,7 @@ class PreviewTests(BaseViewsTestCase):
def test_preview_no_redirect(self):
__, __, preview_url = self._get_urls()
with set_preview_mode(True):
# Previews will not redirect to the mfe
# Previews server from PREVIEW_LMS_BASE will not redirect to the mfe
course_staff = UserFactory.create(is_staff=False)
CourseStaffRole(self.course_key).add_users(course_staff)
self.client.login(username=course_staff.username, password=TEST_PASSWORD)

View File

@@ -27,6 +27,7 @@ from web_fragments.fragment import Fragment
from xmodule.course_block import COURSE_VISIBILITY_PUBLIC
from xmodule.modulestore.django import modulestore
from xmodule.x_module import PUBLIC_VIEW, STUDENT_VIEW
from xmodule.util.xmodule_django import get_current_request_hostname
from common.djangoapps.edxmako.shortcuts import render_to_response, render_to_string
from common.djangoapps.student.models import CourseEnrollment
@@ -188,11 +189,13 @@ class CoursewareIndex(View):
unit_key = None
except InvalidKeyError:
unit_key = None
is_preview = settings.FEATURES.get('PREVIEW_LMS_BASE') == get_current_request_hostname()
url = make_learning_mfe_courseware_url(
self.course_key,
self.section.location if self.section else None,
unit_key,
params=self.request.GET,
preview=is_preview,
)
return url

View File

@@ -51,6 +51,7 @@ from xmodule.course_block import (
COURSE_VISIBILITY_PUBLIC_OUTLINE,
CATALOG_VISIBILITY_CATALOG_AND_ABOUT,
)
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.tabs import CourseTabList
@@ -439,10 +440,12 @@ def jump_to(request, course_id, location):
except InvalidKeyError as exc:
raise Http404("Invalid course_key or usage_key") from exc
staff_access = has_access(request.user, 'staff', course_key)
try:
redirect_url = get_courseware_url(
usage_key=usage_key,
request=request,
is_staff=staff_access,
)
except (ItemNotFoundError, NoPathToItem):
# We used to 404 here, but that's ultimately a bad experience. There are real world use cases where a user
@@ -452,6 +455,7 @@ def jump_to(request, course_id, location):
redirect_url = get_courseware_url(
usage_key=course_location_from_key(course_key),
request=request,
is_staff=staff_access,
)
return redirect(redirect_url)
@@ -1565,143 +1569,152 @@ def render_xblock(request, usage_key_string, check_if_enrolled=True, disable_sta
f"Rendering of the xblock view '{nh3.clean(requested_view)}' is not supported."
)
staff_access = has_access(request.user, 'staff', course_key)
staff_access = bool(has_access(request.user, 'staff', course_key))
is_preview = request.GET.get('preview', '0') == '1'
with modulestore().bulk_operations(course_key):
# verify the user has access to the course, including enrollment check
try:
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled)
except CourseAccessRedirect:
raise Http404("Course not found.") # lint-amnesty, pylint: disable=raise-missing-from
store = modulestore()
branch_type = (
ModuleStoreEnum.Branch.draft_preferred
) if is_preview and staff_access else (
ModuleStoreEnum.Branch.published_only
)
# with course access now verified:
# assume masquerading role, if applicable.
# (if we did this *before* the course access check, then course staff
# masquerading as learners would often be denied access, since course
# staff are generally not enrolled, and viewing a course generally
# requires enrollment.)
_course_masquerade, request.user = setup_masquerade(
request,
course_key,
staff_access,
)
with store.bulk_operations(course_key):
with store.branch_setting(branch_type, course_key):
# verify the user has access to the course, including enrollment check
try:
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled)
except CourseAccessRedirect:
raise Http404("Course not found.") # lint-amnesty, pylint: disable=raise-missing-from
# Record user activity for tracking progress towards a user's course goals (for mobile app)
UserActivity.record_user_activity(
request.user, usage_key.course_key, request=request, only_if_mobile_app=True
)
# get the block, which verifies whether the user has access to the block.
recheck_access = request.GET.get('recheck_access') == '1'
block, _ = get_block_by_usage_id(
request,
str(course_key),
str(usage_key),
disable_staff_debug_info=disable_staff_debug_info,
course=course,
will_recheck_access=recheck_access,
)
student_view_context = request.GET.dict()
student_view_context['show_bookmark_button'] = request.GET.get('show_bookmark_button', '0') == '1'
student_view_context['show_title'] = request.GET.get('show_title', '1') == '1'
is_learning_mfe = is_request_from_learning_mfe(request)
# Right now, we only care about this in regards to the Learning MFE because it results
# in a bad UX if we display blocks with access errors (repeated upgrade messaging).
# If other use cases appear, consider removing the is_learning_mfe check or switching this
# to be its own query parameter that can toggle the behavior.
student_view_context['hide_access_error_blocks'] = is_learning_mfe and recheck_access
is_mobile_app = is_request_from_mobile_app(request)
student_view_context['is_mobile_app'] = is_mobile_app
enable_completion_on_view_service = False
completion_service = block.runtime.service(block, 'completion')
if completion_service and completion_service.completion_tracking_enabled():
if completion_service.blocks_to_mark_complete_on_view({block}):
enable_completion_on_view_service = True
student_view_context['wrap_xblock_data'] = {
'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms()
}
missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user)
# Some content gating happens only at the Sequence level (e.g. "has this
# timed exam started?").
ancestor_sequence_block = enclosing_sequence_for_gating_checks(block)
if ancestor_sequence_block:
context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, course_key)}
# If the SequenceModule feels that gating is necessary, redirect
# there so we can have some kind of error message at any rate.
if ancestor_sequence_block.descendants_are_gated(context):
return redirect(
reverse(
'render_xblock',
kwargs={'usage_key_string': str(ancestor_sequence_block.location)}
)
)
# For courses using an LTI provider managed by edx-exams:
# Access to exam content is determined by edx-exams and passed to the LMS using a
# JWT url param. There is no longer a need for exam gating or logic inside the
# sequence block or its render call. descendants_are_gated shoule not return true
# for these timed exams. Instead, sequences are assumed gated by default and we look for
# an access token on the request to allow rendering to continue.
if course.proctoring_provider == 'lti_external':
seq_block = ancestor_sequence_block if ancestor_sequence_block else block
if getattr(seq_block, 'is_time_limited', None):
if not _check_sequence_exam_access(request, seq_block.location):
return HttpResponseForbidden("Access to exam content is restricted")
context = {
'course': course,
'block': block,
'disable_accordion': True,
'allow_iframing': True,
'disable_header': True,
'disable_footer': True,
'disable_window_wrap': True,
'enable_completion_on_view_service': enable_completion_on_view_service,
'edx_notes_enabled': is_feature_enabled(course, request.user),
'staff_access': staff_access,
'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'),
'missed_deadlines': missed_deadlines,
'missed_gated_content': missed_gated_content,
'has_ended': course.has_ended(),
'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'),
'on_courseware_page': True,
'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course),
'is_learning_mfe': is_learning_mfe,
'is_mobile_app': is_mobile_app,
'render_course_wide_assets': True,
}
try:
# .. filter_implemented_name: RenderXBlockStarted
# .. filter_type: org.openedx.learning.xblock.render.started.v1
context, student_view_context = RenderXBlockStarted.run_filter(
context=context, student_view_context=student_view_context
# with course access now verified:
# assume masquerading role, if applicable.
# (if we did this *before* the course access check, then course staff
# masquerading as learners would often be denied access, since course
# staff are generally not enrolled, and viewing a course generally
# requires enrollment.)
_course_masquerade, request.user = setup_masquerade(
request,
course_key,
staff_access,
)
except RenderXBlockStarted.PreventXBlockBlockRender as exc:
log.info("Halted rendering block %s. Reason: %s", usage_key_string, exc.message)
return render_500(request)
except RenderXBlockStarted.RenderCustomResponse as exc:
log.info("Rendering custom exception for block %s. Reason: %s", usage_key_string, exc.message)
# Record user activity for tracking progress towards a user's course goals (for mobile app)
UserActivity.record_user_activity(
request.user, usage_key.course_key, request=request, only_if_mobile_app=True
)
# get the block, which verifies whether the user has access to the block.
recheck_access = request.GET.get('recheck_access') == '1'
block, _ = get_block_by_usage_id(
request,
str(course_key),
str(usage_key),
disable_staff_debug_info=disable_staff_debug_info,
course=course,
will_recheck_access=recheck_access,
)
student_view_context = request.GET.dict()
student_view_context['show_bookmark_button'] = request.GET.get('show_bookmark_button', '0') == '1'
student_view_context['show_title'] = request.GET.get('show_title', '1') == '1'
is_learning_mfe = is_request_from_learning_mfe(request)
# Right now, we only care about this in regards to the Learning MFE because it results
# in a bad UX if we display blocks with access errors (repeated upgrade messaging).
# If other use cases appear, consider removing the is_learning_mfe check or switching this
# to be its own query parameter that can toggle the behavior.
student_view_context['hide_access_error_blocks'] = is_learning_mfe and recheck_access
is_mobile_app = is_request_from_mobile_app(request)
student_view_context['is_mobile_app'] = is_mobile_app
enable_completion_on_view_service = False
completion_service = block.runtime.service(block, 'completion')
if completion_service and completion_service.completion_tracking_enabled():
if completion_service.blocks_to_mark_complete_on_view({block}):
enable_completion_on_view_service = True
student_view_context['wrap_xblock_data'] = {
'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms()
}
missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user)
# Some content gating happens only at the Sequence level (e.g. "has this
# timed exam started?").
ancestor_sequence_block = enclosing_sequence_for_gating_checks(block)
if ancestor_sequence_block:
context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, course_key)}
# If the SequenceModule feels that gating is necessary, redirect
# there so we can have some kind of error message at any rate.
if ancestor_sequence_block.descendants_are_gated(context):
return redirect(
reverse(
'render_xblock',
kwargs={'usage_key_string': str(ancestor_sequence_block.location)}
)
)
# For courses using an LTI provider managed by edx-exams:
# Access to exam content is determined by edx-exams and passed to the LMS using a
# JWT url param. There is no longer a need for exam gating or logic inside the
# sequence block or its render call. descendants_are_gated shoule not return true
# for these timed exams. Instead, sequences are assumed gated by default and we look for
# an access token on the request to allow rendering to continue.
if course.proctoring_provider == 'lti_external':
seq_block = ancestor_sequence_block if ancestor_sequence_block else block
if getattr(seq_block, 'is_time_limited', None):
if not _check_sequence_exam_access(request, seq_block.location):
return HttpResponseForbidden("Access to exam content is restricted")
context = {
'course': course,
'block': block,
'disable_accordion': True,
'allow_iframing': True,
'disable_header': True,
'disable_footer': True,
'disable_window_wrap': True,
'enable_completion_on_view_service': enable_completion_on_view_service,
'edx_notes_enabled': is_feature_enabled(course, request.user),
'staff_access': staff_access,
'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'),
'missed_deadlines': missed_deadlines,
'missed_gated_content': missed_gated_content,
'has_ended': course.has_ended(),
'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'),
'on_courseware_page': True,
'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course),
'is_learning_mfe': is_learning_mfe,
'is_mobile_app': is_mobile_app,
'render_course_wide_assets': True,
}
try:
# .. filter_implemented_name: RenderXBlockStarted
# .. filter_type: org.openedx.learning.xblock.render.started.v1
context, student_view_context = RenderXBlockStarted.run_filter(
context=context, student_view_context=student_view_context
)
except RenderXBlockStarted.PreventXBlockBlockRender as exc:
log.info("Halted rendering block %s. Reason: %s", usage_key_string, exc.message)
return render_500(request)
except RenderXBlockStarted.RenderCustomResponse as exc:
log.info("Rendering custom exception for block %s. Reason: %s", usage_key_string, exc.message)
context.update({
'fragment': Fragment(exc.response)
})
return render_to_response('courseware/courseware-chromeless.html', context, request=request)
fragment = block.render(requested_view, context=student_view_context)
optimization_flags = get_optimization_flags_for_content(block, fragment)
context.update({
'fragment': Fragment(exc.response)
'fragment': fragment,
**optimization_flags,
})
return render_to_response('courseware/courseware-chromeless.html', context, request=request)
fragment = block.render(requested_view, context=student_view_context)
optimization_flags = get_optimization_flags_for_content(block, fragment)
context.update({
'fragment': fragment,
**optimization_flags,
})
return render_to_response('courseware/courseware-chromeless.html', context, request=request)
def get_optimization_flags_for_content(block, fragment):
"""

View File

@@ -19,6 +19,7 @@ from rest_framework.permissions import IsAuthenticated
from rest_framework.response import Response
from rest_framework.views import APIView
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from xmodule.modulestore.search import path_to_location
@@ -594,30 +595,40 @@ class SequenceMetadata(DeveloperErrorViewMixin, APIView):
usage_key = UsageKey.from_string(usage_key_string)
except InvalidKeyError as exc:
raise NotFound(f"Invalid usage key: '{usage_key_string}'.") from exc
staff_access = has_access(request.user, 'staff', usage_key.course_key)
is_preview = request.GET.get('preview', '0') == '1'
_, request.user = setup_masquerade(
request,
usage_key.course_key,
staff_access=has_access(request.user, 'staff', usage_key.course_key),
staff_access=staff_access,
reset_masquerade_data=True,
)
sequence, _ = get_block_by_usage_id(
self.request,
str(usage_key.course_key),
str(usage_key),
disable_staff_debug_info=True,
will_recheck_access=True)
branch_type = (
ModuleStoreEnum.Branch.draft_preferred
) if is_preview and staff_access else (
ModuleStoreEnum.Branch.published_only
)
if not hasattr(sequence, 'get_metadata'):
# Looks like we were asked for metadata on something that is not a sequence (or section).
return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)
with modulestore().branch_setting(branch_type, usage_key.course_key):
sequence, _ = get_block_by_usage_id(
self.request,
str(usage_key.course_key),
str(usage_key),
disable_staff_debug_info=True,
will_recheck_access=True)
view = STUDENT_VIEW
if request.user.is_anonymous:
view = PUBLIC_VIEW
if not hasattr(sequence, 'get_metadata'):
# Looks like we were asked for metadata on something that is not a sequence (or section).
return Response(status=status.HTTP_422_UNPROCESSABLE_ENTITY)
context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, usage_key.course_key)}
return Response(sequence.get_metadata(view=view, context=context))
view = STUDENT_VIEW
if request.user.is_anonymous:
view = PUBLIC_VIEW
context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, usage_key.course_key)}
return Response(sequence.get_metadata(view=view, context=context))
class Resume(DeveloperErrorViewMixin, APIView):

View File

@@ -12,9 +12,10 @@ from django.http import HttpRequest
from django.http.request import QueryDict
from django.urls import reverse
from opaque_keys.edx.keys import CourseKey, UsageKey
from six.moves.urllib.parse import urlencode, urlparse
from six.moves.urllib.parse import urlencode, urlparse, urlunparse
from lms.djangoapps.courseware.toggles import courseware_mfe_is_active
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.search import navigation_index, path_to_location # lint-amnesty, pylint: disable=wrong-import-order
@@ -24,6 +25,7 @@ User = get_user_model()
def get_courseware_url(
usage_key: UsageKey,
request: Optional[HttpRequest] = None,
is_staff: bool = False,
) -> str:
"""
Return the URL to the canonical learning experience for a given block.
@@ -44,12 +46,13 @@ def get_courseware_url(
get_url_fn = _get_new_courseware_url
else:
get_url_fn = _get_legacy_courseware_url
return get_url_fn(usage_key=usage_key, request=request)
return get_url_fn(usage_key=usage_key, request=request, is_staff=is_staff)
def _get_legacy_courseware_url(
usage_key: UsageKey,
request: Optional[HttpRequest] = None,
is_staff: bool = None
) -> str:
"""
Return the URL to Legacy (LMS-rendered) courseware content.
@@ -90,6 +93,7 @@ def _get_legacy_courseware_url(
def _get_new_courseware_url(
usage_key: UsageKey,
request: Optional[HttpRequest] = None,
is_staff: bool = None,
) -> str:
"""
Return the URL to the "new" (Learning Micro-Frontend) experience for a given block.
@@ -99,7 +103,13 @@ def _get_new_courseware_url(
* NoPathToItem if we cannot build a path to the `usage_key`.
"""
course_key = usage_key.course_key.replace(version_guid=None, branch=None)
path = path_to_location(modulestore(), usage_key, request, full_path=True)
preview = request.GET.get('preview') if request and request.GET else False
branch_type = (
ModuleStoreEnum.Branch.draft_preferred
) if preview and is_staff else ModuleStoreEnum.Branch.published_only
path = path_to_location(modulestore(), usage_key, request, full_path=True, branch_type=branch_type)
if len(path) <= 1:
# Course-run-level block:
# We have no Sequence or Unit to return.
@@ -120,6 +130,7 @@ def _get_new_courseware_url(
course_key=course_key,
sequence_key=sequence_key,
unit_key=unit_key,
preview=preview,
params=request.GET if request and request.GET else None,
)
@@ -129,6 +140,7 @@ def make_learning_mfe_courseware_url(
sequence_key: Optional[UsageKey] = None,
unit_key: Optional[UsageKey] = None,
params: Optional[QueryDict] = None,
preview: bool = None,
) -> str:
"""
Return a str with the URL for the specified courseware content in the Learning MFE.
@@ -159,7 +171,18 @@ def make_learning_mfe_courseware_url(
strings. They're only ever used to concatenate a URL string.
`params` is an optional QueryDict object (e.g. request.GET)
"""
mfe_link = f'{settings.LEARNING_MICROFRONTEND_URL}/course/{course_key}'
mfe_link = f'/course/{course_key}'
get_params = params.copy() if params else None
query_string = ''
if preview:
if len(get_params.keys()) > 1:
get_params.pop('preview')
else:
get_params = None
if (unit_key or sequence_key):
mfe_link = f'/preview/course/{course_key}'
if sequence_key:
mfe_link += f'/{sequence_key}'
@@ -167,10 +190,14 @@ def make_learning_mfe_courseware_url(
if unit_key:
mfe_link += f'/{unit_key}'
if params:
mfe_link += f'?{params.urlencode()}'
if get_params:
query_string = get_params.urlencode()
return mfe_link
url_parts = list(urlparse(settings.LEARNING_MICROFRONTEND_URL))
url_parts[2] = mfe_link
url_parts[4] = query_string
return urlunparse(url_parts)
def get_learning_mfe_home_url(

View File

@@ -12,7 +12,7 @@ from .exceptions import ItemNotFoundError, NoPathToItem
LOGGER = getLogger(__name__)
def path_to_location(modulestore, usage_key, request=None, full_path=False):
def path_to_location(modulestore, usage_key, request=None, full_path=False, branch_type=None):
'''
Try to find a course_id/chapter/section[/position] path to location in
modulestore. The courseware insists that the first level in the course is
@@ -82,46 +82,47 @@ def path_to_location(modulestore, usage_key, request=None, full_path=False):
queue.append((parent, newpath))
with modulestore.bulk_operations(usage_key.course_key):
if not modulestore.has_item(usage_key):
raise ItemNotFoundError(usage_key)
with modulestore.branch_setting(branch_type, usage_key.course_key):
if not modulestore.has_item(usage_key):
raise ItemNotFoundError(usage_key)
path = find_path_to_course()
if path is None:
raise NoPathToItem(usage_key)
path = find_path_to_course()
if path is None:
raise NoPathToItem(usage_key)
if full_path:
return path
if full_path:
return path
n = len(path)
course_id = path[0].course_key
# pull out the location names
chapter = path[1].block_id if n > 1 else None
section = path[2].block_id if n > 2 else None
vertical = path[3].block_id if n > 3 else None
# Figure out the position
position = None
n = len(path)
course_id = path[0].course_key
# pull out the location names
chapter = path[1].block_id if n > 1 else None
section = path[2].block_id if n > 2 else None
vertical = path[3].block_id if n > 3 else None
# Figure out the position
position = None
# This block of code will find the position of a block within a nested tree
# of blocks. If a problem is on tab 2 of a sequence that's on tab 3 of a
# sequence, the resulting position is 3_2. However, no positional blocks
# (e.g. sequential) currently deal with this form of representing nested
# positions. This needs to happen before jumping to a block nested in more
# than one positional block will work.
# This block of code will find the position of a block within a nested tree
# of blocks. If a problem is on tab 2 of a sequence that's on tab 3 of a
# sequence, the resulting position is 3_2. However, no positional blocks
# (e.g. sequential) currently deal with this form of representing nested
# positions. This needs to happen before jumping to a block nested in more
# than one positional block will work.
if n > 3:
position_list = []
for path_index in range(2, n - 1):
category = path[path_index].block_type
if category == 'sequential':
section_desc = modulestore.get_item(path[path_index])
# this calls get_children rather than just children b/c old mongo includes private children
# in children but not in get_children
child_locs = get_child_locations(section_desc, request, course_id)
# positions are 1-indexed, and should be strings to be consistent with
# url parsing.
if path[path_index + 1] in child_locs:
position_list.append(str(child_locs.index(path[path_index + 1]) + 1))
position = "_".join(position_list)
if n > 3:
position_list = []
for path_index in range(2, n - 1):
category = path[path_index].block_type
if category == 'sequential':
section_desc = modulestore.get_item(path[path_index])
# this calls get_children rather than just children b/c old mongo includes private children
# in children but not in get_children
child_locs = get_child_locations(section_desc, request, course_id)
# positions are 1-indexed, and should be strings to be consistent with
# url parsing.
if path[path_index + 1] in child_locs:
position_list.append(str(child_locs.index(path[path_index + 1]) + 1))
position = "_".join(position_list)
return (course_id, chapter, section, vertical, position, path[-1])

View File

@@ -1752,7 +1752,7 @@ class TestMixedModuleStore(CommonMixedModuleStoreSetup):
for location, expected in should_work:
# each iteration has different find count, pop this iter's find count
with check_mongo_calls(num_finds.pop(0), num_sends), self.assertNumQueries(num_mysql.pop(0)):
path = path_to_location(self.store, location)
path = path_to_location(self.store, location, branch_type=ModuleStoreEnum.Branch.published_only)
assert path == expected
not_found = (