Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

XBlock asset support for new libraries experience (WIP) #35557

Closed
wants to merge 8 commits into from
1 change: 0 additions & 1 deletion common/djangoapps/static_replace/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,6 @@ def replace_static_urls(
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:
static_paths_out = []

Expand Down
1 change: 1 addition & 0 deletions common/djangoapps/static_replace/services.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ def replace_urls(self, text, static_replace_only=False):
static_replace_only: If True, only static urls will be replaced
"""
block = self.xblock()

if self.lookup_asset_url:
text = replace_static_urls(text, xblock=block, lookup_asset_url=self.lookup_asset_url)
else:
Expand Down
9 changes: 9 additions & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -92,10 +92,19 @@
),
]

from openedx.core.djangoapps.content_libraries.views import component_version_asset

urlpatterns = [
path('', branding_views.index, name='root'), # Main marketing page, or redirect to courseware

# this is definitely wrong for now, but I need it to test stuff end-to-end.
path(
'library_assets/<uuid:component_version_uuid>/<path:asset_path>',
component_version_asset,
name='library-assets',
),


path('', include('common.djangoapps.student.urls')),
# TODO: Move lms specific student views out of common code
re_path(r'^dashboard/?$', student_views.student_dashboard, name='dashboard'),
Expand Down
32 changes: 31 additions & 1 deletion openedx/core/djangoapps/content_libraries/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@
from django.db import IntegrityError, transaction
from django.db.models import Q, QuerySet
from django.utils.translation import gettext as _
from django.urls import reverse
from edx_rest_api_client.client import OAuthAPIClient
from lxml import etree
from opaque_keys.edx.keys import BlockTypeKey, UsageKey, UsageKeyV2
Expand Down Expand Up @@ -997,7 +998,36 @@ def get_library_block_static_asset_files(usage_key) -> list[LibraryXBlockStaticF
TODO: This is not yet implemented for Learning Core backed libraries.
TODO: Should this be in the general XBlock API rather than the libraries API?
"""
return []
component = get_component_from_usage_key(usage_key)
component_version = component.versioning.draft

# If there is no Draft version, then this was soft-deleted
if component_version is None:
return []

# cvc = the ComponentVersionContent through table
cvc_set = (
component_version
.componentversioncontent_set
.filter(content__has_file=True)
.order_by('key')
.select_related('content')
)

return [
LibraryXBlockStaticFile(
path=cvc.key,
size=cvc.content.size,
url=reverse(
'content_libraries:library-assets',
kwargs={
'component_version_uuid': component_version.uuid,
'asset_path': cvc.key,
}
),
bradenmacdonald marked this conversation as resolved.
Show resolved Hide resolved
)
for cvc in cvc_set
]


def add_library_block_static_asset_file(usage_key, file_name, file_content) -> LibraryXBlockStaticFile:
Expand Down
5 changes: 5 additions & 0 deletions openedx/core/djangoapps/content_libraries/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -73,4 +73,9 @@
path('pub/jwks/', views.LtiToolJwksView.as_view(), name='lti-pub-jwks'),
])),
])),
path(
'library_assets/<uuid:component_version_uuid>/<path:asset_path>',
views.component_version_asset,
name='library-assets',
),
]
72 changes: 71 additions & 1 deletion openedx/core/djangoapps/content_libraries/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,20 +72,22 @@
from django.contrib.auth import authenticate, get_user_model, login
from django.contrib.auth.models import Group
from django.db.transaction import atomic, non_atomic_requests
from django.http import Http404, HttpResponseBadRequest, JsonResponse
from django.http import Http404, HttpResponse, HttpResponseBadRequest, JsonResponse, StreamingHttpResponse
from django.shortcuts import get_object_or_404
from django.urls import reverse
from django.utils.decorators import method_decorator
from django.utils.translation import gettext as _
from django.views.decorators.clickjacking import xframe_options_exempt
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_safe
from django.views.generic.base import TemplateResponseMixin, View
from pylti1p3.contrib.django import DjangoCacheDataStorage, DjangoDbToolConf, DjangoMessageLaunch, DjangoOIDCLogin
from pylti1p3.exception import LtiException, OIDCException

