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-542] create xblock renderer #2570

11 changes: 11 additions & 0 deletions cms/djangoapps/contentstore/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,11 @@


import logging
import requests
from datetime import datetime, timezone
from functools import wraps
from typing import Optional
from urllib.parse import urljoin

from django.conf import settings
from django.core.cache import cache
Expand All @@ -27,13 +29,15 @@
from openedx.core.djangoapps.content.learning_sequences.api import key_supports_outlines
from openedx.core.djangoapps.discussions.tasks import update_discussions_settings_from_course_task
from openedx.core.lib.gating import api as gating_api
from openedx.features.offline_mode.toggles import is_offline_mode_enabled
from xmodule.modulestore import ModuleStoreEnum
from xmodule.modulestore.django import SignalHandler, modulestore
from .signals import GRADING_POLICY_CHANGED

log = logging.getLogger(__name__)

GRADING_POLICY_COUNTDOWN_SECONDS = 3600
LMS_OFFLINE_HANDLER_URL = '/offline_mode/handle_course_published'


def locked(expiry_seconds, key): # lint-amnesty, pylint: disable=missing-function-docstring
Expand Down Expand Up @@ -155,6 +159,13 @@ def listen_for_course_publish(sender, course_key, **kwargs): # pylint: disable=
# Send to a signal for catalog info changes as well, but only once we know the transaction is committed.
transaction.on_commit(lambda: emit_catalog_info_changed_signal(course_key))

if is_offline_mode_enabled(course_key):
requests.post(
url=urljoin(settings.LMS_ROOT_URL, LMS_OFFLINE_HANDLER_URL),
data={'course_id': str(course_key)},
)
log.info('Sent course_published event to offline mode handler')


@receiver(SignalHandler.course_deleted)
def listen_for_course_delete(sender, course_key, **kwargs): # pylint: disable=unused-argument
Expand Down
28 changes: 27 additions & 1 deletion lms/djangoapps/mobile_api/course_info/views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,13 +2,16 @@
Views for course info API
"""

import os
import logging
from typing import Dict, Optional, Union

import django
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.files.storage import default_storage
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from opaque_keys.edx.keys import CourseKey, UsageKey
from rest_framework import generics, status
from rest_framework.response import Response
from rest_framework.reverse import reverse
Expand All @@ -31,6 +34,9 @@
from openedx.core.lib.api.view_utils import view_auth_classes
from openedx.core.lib.xblock_utils import get_course_update_items
from openedx.features.course_experience import ENABLE_COURSE_GOALS
from openedx.features.offline_mode.assets_management import get_offline_block_content_path
from openedx.features.offline_mode.toggles import is_offline_mode_enabled

from ..decorators import mobile_course_access, mobile_view

User = get_user_model()
Expand Down Expand Up @@ -369,6 +375,8 @@ def list(self, request, **kwargs): # pylint: disable=W0221
course_key,
response.data['blocks'],
)
if api_version == 'v4' and is_offline_mode_enabled(course_key):
self._extend_block_info_with_offline_data(response.data['blocks'])

course_info_context = {
'user': requested_user,
Expand Down Expand Up @@ -426,3 +434,21 @@ def _extend_sequential_info_with_assignment_progress(
}
}
)

@staticmethod
def _extend_block_info_with_offline_data(blocks_info_data: Dict[str, Dict]) -> None:
"""
Extends block info with offline download data.

If offline content is available for the block, adds the offline download data to the block info.
"""
for block_id, block_info in blocks_info_data.items():
if offline_content_path := get_offline_block_content_path(usage_key=UsageKey.from_string(block_id)):
file_url = os.path.join(settings.MEDIA_URL, offline_content_path)
block_info.update({
'offline_download': {
'file_url': file_url,
'last_modified': default_storage.get_created_time(offline_content_path),
'file_size': default_storage.size(offline_content_path)
}
})
1 change: 1 addition & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3328,6 +3328,7 @@ def _make_locale_paths(settings): # pylint: disable=missing-function-docstring
'openedx.features.discounts',
'openedx.features.effort_estimation',
'openedx.features.name_affirmation_api.apps.NameAffirmationApiConfig',
'openedx.features.offline_mode',

'lms.djangoapps.experiments',

Expand Down
42 changes: 42 additions & 0 deletions lms/templates/courseware/courseware-chromeless.html
Original file line number Diff line number Diff line change
Expand Up @@ -216,3 +216,45 @@
}());
</script>
% endif

% if is_offline_content:
<script type="text/javascript">
(function() {
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);
};
}());
</script>
% endif
4 changes: 4 additions & 0 deletions lms/urls.py
Original file line number Diff line number Diff line change
Expand Up @@ -1053,3 +1053,7 @@
urlpatterns += [
path('api/notifications/', include('openedx.core.djangoapps.notifications.urls')),
]

urlpatterns += [
path('offline_mode/', include('openedx.features.offline_mode.urls')),
]

Choose a reason for hiding this comment

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

We should add docs directory to a new app, and add ADR there.

Empty file.
14 changes: 14 additions & 0 deletions openedx/features/offline_mode/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
"""
OfflineMode application configuration
"""


from django.apps import AppConfig


class OfflineModeConfig(AppConfig):

Choose a reason for hiding this comment

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

Need to think if this app should become a plugin as other new edx-platform applications

"""
Application Configuration for Offline Mode module.
"""

name = 'openedx.features.offline_mode'
172 changes: 172 additions & 0 deletions openedx/features/offline_mode/assets_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"""
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.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(temp_dir, 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:
temp_dir (str): The temporary directory where the assets are stored.
xblock (XBlock): The XBlock instance
path (str): The path where the asset is located.
filename (str): The name of the file to be saved.
"""
try:
if filename.startswith('assets/'):
asset_filename = filename.split('/')[-1]
asset_key = StaticContent.get_asset_key_from_path(xblock.location.course_key, asset_filename)
content = AssetManager.find(asset_key).data
file_path = os.path.join(temp_dir, filename)
else:
static_path = get_static_file_path(filename)
content = read_static_file(static_path)
file_path = os.path.join(temp_dir, 'assets', filename)
except (ItemNotFoundError, NotFoundError):
log.info(f"Asset not found: {filename}")

else:
create_subdirectories_for_asset(file_path)
with open(file_path, 'wb') as file:
file.write(content)


def create_subdirectories_for_asset(file_path):
out_dir_name = '/'
for dir_name in file_path.split('/')[:-1]:
out_dir_name = os.path.join(out_dir_name, dir_name)
if out_dir_name and not os.path.exists(out_dir_name):
os.mkdir(out_dir_name)


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)}/' 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(temp_dir):
"""
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(temp_dir, MATHJAX_STATIC_PATH)
if not os.path.exists(file_path):
response = requests.get(MATHJAX_CDN_URL)
with open(file_path, 'wb') as file:
file.write(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 = ['html']
OFFLINE_SUPPORTED_XBLOCKS = getattr(settings, 'OFFLINE_SUPPORTED_XBLOCKS', DEFAULT_OFFLINE_SUPPORTED_XBLOCKS)
Loading
Loading