Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 6 additions & 4 deletions cms/djangoapps/contentstore/views/preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,15 +28,15 @@
from xmodule.x_module import AUTHOR_VIEW, PREVIEW_VIEWS, STUDENT_VIEW, ModuleSystem, XModule, XModuleDescriptor
from cms.djangoapps.xblock_config.models import StudioConfig
from cms.lib.xblock.field_data import CmsFieldData
from common.djangoapps import static_replace
from common.djangoapps.static_replace.services import ReplaceURLService
from common.djangoapps.static_replace.wrapper import replace_urls_wrapper
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.edxmako.services import MakoService
from common.djangoapps.xblock_django.user_service import DjangoXBlockUserService
from lms.djangoapps.lms_xblock.field_data import LmsFieldData
from openedx.core.lib.license import wrap_with_license
from openedx.core.lib.cache_utils import CacheService
from openedx.core.lib.xblock_utils import (
replace_static_urls,
request_token,
wrap_fragment,
wrap_xblock,
Expand Down Expand Up @@ -162,6 +162,8 @@ def _preview_module_system(request, descriptor, field_data):
course_id = descriptor.location.course_key
display_name_only = (descriptor.category == 'static_tab')

replace_url_service = ReplaceURLService(course_id=course_id)

wrappers = [
# This wrapper wraps the module in the template specified above
partial(
Expand All @@ -174,7 +176,7 @@ def _preview_module_system(request, descriptor, field_data):

# This wrapper replaces urls in the output that start with /static
# with the correct course-specific url for the static content
partial(replace_static_urls, None, course_id=course_id),
partial(replace_urls_wrapper, replace_url_service=replace_url_service, static_replace_only=True),
_studio_wrap_xblock,
]

Expand All @@ -199,7 +201,6 @@ def _preview_module_system(request, descriptor, field_data):
filestore=descriptor.runtime.resources_fs,
get_module=partial(_load_preview_module, request),
debug=True,
replace_urls=partial(static_replace.replace_static_urls, data_directory=None, course_id=course_id),
mixins=settings.XBLOCK_MIXINS,
course_id=course_id,

Expand All @@ -223,6 +224,7 @@ def _preview_module_system(request, descriptor, field_data):
"teams_configuration": TeamsConfigurationService(),
"sandbox": SandboxService(contentstore=contentstore, course_id=course_id),
"cache": CacheService(cache),
'replace_urls': replace_url_service
},
)

Expand Down
9 changes: 8 additions & 1 deletion cms/djangoapps/contentstore/views/tests/test_preview.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
from xmodule.modulestore.tests.test_asides import AsideTestType
from cms.djangoapps.contentstore.utils import reverse_usage_url
from cms.djangoapps.xblock_config.models import StudioConfig
from common.djangoapps import static_replace
from common.djangoapps.student.tests.factories import UserFactory

from ..preview import _preview_module_system, get_preview_fragment
Expand Down Expand Up @@ -174,6 +175,7 @@ def test_block_branch_not_changed_by_preview_handler(self, default_store):
@XBlock.needs("field-data")
@XBlock.needs("i18n")
@XBlock.needs("mako")
@XBlock.needs("replace_urls")
@XBlock.needs("user")
@XBlock.needs("teams_configuration")
class PureXBlock(XBlock):
Expand Down Expand Up @@ -205,7 +207,7 @@ def setUp(self):
self.field_data = mock.Mock()

@XBlock.register_temp_plugin(PureXBlock, identifier='pure')
@ddt.data("user", "i18n", "field-data", "teams_configuration")
@ddt.data("user", "i18n", "field-data", "teams_configuration", "replace_urls")
def test_expected_services_exist(self, expected_service):
"""
Tests that the 'user' and 'i18n' services are provided by the Studio runtime.
Expand Down Expand Up @@ -287,3 +289,8 @@ def test_no_get_python_lib_zip(self):
def test_cache(self):
assert hasattr(self.runtime.cache, 'get')
assert hasattr(self.runtime.cache, 'set')

def test_replace_urls(self):
html = '<a href="/static/id">'
assert self.runtime.replace_urls(html) == \
static_replace.replace_static_urls(html, course_id=self.runtime.course_id)
18 changes: 16 additions & 2 deletions common/djangoapps/static_replace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -147,12 +147,20 @@ def replace(__, prefix, quote, rest):
)


def replace_static_urls(text, data_directory=None, course_id=None, static_asset_path='', static_paths_out=None):
def replace_static_urls(
text,
data_directory=None,
course_id=None,
static_asset_path='',
static_paths_out=None,
xblock=None,
lookup_asset_url=None
):
"""
Replace /static/$stuff urls either with their correct url as generated by collectstatic,
(/static/$md5_hashed_stuff) or by the course-specific content static url
/static/$course_data_dir/$stuff, or, if course_namespace is not None, by the
correct url in the contentstore (/c4x/.. or /asset-loc:..)
correct url in the contentstore (/c4x/.. or /asset-loc:..) or by lookup_asset_url