import edx_api_doc_tools as apidocs
from opaque_keys import InvalidKeyError
from opaque_keys.edx.locator import LibraryLocatorV2, LibraryUsageLocatorV2
from openedx_learning.api import authoring as authoring_api
from organizations.api import ensure_organization
from organizations.exceptions import InvalidOrganizationException
from organizations.models import Organization
Expand Down Expand Up @@ -1107,3 +1109,71 @@ def get(self, request):
Return the JWKS.
"""
return JsonResponse(self.lti_tool_config.get_jwks(), safe=False)


@require_safe
def component_version_asset(request, component_version_uuid, asset_path):
"""
Serves static assets associated with particular Component versions.

Important notes:

* This is meant for Studio/authoring use ONLY. It requires read access to
the content library.
* It uses the UUID because that's easier to parse than the key field (which
could be part of an OpaqueKey, but could also be almost anything else).
* This is not very performant, and we still want to use the X-Accel-Redirect
method for serving LMS traffic in the longer term (and probably Studio
eventually).
"""
try:
component_version = authoring_api.get_component_version_by_uuid(
component_version_uuid
)
except ObjectDoesNotExist:
raise Http404()

learning_package = component_version.component.learning_package
library_key = LibraryLocatorV2.from_string(learning_package.key)

api.require_permission_for_library_key(
library_key, request.user, permissions.CAN_VIEW_THIS_CONTENT_LIBRARY,
)

# We already have logic for getting the correct content and generating the
# proper headers in Learning Core, but the response generated here is an
# X-Accel-Redirect and lacks the actual content. We eventually want to use
# this response in conjunction with a media reverse proxy (Caddy or Nginx),
# but in the short term we're just going to remove the redirect and stream
# the content directly.
redirect_response = authoring_api.get_redirect_response_for_component_asset(
component_version_uuid,
asset_path,
public=False,
learner_downloadable_only=False,
)

# If there was any error, we return that response because it will have the
# correct headers set and won't have any X-Accel-Redirect header set.
if redirect_response.status_code != 200:
return redirect_response

cv_content = component_version.componentversioncontent_set.get(key=asset_path)
content = cv_content.content

# Delete the re-direct part of the response headers. We'll copy the rest.
headers = redirect_response.headers
headers.pop('X-Accel-Redirect')

# We need to set the content size header manually because this is a
# streaming response. It's not included in the redirect headers because it's
# not needed there (the reverse-proxy would have direct access to the file).
headers['Content-Length'] = content.size

if request.method == "HEAD":
return HttpResponse(headers=headers)

return StreamingHttpResponse(
content.read_file().chunks(),
headers=redirect_response.headers,
)
80 changes: 78 additions & 2 deletions openedx/core/djangoapps/xblock/runtime/learning_core_runtime.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

from django.core.exceptions import ObjectDoesNotExist
from django.db.transaction import atomic
from django.urls import reverse

from openedx_learning.api import authoring as authoring_api

Expand All @@ -20,6 +21,7 @@
from xblock.field_data import FieldData

from openedx.core.lib.xblock_serializer.api import serialize_modulestore_block_for_learning_core
from openedx.core.lib.xblock_serializer.data import StaticFile
from ..learning_context.manager import get_learning_context_impl
from .runtime import XBlockRuntime

Expand Down Expand Up @@ -215,6 +217,42 @@ def get_block(self, usage_key, for_parent=None):

return block

def get_block_assets(self, usage_key):
"""
This currently doesn't copy any
"""
component = self._get_component_from_usage_key(usage_key)
component_version = component.versioning.draft

# If there is no Draft version, then this was soft-deleted
if component_version is None:
return []

# cvc = the ComponentVersionContent through table
cvc_set = (
component_version
.componentversioncontent_set
.filter(content__has_file=True)
.order_by('key')
.select_related('content')
)

return [
StaticFile(
name=cvc.key,
url=None,
# url=reverse(
# 'content_libraries:library-assets',
# kwargs={
# 'component_version_uuid': component_version.uuid,
# 'asset_path': cvc.key,
# }
# ),
data=cvc.content.read_file().read()
)
for cvc in cvc_set
]

def save_block(self, block):
"""
Save any pending field data values to Learning Core data models.
Expand Down Expand Up @@ -291,6 +329,44 @@ def _lookup_asset_url(self, block: XBlock, asset_path: str) -> str | None: # py

