Skip to content

Commit

Permalink
feat: move logic into mobile_api
Browse files Browse the repository at this point in the history
  • Loading branch information
vzadorozhnii committed May 16, 2024
1 parent 6e10a80 commit 531e6b8
Show file tree
Hide file tree
Showing 17 changed files with 373 additions and 2 deletions.
7 changes: 7 additions & 0 deletions lms/djangoapps/mobile_api/apps.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,3 +12,10 @@ class MobileApiConfig(AppConfig):
"""
name = 'lms.djangoapps.mobile_api'
verbose_name = "Mobile API"

def ready(self):
"""
Connect signal handlers.
"""
from lms.djangoapps.mobile_api.offline_mode import signals # pylint: disable=unused-import
from lms.djangoapps.mobile_api.offline_mode import tasks # pylint: disable=unused-import
12 changes: 12 additions & 0 deletions lms/djangoapps/mobile_api/offline_mode/signals.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import six
from django.dispatch import receiver

from xmodule.modulestore.django import SignalHandler

from .tasks import generate_course_media


@receiver(SignalHandler.course_published)
def hello_world(sender, course_key, **kwargs):
import pdb; pdb.set_trace()
generate_course_media.delay(six.text_type(course_key))
12 changes: 12 additions & 0 deletions lms/djangoapps/mobile_api/offline_mode/tasks.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
from celery import shared_task
from opaque_keys.edx.keys import CourseKey

from xmodule.modulestore.django import modulestore


@shared_task
def generate_course_media(course_id):
course_key = CourseKey.from_string(course_id)

for xblock_html in modulestore().get_items(course_key, qualifiers={'category': ['html', 'problem']}):
xblock_html.update_info_api()
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
Empty file.
47 changes: 47 additions & 0 deletions lms/djangoapps/mobile_api/offline_mode/utils/assets_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import os

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 .file_management import get_static_file_path, read_static_file


def save_asset_file(xblock, path, filename):
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):
pass
else:
base_path = base_storage_path(xblock)
default_storage.save(f'{base_path}assets/{filename}', ContentFile(content))


def remove_old_files(base_path):
try:
directories, files = default_storage.listdir(base_path)
except OSError:
pass
else:
for file_name in files:
default_storage.delete(base_path + file_name)

try:
directories, files = default_storage.listdir(base_path + 'assets/')
except OSError:
pass
else:
for file_name in files:
default_storage.delete(base_path + 'assets/' + file_name)


def base_storage_path(xblock):
return '{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}/'.format(loc=xblock.location)
12 changes: 12 additions & 0 deletions lms/djangoapps/mobile_api/offline_mode/utils/file_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import os
from django.conf import settings


def get_static_file_path(relative_path):
base_path = settings.STATIC_ROOT
return os.path.join(base_path, relative_path)


def read_static_file(path):
with open(path, 'rb') as file:
return file.read()
91 changes: 91 additions & 0 deletions lms/djangoapps/mobile_api/offline_mode/utils/html_manipulator.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,91 @@
import re
from bs4 import BeautifulSoup

from .assets_management import save_asset_file


class HtmlManipulator:
def __init__(self, xblock, html_data):
self.html_data = html_data
self.xblock = xblock

def _replace_mathjax_link(self):
mathjax_pattern = re.compile(r'src="https://cdn.jsdelivr.net/npm/[email protected]/MathJax.js[^"]*"')
return mathjax_pattern.sub('src="/static/mathjax/MathJax.js"', self.html_data)

def _replace_static_links(self):
pattern = re.compile(r'/static/[\w./-]+')
return pattern.sub(self._replace_link, self.html_data)

def _replace_link(self, match):
link = match.group()
filename = link.split('/static/')[-1]
save_asset_file(self.xblock, link, filename)
return f'assets/{filename}'

def _replace_iframe(self, soup):
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)

def _add_js_bridge(self, soup):
script_tag = soup.new_tag('script')
script_tag.string = """
// JS bridge script
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);
};
"""
if soup.body:
soup.body.append(script_tag)
else:
soup.append(script_tag)
return soup

def process_html(self):
self._replace_mathjax_link()
self._replace_static_links()
soup = BeautifulSoup(self.html_data, 'html.parser')
self._replace_iframe(soup)
self._add_js_bridge(soup)
return str(soup)
159 changes: 159 additions & 0 deletions lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
from django.conf import settings
from django.core.files.base import ContentFile
from django.core.files.storage import default_storage

from xmodule.modulestore.django import modulestore

from .utils.html_manipulation import manipulate_html
from .utils.assets_management import save_asset_file, remove_old_files, base_storage_path
from .utils.zip_management import create_zip_file


def is_modified(xblock):
file_path = f'{base_storage_path(xblock)}content_html.zip'

try:
last_modified = default_storage.get_created_time(file_path)
except OSError:
return True

return xblock.published_on > last_modified


def enclosing_sequence_for_gating_checks(block):
seq_tags = ['sequential']
if block.location.block_type in seq_tags:
return None

ancestor = block
while ancestor and ancestor.location.block_type not in seq_tags:
ancestor = ancestor.get_parent() # Note: CourseBlock's parent is None

if ancestor:
return block.runtime.get_block(ancestor.location)
return None


def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_debug_info=False):
"""
Helper function to render an XBlock and return the rendered HTML content.
"""
from edx_django_utils.monitoring import set_custom_attribute, set_custom_attributes_for_course_key
from lms.djangoapps.courseware.courses import get_course_with_access
from lms.djangoapps.courseware.block_render import get_block, get_block_by_usage_id, get_block_for_descriptor
from xmodule.modulestore.exceptions import ItemNotFoundError, NoPathToItem
from openedx.features.course_experience.url_helpers import (
get_courseware_url,
get_learning_mfe_home_url,
is_request_from_learning_mfe
)
from openedx.core.lib.mobile_utils import is_request_from_mobile_app
from openedx.features.course_experience.utils import dates_banner_should_display
from lms.djangoapps.courseware.masquerade import is_masquerading_as_specific_student, setup_masquerade
from lms.djangoapps.courseware.views.views import get_optimization_flags_for_content
from lms.djangoapps.edxnotes.helpers import is_feature_enabled
from lms.djangoapps.courseware.date_summary import verified_upgrade_deadline_link
from common.djangoapps.edxmako.shortcuts import marketing_link, render_to_response, render_to_string
usage_key = xblock.usage_key

usage_key = usage_key.replace(course_key=modulestore().fill_in_run(usage_key.course_key))
course_key = usage_key.course_key

# Gathering metrics to make performance measurements easier.
set_custom_attributes_for_course_key(course_key)
set_custom_attribute('usage_key', str(usage_key))
set_custom_attribute('block_type', usage_key.block_type)

staff_access = has_access(request.user, 'staff', course_key)

with modulestore().bulk_operations(course_key):
# verify the user has access to the course, including enrollment check
try:
course = get_course_with_access(request.user, 'load', course_key, check_if_enrolled=check_if_enrolled)
except:
return None

_course_masquerade, request.user = setup_masquerade(
request,
course_key,
staff_access,
)

UserActivity.record_user_activity(
request.user, usage_key.course_key, request=request, only_if_mobile_app=True
)

recheck_access = request.GET.get('recheck_access') == '1'
try:
block, _ = get_block_by_usage_id(
request,
str(course_key),
str(usage_key),
disable_staff_debug_info=disable_staff_debug_info,
course=course,
will_recheck_access=recheck_access,
)
except:
return None

student_view_context = request.GET.dict()
student_view_context['show_bookmark_button'] = request.GET.get('show_bookmark_button', '0') == '1'
student_view_context['show_title'] = request.GET.get('show_title', '1') == '1'

is_learning_mfe = is_request_from_learning_mfe(request)
student_view_context['hide_access_error_blocks'] = is_learning_mfe and recheck_access
is_mobile_app = is_request_from_mobile_app(request)
student_view_context['is_mobile_app'] = is_mobile_app

enable_completion_on_view_service = False
completion_service = block.runtime.service(block, 'completion')
if completion_service and completion_service.completion_tracking_enabled():
if completion_service.blocks_to_mark_complete_on_view({block}):
enable_completion_on_view_service = True
student_view_context['wrap_xblock_data'] = {
'mark-completed-on-view-after-delay': completion_service.get_complete_on_view_delay_ms()
}

missed_deadlines, missed_gated_content = dates_banner_should_display(course_key, request.user)
fragment = block.render('student_view', context=student_view_context)
optimization_flags = get_optimization_flags_for_content(block, fragment)

context = {
'fragment': fragment,
'course': course,
'block': block,
'disable_accordion': True,
'allow_iframing': True,
'disable_header': True,
'disable_footer': True,
'disable_window_wrap': True,
'enable_completion_on_view_service': enable_completion_on_view_service,
'edx_notes_enabled': is_feature_enabled(course, request.user),
'staff_access': staff_access,
'xqa_server': settings.FEATURES.get('XQA_SERVER', 'http://your_xqa_server.com'),
'missed_deadlines': missed_deadlines,
'missed_gated_content': missed_gated_content,
'has_ended': course.has_ended(),
'web_app_course_url': get_learning_mfe_home_url(course_key=course.id, url_fragment='home'),
'on_courseware_page': True,
'verified_upgrade_link': verified_upgrade_deadline_link(request.user, course=course),
'is_learning_mfe': is_learning_mfe,
'is_mobile_app': is_mobile_app,
'render_course_wide_assets': True,

**optimization_flags,
}
return render_to_string('courseware/courseware-chromeless.html', context)


def generate_offline_content(xblock, html_data):
if not is_modified(xblock):
return

base_path = base_storage_path(xblock)
remove_old_files(base_path)

manipulated_html = manipulate_html(html_data, lambda path, filename: save_asset_file(xblock, path, filename))

default_storage.save(f'{base_path}index.html', ContentFile(manipulated_html))
create_zip_file(base_path, 'content_html.zip')
25 changes: 25 additions & 0 deletions lms/djangoapps/mobile_api/offline_mode/utils/zip_management.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import os
import zipfile
from django.core.files.storage import default_storage


def create_zip_file(base_path, file_name):
zf = zipfile.ZipFile(default_storage.path(base_path + file_name), "w")
zf.write(default_storage.path(base_path + "index.html"), "index.html")

def add_files_to_zip(zip_file, current_base_path, current_path_in_zip):
try:
directories, files = default_storage.listdir(current_base_path)
except OSError:
return

for file_name in files:
full_path = os.path.join(current_base_path, file_name)
zip_file.write(full_path, os.path.join(current_path_in_zip, file_name))

for directory in directories:
add_files_to_zip(zip_file, os.path.join(current_base_path, directory),
os.path.join(current_path_in_zip, directory))

add_files_to_zip(zf, default_storage.path(base_path + "assets/"), 'assets')
zf.close()
Loading

0 comments on commit 531e6b8

Please sign in to comment.