text: The source text to do the substitution in
data_directory: The directory in which course data is stored
Expand All @@ -161,6 +169,8 @@ def replace_static_urls(text, data_directory=None, course_id=None, static_asset_
static_paths_out: (optional) pass an array to collect tuples for each static URI found:
* the original unmodified static URI
* the updated static URI (will match the original if unchanged)
xblock: xblock where the static assets are stored
lookup_url_func: Lookup function which returns the correct path of the asset
"""

if static_paths_out is None:
Expand All @@ -176,6 +186,10 @@ def replace_static_url(original, prefix, quote, rest):
static_paths_out.append((original_uri, original_uri))
return original

if lookup_asset_url:
new_url = lookup_asset_url(xblock, rest) or original_uri
return "".join([quote, new_url, quote])

# In debug mode, if we can find the url as is,
if settings.DEBUG and finders.find(rest, True):
static_paths_out.append((original_uri, original_uri))
Expand Down
69 changes: 69 additions & 0 deletions common/djangoapps/static_replace/services.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
"""
Supports replacement of static/course/jump-to-id URLs to absolute URLs in XBlocks.
"""

from xblock.reference.plugins import Service

from common.djangoapps.static_replace import (
replace_course_urls,
replace_jump_to_id_urls,
replace_static_urls
)


class ReplaceURLService(Service):
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Given that we're adding this service, should we also deprecate these methods?
cc: @bradenmacdonald, as I'm not sure if these aren't used by the blockstore.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

They're not called by blockstore but they are called by XBlocks, so yes.

@kaustavb12 Please make the same changes to https://github.com/openedx/edx-platform/blob/25b275bca4aee8099ad015648efa8551b272e11a/openedx/core/djangoapps/xblock/runtime/shims.py#L179-L219 - make them print a deprecation warning and call the new service instead.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald @Agrendalath

These methods are part of the RuntimeShim class which is used with the XBlockRuntime class.
The XBlockRuntime class is a super class of the BlockstoreXBlockRuntime class

As I understand, blockstore Xblocks are not attached to courses, and so they don't have course id associated with them.

In that case would having replace_course_urls() and replace_jump_to_id_urls() methods in this shim necessary, since replacing both course urls and jump-to-id urls makes use of course ids.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@kaustavb12

As I understand, blockstore Xblocks are not attached to courses, and so they don't have course id associated with them.

That's true, and replace_course_urls is not needed for that reason. (At least not yet; blockstore will support courses in the future!)

But: blockstore XBlocks still use static files and they still need to have their /static/... asset paths rewritten. The difference is that in Blockstore, static assets are stored with the XBlock not with the course.

I actually created a new API for this, self.runtime.transform_static_paths_to_urls:

https://github.com/openedx/edx-platform/blob/4f58ed4f25d6e13250583504df08aa2ef6561fb7/openedx/core/djangoapps/xblock/runtime/runtime.py#L338-L354

And if you look at shims.py, you can see that the old APIs are just wrappers around the new API.

So, if you want this service to be the new API, you'll have to implement it for the blockstore runtime as well, and make sure that when it's called in the blockstore runtime it uses self.runtime.transform_static_paths_to_urls to do the rewriting, which will correctly rewrite the URLs to refer to assets from the XBlock's "bundle".


The easiest way to see what I'm talking about would be to set up blockstore yourself, e.g. following these instructions and then create within blockstore an HTML block, and upload an image to the HTML block, then set the image src to /static/image.png and make sure it's working.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let me know if you want more help!

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald, I'm copying the message from @kaustavb12 here.

I have used the ReplaceURLService directly here instead of adding the service in the service() method. This is because, for invoking the service from this method we need to add a declaration for each Xblock in the form of @XBlock.needs('mako'). However, I couldn't figure out the correct classes for xblocks like Completion, Drag and Drop, Feedback, Survey, etc.

If you think the correct implementation should have been through the service() method, then I will look into in further later in the sprint.

As I don't know what this change means for blockstore, I could use a second pair of eyes to check the approach from c1c323ce08ac200955e921798039a014688bb264 when you have a moment. For now, we don't need to use the attributes of ReplaceURLService here, so we could make ReplaceURLService.replace_block_urls a static method to start using it as an API. If we are going to support courses with blockstore in the future, then we could expand the service to support them, and merge replace_static_block_urls with replace_static_urls within static_replace/__init__.py. Would it make sense?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@Agrendalath Sure, I left my comments on c1c323ce08ac200955e921798039a014688bb264

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald
Thanks a lot pointing me to the instructions to setup blockstore in devstack and for reviewing the changes. It really helped me gain a lot of context about xblocks and the blockstore runtime.

I have now incorporated all your suggestions, and now there is only one API in ReplaceURLService which can be called by XBlocks as needed in the form of self.runtime.service(self, 'replace_urls').replace_urls(text)

Copy link
Contributor Author

@kaustavb12 kaustavb12 Mar 5, 2022

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@bradenmacdonald

Also I have gone ahead and merged the new replace_static_block_urls function with the replace_static_urls function in static_replace/init.py similar to what was done for ReplaceURLService to avoid code duplication

cc. @Agrendalath

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks @kaustavb12, those changes look great :)

"""
A service for replacing static/course/jump-to-id URLs with absolute URLs in XBlocks.

Args:
course_id: Course identifier to be used in the absolute URL
data_directory: (optional) Directory in which course data is stored
static_asset_path: (optional) Path for static assets, which overrides data_directory and course_id, if nonempty
static_paths_out: (optional) Array to collect tuples for each static URI found:
* the original unmodified static URI
* the updated static URI (will match the original if unchanged)
jump_to_id_base_url: (optional) Absolute path to the base of the handler that will perform the redirect
lookup_url_func: Lookup function which returns the correct path of the asset
"""
def __init__(
self,
data_directory=None,
course_id=None,
static_asset_path='',
static_paths_out=None,
jump_to_id_base_url=None,
lookup_asset_url=None,
**kwargs
):
super().__init__(**kwargs)
self.data_directory = data_directory
self.course_id = course_id
self.static_asset_path = static_asset_path
self.static_paths_out = static_paths_out
self.jump_to_id_base_url = jump_to_id_base_url
self.lookup_asset_url = lookup_asset_url

def replace_urls(self, text, static_replace_only=False):
"""
Replaces all static/course/jump-to-id URLs in provided text/html.

Args:
text: String containing the URL to be replaced
static_replace_only: If True, only static urls will be replaced
"""
if self.lookup_asset_url:
text = replace_static_urls(text, xblock=self.xblock(), lookup_asset_url=self.lookup_asset_url)
else:
text = replace_static_urls(
text,
data_directory=self.data_directory,
course_id=self.course_id,
static_asset_path=self.static_asset_path,
static_paths_out=self.static_paths_out
)
if not static_replace_only:
text = replace_course_urls(text, self.course_id)
if self.jump_to_id_base_url:
text = replace_jump_to_id_urls(text, self.course_id, self.jump_to_id_base_url)

return text
144 changes: 144 additions & 0 deletions common/djangoapps/static_replace/test/test_static_replace.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

import re
from io import BytesIO
from unittest import TestCase
from unittest.mock import Mock, patch
from urllib.parse import parse_qsl, quote, urlparse, urlunparse, urlencode

Expand All @@ -11,6 +12,7 @@
from django.test import override_settings
from opaque_keys.edx.keys import CourseKey
from PIL import Image
from web_fragments.fragment import Fragment

from common.djangoapps.static_replace import (
_url_replace_regex,
Expand All @@ -19,6 +21,8 @@
replace_course_urls,
replace_static_urls
)
from common.djangoapps.static_replace.services import ReplaceURLService
from common.djangoapps.static_replace.wrapper import replace_urls_wrapper
from xmodule.assetstore.assetmgr import AssetManager # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.contentstore.content import StaticContent # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.contentstore.django import contentstore # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -783,3 +787,143 @@ def test_canonical_asset_path_with_c4x_style_assets(self, base_url, start, expec
with check_mongo_calls(mongo_calls):
asset_path = StaticContent.get_canonicalized_asset_path(self.courses[prefix].id, start, base_url, exts)
assert re.match(expected, asset_path) is not None


class ReplaceURLServiceTest(TestCase):
"""
Test ReplaceURLService methods
"""
def setUp(self):
super().setUp()
self.mock_replace_static_urls = self.create_patch(
'common.djangoapps.static_replace.services.replace_static_urls'
)
self.mock_replace_course_urls = self.create_patch(
'common.djangoapps.static_replace.services.replace_course_urls'
)
self.mock_replace_jump_to_id_urls = self.create_patch(
'common.djangoapps.static_replace.services.replace_jump_to_id_urls'
)

def create_patch(self, name):
patcher = patch(name)
mock_method = patcher.start()
self.addCleanup(patcher.stop)
return mock_method

def test_replace_static_url_only(self):
"""
Test only replace_static_urls method called when static_replace_only is passed as True.
"""
replace_url_service = ReplaceURLService(course_id=COURSE_KEY)
return_text = replace_url_service.replace_urls("text", static_replace_only=True)
assert self.mock_replace_static_urls.called
assert not self.mock_replace_course_urls.called
assert not self.mock_replace_jump_to_id_urls.called

def test_replace_course_urls_called(self):
"""
Test replace_course_urls method called static_replace_only is passed as False.
"""
replace_url_service = ReplaceURLService(course_id=COURSE_KEY)
return_text = replace_url_service.replace_urls("text")
assert self.mock_replace_course_urls.called

def test_replace_jump_to_id_urls_called(self):
"""
Test replace_jump_to_id_urls method called jump_to_id_base_url is provided.
"""
replace_url_service = ReplaceURLService(course_id=COURSE_KEY, jump_to_id_base_url="/course/course_id")
return_text = replace_url_service.replace_urls("text")
assert self.mock_replace_jump_to_id_urls.called

def test_replace_jump_to_id_urls_not_called(self):
"""
Test replace_jump_to_id_urls method called jump_to_id_base_url is not provided.
"""
replace_url_service = ReplaceURLService(course_id=COURSE_KEY)
return_text = replace_url_service.replace_urls("text")
assert not self.mock_replace_jump_to_id_urls.called


@ddt.ddt
class TestReplaceURLWrapper(SharedModuleStoreTestCase):
"""
Tests for replace_url_wrapper utility function.
"""

@classmethod
def setUpClass(cls):
super().setUpClass()
cls.course_mongo = CourseFactory.create(
default_store=ModuleStoreEnum.Type.mongo,
org='TestX',
number='TS01',
run='2015'
)
cls.course_split = CourseFactory.create(
default_store=ModuleStoreEnum.Type.split,
org='TestX',
number='TS02',
run='2015'
)

@ddt.data('course_mongo', 'course_split')
def test_replace_jump_to_id_urls(self, course_id):
"""
Verify that the jump-to URL has been replaced.
"""
course = getattr(self, course_id)
replace_url_service = ReplaceURLService(course_id=course.id, jump_to_id_base_url='/base_url/')
test_replace = replace_urls_wrapper(
block=course,
view='baseview',
frag=Fragment('<a href="/jump_to_id/id">'),
context=None,
replace_url_service=replace_url_service
)
assert isinstance(test_replace, Fragment)
assert test_replace.content == '<a href="/base_url/id">'

@ddt.data(
('course_mongo', '<a href="/courses/TestX/TS01/2015/id">'),
('course_split', '<a href="/courses/course-v1:TestX+TS02+2015/id">')
)
@ddt.unpack
def test_replace_course_urls(self, course_id, anchor_tag):
"""
Verify that the course URL has been replaced.
"""
course = getattr(self, course_id)
replace_url_service = ReplaceURLService(course_id=course.id)
test_replace = replace_urls_wrapper(
block=course,
view='baseview',
frag=Fragment('<a href="/course/id">'),
context=None,
replace_url_service=replace_url_service
)
assert isinstance(test_replace, Fragment)
assert test_replace.content == anchor_tag

@ddt.data(
('course_mongo', '<a href="/c4x/TestX/TS01/asset/id">'),
('course_split', '<a href="/asset-v1:TestX+TS02+2015+type@asset+block/id">')
)
@ddt.unpack
def test_replace_static_urls(self, course_id, anchor_tag):
"""
Verify that the static URL has been replaced.
"""
course = getattr(self, course_id)
replace_url_service = ReplaceURLService(course_id=course.id)
test_replace = replace_urls_wrapper(
block=course,
view='baseview',
frag=Fragment('<a href="/static/id">'),
context=None,
replace_url_service=replace_url_service,
static_replace_only=True
)
assert isinstance(test_replace, Fragment)
assert test_replace.content == anchor_tag
12 changes: 12 additions & 0 deletions common/djangoapps/static_replace/wrapper.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
"""
Wrapper function to replace static/course/jump-to-id URLs in XBlock to absolute URLs
"""

from openedx.core.lib.xblock_utils import wrap_fragment


def replace_urls_wrapper(block, view, frag, context, replace_url_service, static_replace_only=False): # pylint: disable=unused-argument
"""
Replace any static/course/jump-to-id URLs in XBlock to absolute URLs
"""
return wrap_fragment(frag, replace_url_service.replace_urls(frag.content, static_replace_only))
Loading