forked from openedx/edx-platform
-
Notifications
You must be signed in to change notification settings - Fork 14
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
feat: [AXM-349] Implement media generation for problem xblock #2568
Merged
GlugovGrGlib
merged 7 commits into
NiedielnitsevIvan/AXM-542/feature/Render-xblock-in-CMS
from
NiedielnitsevIvan/AXM-349/feature/Implement-media-generation-for-problem-xblock
Jun 11, 2024
Merged
Changes from all commits
Commits
Show all changes
7 commits
Select commit
Hold shift + click to select a range
3811290
feat: [AXM-349] Implement media generation for problem xblock
NiedielnitsevIvan dfb7e6b
feat: [AXM-349] refactor offline content generation
NiedielnitsevIvan 98249ee
style: [AXM-349] fix style issues
NiedielnitsevIvan 4caafac
refactor: [AXM-349] move MathJax to assets
NiedielnitsevIvan 0350649
fix: [AXM-349] fix wrong filename
NiedielnitsevIvan d9a6a05
refactor: [AXM-349] refactor generated file pathes and generating tas…
NiedielnitsevIvan 450e04a
style: [AXM-349] remove unused import
NiedielnitsevIvan File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
Empty file.
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,162 @@ | ||
""" | ||
This module contains utility functions for managing assets and files. | ||
""" | ||
import shutil | ||
import logging | ||
import os | ||
import requests | ||
|
||
from django.conf import settings | ||
from django.core.files.base import ContentFile | ||
from django.core.files.storage import default_storage | ||
|
||
from xmodule.assetstore.assetmgr import AssetManager | ||
from xmodule.contentstore.content import StaticContent | ||
from xmodule.exceptions import NotFoundError | ||
from xmodule.modulestore.exceptions import ItemNotFoundError | ||
|
||
from .constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH | ||
|
||
|
||
log = logging.getLogger(__name__) | ||
|
||
|
||
def get_static_file_path(relative_path): | ||
""" | ||
Constructs the absolute path for a static file based on its relative path. | ||
""" | ||
base_path = settings.STATIC_ROOT | ||
return os.path.join(base_path, relative_path) | ||
|
||
|
||
def read_static_file(path): | ||
""" | ||
Reads the contents of a static file in binary mode. | ||
""" | ||
with open(path, 'rb') as file: | ||
return file.read() | ||
|
||
|
||
def save_asset_file(xblock, path, filename): | ||
""" | ||
Saves an asset file to the default storage. | ||
|
||
If the filename contains a '/', it reads the static file directly from the file system. | ||
Otherwise, it fetches the asset from the AssetManager. | ||
Args: | ||
xblock (XBlock): The XBlock instance | ||
path (str): The path where the asset is located. | ||
filename (str): The name of the file to be saved. | ||
""" | ||
if filename.endswith('djangojs.js'): | ||
return | ||
|
||
try: | ||
if '/' in filename: | ||
static_path = get_static_file_path(filename) | ||
content = read_static_file(static_path) | ||
else: | ||
asset_key = StaticContent.get_asset_key_from_path(xblock.location.course_key, path) | ||
content = AssetManager.find(asset_key).data | ||
except (ItemNotFoundError, NotFoundError): | ||
log.info(f"Asset not found: {filename}") | ||
|
||
else: | ||
base_path = block_storage_path(xblock) | ||
file_path = os.path.join(base_path, 'assets', filename) | ||
default_storage.save(file_path, ContentFile(content)) | ||
|
||
|
||
def remove_old_files(xblock): | ||
""" | ||
Removes the 'assets' directory, 'index.html', and 'offline_content.zip' files | ||
in the specified base path directory. | ||
Args: | ||
(XBlock): The XBlock instance | ||
""" | ||
try: | ||
base_path = block_storage_path(xblock) | ||
assets_path = os.path.join(base_path, 'assets') | ||
index_file_path = os.path.join(base_path, 'index.html') | ||
offline_zip_path = os.path.join(base_path, f'{xblock.location.block_id}.zip') | ||
|
||
# Delete the 'assets' directory if it exists | ||
if os.path.isdir(assets_path): | ||
shutil.rmtree(assets_path) | ||
log.info(f"Successfully deleted the directory: {assets_path}") | ||
|
||
# Delete the 'index.html' file if it exists | ||
if default_storage.exists(index_file_path): | ||
os.remove(index_file_path) | ||
log.info(f"Successfully deleted the file: {index_file_path}") | ||
|
||
# Delete the 'offline_content.zip' file if it exists | ||
if default_storage.exists(offline_zip_path): | ||
os.remove(offline_zip_path) | ||
log.info(f"Successfully deleted the file: {offline_zip_path}") | ||
|
||
except OSError as e: | ||
log.error(f"Error occurred while deleting the files or directory: {e}") | ||
|
||
|
||
def get_offline_block_content_path(xblock=None, usage_key=None): | ||
""" | ||
Checks whether 'offline_content.zip' file is present in the specified base path directory. | ||
|
||
Args: | ||
xblock (XBlock): The XBlock instance | ||
usage_key (UsageKey): The UsageKey of the XBlock | ||
Returns: | ||
bool: True if the file is present, False otherwise | ||
""" | ||
usage_key = usage_key or getattr(xblock, 'location', None) | ||
base_path = block_storage_path(usage_key=usage_key) | ||
offline_zip_path = os.path.join(base_path, f'{usage_key.block_id}.zip') | ||
return offline_zip_path if default_storage.exists(offline_zip_path) else None | ||
|
||
|
||
def block_storage_path(xblock=None, usage_key=None): | ||
""" | ||
Generates the base storage path for the given XBlock. | ||
|
||
The path is constructed based on the XBlock's location, which includes the organization, | ||
course, block type, and block ID. | ||
Args: | ||
xblock (XBlock): The XBlock instance for which to generate the storage path. | ||
usage_key (UsageKey): The UsageKey of the XBlock. | ||
Returns: | ||
str: The constructed base storage path. | ||
""" | ||
loc = usage_key or getattr(xblock, 'location', None) | ||
return f'{str(loc.course_key)}/{loc.block_id}/' if loc else '' | ||
|
||
|
||
def is_modified(xblock): | ||
""" | ||
Check if the xblock has been modified since the last time the offline content was generated. | ||
|
||
Args: | ||
xblock (XBlock): The XBlock instance to check. | ||
""" | ||
file_path = os.path.join(block_storage_path(xblock), f'{xblock.location.block_id}.zip') | ||
|
||
try: | ||
last_modified = default_storage.get_created_time(file_path) | ||
except OSError: | ||
return True | ||
|
||
return xblock.published_on > last_modified | ||
|
||
|
||
def save_mathjax_to_xblock_assets(xblock): | ||
""" | ||
Saves MathJax to the local static directory. | ||
|
||
If MathJax is not already saved, it fetches MathJax from | ||
the CDN and saves it to the local static directory. | ||
""" | ||
file_path = os.path.join(block_storage_path(xblock), MATHJAX_STATIC_PATH) | ||
if not default_storage.exists(file_path): | ||
response = requests.get(MATHJAX_CDN_URL) | ||
default_storage.save(file_path, ContentFile(response.content)) | ||
log.info(f"Successfully saved MathJax to {file_path}") |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,13 @@ | ||
""" | ||
Constants for offline mode app. | ||
""" | ||
import os | ||
|
||
from django.conf import settings | ||
|
||
MATHJAX_VERSION = '2.7.5' | ||
MATHJAX_CDN_URL = f'https://cdn.jsdelivr.net/npm/mathjax@{MATHJAX_VERSION}/MathJax.js' | ||
MATHJAX_STATIC_PATH = os.path.join('assets', 'js', f'MathJax-{MATHJAX_VERSION}.js') | ||
|
||
DEFAULT_OFFLINE_SUPPORTED_XBLOCKS = ['problem'] | ||
OFFLINE_SUPPORTED_XBLOCKS = getattr(settings, 'OFFLINE_SUPPORTED_XBLOCKS', DEFAULT_OFFLINE_SUPPORTED_XBLOCKS) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,90 @@ | ||
""" | ||
Module to prepare HTML content for offline use. | ||
""" | ||
import os | ||
import re | ||
|
||
from bs4 import BeautifulSoup | ||
|
||
from django.conf import settings | ||
|
||
from .assets_management import save_asset_file, save_mathjax_to_xblock_assets | ||
from .constants import MATHJAX_CDN_URL, MATHJAX_STATIC_PATH | ||
|
||
|
||
class HtmlManipulator: | ||
""" | ||
Class to prepare HTML content for offline use. | ||
|
||
Changes links to static files to paths to pre-generated static files for offline use. | ||
""" | ||
|
||
def __init__(self, xblock, html_data): | ||
self.html_data = html_data | ||
self.xblock = xblock | ||
|
||
def _replace_mathjax_link(self): | ||
""" | ||
Replace MathJax CDN link with local path to MathJax.js file. | ||
""" | ||
save_mathjax_to_xblock_assets(self.xblock) | ||
mathjax_pattern = re.compile(fr'src="{MATHJAX_CDN_URL}[^"]*"') | ||
self.html_data = mathjax_pattern.sub(f'src="{MATHJAX_STATIC_PATH}"', self.html_data) | ||
|
||
def _replace_static_links(self): | ||
""" | ||
Replace static links with local links. | ||
""" | ||
static_links_pattern = os.path.join(settings.STATIC_URL, r'[\w./-]+') | ||
pattern = re.compile(fr'{static_links_pattern}') | ||
self.html_data = pattern.sub(self._replace_link, self.html_data) | ||
|
||
def _replace_link(self, match): | ||
""" | ||
Returns the local path of the asset file. | ||
""" | ||
link = match.group() | ||
filename = link.split(settings.STATIC_URL)[-1] | ||
save_asset_file(self.xblock, link, filename) | ||
return f'assets/{filename}' | ||
|
||
@staticmethod | ||
def _replace_iframe(soup): | ||
""" | ||
Replace iframe tags with anchor tags. | ||
""" | ||
for node in soup.find_all('iframe'): | ||
replacement = soup.new_tag('p') | ||
tag_a = soup.new_tag('a') | ||
tag_a['href'] = node.get('src') | ||
tag_a.string = node.get('title', node.get('src')) | ||
replacement.append(tag_a) | ||
node.replace_with(replacement) | ||
|
||
@staticmethod | ||
def _add_js_bridge(soup): | ||
""" | ||
Add JS bridge script to the HTML content. | ||
""" | ||
script_tag = soup.new_tag('script') | ||
with open('openedx/features/offline_mode/static/offline_mode/js/bridge.js', 'r') as file: | ||
script_tag.string = file.read() | ||
if soup.body: | ||
soup.body.append(script_tag) | ||
else: | ||
soup.append(script_tag) | ||
return soup | ||
|
||
def process_html(self): | ||
""" | ||
Prepares HTML content for local use. | ||
|
||
Changes links to static files to paths to pre-generated static files for offline use. | ||
""" | ||
self._replace_static_links() | ||
self._replace_mathjax_link() | ||
|
||
soup = BeautifulSoup(self.html_data, 'html.parser') | ||
self._replace_iframe(soup) | ||
self._add_js_bridge(soup) | ||
return str(soup) |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
35 changes: 35 additions & 0 deletions
35
openedx/features/offline_mode/static/offline_mode/js/bridge.js
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,35 @@ | ||
function sendMessageToiOS(message) { | ||
window?.webkit?.messageHandlers?.iOSBridge?.postMessage(message); | ||
} | ||
|
||
function sendMessageToAndroid(message) { | ||
window?.AndroidBridge?.postMessage(message); | ||
} | ||
|
||
function receiveMessageFromiOS(message) { | ||
console.log("Message received from iOS:", message); | ||
} | ||
|
||
function receiveMessageFromAndroid(message) { | ||
console.log("Message received from Android:", message); | ||
} | ||
|
||
if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iOSBridge) { | ||
window.addEventListener("messageFromiOS", function (event) { | ||
receiveMessageFromiOS(event.data); | ||
}); | ||
} | ||
if (window.AndroidBridge) { | ||
window.addEventListener("messageFromAndroid", function (event) { | ||
receiveMessageFromAndroid(event.data); | ||
}); | ||
} | ||
const originalAjax = $.ajax; | ||
$.ajax = function (options) { | ||
sendMessageToiOS(options); | ||
sendMessageToiOS(JSON.stringify(options)); | ||
sendMessageToAndroid(options); | ||
sendMessageToAndroid(JSON.stringify(options)); | ||
console.log(options, JSON.stringify(options)); | ||
return originalAjax.call(this, options); | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Original file line number | Diff line number | Diff line change |
---|---|---|
@@ -0,0 +1,36 @@ | ||
""" | ||
Tasks for offline mode feature. | ||
""" | ||
from celery import shared_task | ||
from edx_django_utils.monitoring import set_code_owner_attribute | ||
from opaque_keys.edx.keys import CourseKey, UsageKey | ||
|
||
from xmodule.modulestore.django import modulestore | ||
|
||
from .constants import OFFLINE_SUPPORTED_XBLOCKS | ||
from .renderer import XBlockRenderer | ||
from .utils import generate_offline_content | ||
|
||
|
||
@shared_task | ||
@set_code_owner_attribute | ||
def generate_offline_content_for_course(course_id): | ||
""" | ||
Generates offline content for all supported XBlocks in the course. | ||
""" | ||
course_key = CourseKey.from_string(course_id) | ||
for offline_supported_block_type in OFFLINE_SUPPORTED_XBLOCKS: | ||
for xblock in modulestore().get_items(course_key, qualifiers={'category': offline_supported_block_type}): | ||
html_data = XBlockRenderer(str(xblock.location)).render_xblock_from_lms() | ||
generate_offline_content_for_block.apply_async([str(xblock.location), html_data]) | ||
|
||
|
||
@shared_task | ||
@set_code_owner_attribute | ||
def generate_offline_content_for_block(block_id, html_data): | ||
""" | ||
Generates offline content for the specified block. | ||
""" | ||
block_usage_key = UsageKey.from_string(block_id) | ||
xblock = modulestore().get_item(block_usage_key) | ||
generate_offline_content(xblock, html_data) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We probably will need to use utils from other modules to resolve static for sure
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If you mean to use an existing method for this, then unfortunately I couldn't find one, because it uses
STATIC_ROOT
, notSTATIC_URL
.And if you mean to use this method from another part of the platform, then there shouldn't be any problems with that, because
assets_management
doesn't have imports from LMS and CMS