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

feat: [AXM-349] Implement media generation for problem xblock #2568

Empty file.
162 changes: 162 additions & 0 deletions openedx/features/offline_mode/assets_management.py
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):
Copy link
Collaborator

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

Copy link
Author

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, not STATIC_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

"""
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}")
13 changes: 13 additions & 0 deletions openedx/features/offline_mode/constants.py
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)
90 changes: 90 additions & 0 deletions openedx/features/offline_mode/html_manipulator.py
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)
2 changes: 1 addition & 1 deletion openedx/features/offline_mode/renderer.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@
from django.contrib.sessions.backends.db import SessionStore
from django.http import HttpRequest

from opaque_keys.edx.keys import CourseKey, UsageKey
from opaque_keys.edx.keys import UsageKey
from xmodule.modulestore.django import modulestore

from common.djangoapps.edxmako.shortcuts import render_to_string
Expand Down
35 changes: 35 additions & 0 deletions openedx/features/offline_mode/static/offline_mode/js/bridge.js
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);
};
36 changes: 36 additions & 0 deletions openedx/features/offline_mode/tasks.py
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)
Loading
Loading