This is called by the XBlockRuntime superclass in the .runtime module.

TODO: Implement as part of larger static asset effort.
TODO: Like get_block, we currently assume that we're using the Draft
version. This should be a runtime parameter.
Comment on lines +328 to +329
Copy link
Contributor

Choose a reason for hiding this comment

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

You probably know this, but here in apps.py is where we can set runtime parameters like "studio should default to DRAFT" and "LMS should default to PUBLISHED".

It would be very cool if we could later override this default using a context manager when needed, but I don't know if that's a safe way to implement such an override.

Copy link
Contributor Author

@ormsbee ormsbee Sep 28, 2024

Choose a reason for hiding this comment

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

Is it sufficient to do Draft vs. Published, or should we be able to do any stored version (for the sake of seeing what changed)? (I was assuming we'd eventually need the latter.)

Copy link
Contributor

Choose a reason for hiding this comment

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

Being able to see any stored version would be great, so I think we should do that. But more often I'll just want to specify "latest draft" or "latest published" so I hope we can have both options.

"""
return None
usage_key = block.scope_ids.usage_id
component = self._get_component_from_usage_key(usage_key)
component_version = component.versioning.draft
if component_version is None:
# This could happen if a Component was soft-deleted.
raise NoSuchUsage(usage_key)

return reverse(
'library-assets',
kwargs={
'component_version_uuid': component_version.uuid,

# The standard XBlock "static/{asset_path}"" substitution
# strips out the leading "static/" part because it assumes that
# all files will exist in a flat namespace. Learning Core treats
# directories differently, and there may one day be other files
# that are not meant to be externally downloadable at the root
# (e.g. LaTeX source files or graders).
#
# So the transformation looks like this:
#
# 1. The Learning Core ComponentVersion has an asset stored as
# "static/test.png".
# 1. The original OLX content we store references
# "/static/test.png" or "static/test.png".
# 2. The ReplaceURLService XBlock runtime service invokes
# static_replace and strips out "/static/".
# 3. The method we're in now is invoked with a static_path of
# just "test.png", because that's the transformation that
# works for ModuleStore-based courses.
# 4. This method then builds a URL that adds the "static/"
# prefix back when creating the URL.
#
# Which is a very long explanation for why we're re-adding the
# "static/" to the asset path below.
'asset_path': f"static/{asset_path}",
}
)
16 changes: 16 additions & 0 deletions openedx/core/lib/xblock_serializer/block_serializer.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,25 @@ def __init__(self, block):
self.olx_str = etree.tostring(olx_node, encoding="unicode", pretty_print=True)

course_key = self.orig_block_key.course_key

# Search the OLX for references to files stored in the course's
# "Files & Uploads" (contentstore):
self.olx_str = utils.rewrite_absolute_static_urls(self.olx_str, course_key)

# If a block supports explicit assets, there's no need to scan the
# content looking for static assets that it *might* be using. Learning
# Core backed content supports this, which currently means v2 Content
# Libraries.
runtime_supports_explicit_assets = hasattr(block.runtime, 'get_block_assets')
if runtime_supports_explicit_assets:
self.static_files.extend(
block.runtime.get_block_assets(block.scope_ids.usage_id)
)
print(f"static_files: {self.static_files}")
return

# Otherwise, we have to scan the content to extract associated asset
# files that are stored in the course-global Files and Uploads.
for asset in utils.collect_assets_from_text(self.olx_str, course_key):
path = asset['path']
if path not in [sf.name for sf in self.static_files]:
Expand Down
Loading