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: [FC-0047] Implement offline content generation: #36409

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 22 additions & 2 deletions lms/djangoapps/mobile_api/course_info/views.py
Original file line number Diff line number Diff line change
@@ -1,14 +1,14 @@
"""
Views for course info API
"""

import logging
from typing import Dict, Optional, Union

import django
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 +31,8 @@
from openedx.core.djangoapps.video_pipeline.config.waffle import DEPRECATE_YOUTUBE
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.offline_mode.assets_management import get_offline_block_content_path
from openedx.features.offline_mode.toggles import is_offline_mode_enabled
from openedx.features.course_experience import ENABLE_COURSE_GOALS

from ..decorators import mobile_course_access, mobile_view
Expand Down Expand Up @@ -352,6 +354,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 @@ -410,6 +414,22 @@ 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)):
block_info.update({
'offline_download': {
'file_url': default_storage.url(offline_content_path),
'last_modified': default_storage.get_modified_time(offline_content_path),
'file_size': default_storage.size(offline_content_path)
}
})


@mobile_view()
class CourseEnrollmentDetailsView(APIView):
Expand Down
62 changes: 60 additions & 2 deletions lms/djangoapps/mobile_api/tests/test_course_info_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
Tests for course_info
"""
from datetime import datetime, timedelta
from unittest.mock import patch
from unittest.mock import MagicMock, patch

import ddt
from django.conf import settings
Expand All @@ -20,7 +20,7 @@
from lms.djangoapps.course_api.blocks.tests.test_views import TestBlocksInCourseView
from lms.djangoapps.mobile_api.course_info.views import BlocksInfoInCourseView
from lms.djangoapps.mobile_api.testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin
from lms.djangoapps.mobile_api.utils import API_V1, API_V05
from lms.djangoapps.mobile_api.utils import API_V05, API_V1, API_V2, API_V3, API_V4
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.features.course_experience import ENABLE_COURSE_GOALS
from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -430,6 +430,64 @@ def test_extend_sequential_info_with_assignment_progress_for_other_types(self, b
for block_info in response.data['blocks'].values():
self.assertNotEqual('assignment_progress', block_info)

@patch('lms.djangoapps.mobile_api.course_info.views.default_storage')
@patch('lms.djangoapps.mobile_api.course_info.views.get_offline_block_content_path')
@patch('lms.djangoapps.mobile_api.course_info.views.is_offline_mode_enabled')
def test_extend_block_info_with_offline_data(
self,
is_offline_mode_enabled_mock: MagicMock,
get_offline_block_content_path_mock: MagicMock,
default_storage_mock: MagicMock,
) -> None:
url = reverse('blocks_info_in_course', kwargs={'api_version': API_V4})
offline_content_path_mock = '/offline_content_path_mock/'
created_time_mock = 'created_time_mock'
size_mock = 'size_mock'
get_offline_block_content_path_mock.return_value = offline_content_path_mock
default_storage_mock.get_modified_time.return_value = created_time_mock
default_storage_mock.size.return_value = size_mock
default_storage_mock.url.return_value = offline_content_path_mock

expected_offline_download_data = {
'file_url': offline_content_path_mock,
'last_modified': created_time_mock,
'file_size': size_mock
}

response = self.verify_response(url=url)

is_offline_mode_enabled_mock.assert_called_once_with(self.course.course_id)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for block_info in response.data['blocks'].values():
self.assertDictEqual(block_info['offline_download'], expected_offline_download_data)

@patch('lms.djangoapps.mobile_api.course_info.views.is_offline_mode_enabled')
@ddt.data(
(API_V05, True),
(API_V05, False),
(API_V1, True),
(API_V1, False),
(API_V2, True),
(API_V2, False),
(API_V3, True),
(API_V3, False),
)
@ddt.unpack
def test_not_extend_block_info_with_offline_data_for_version_less_v4_and_any_waffle_flag(
self,
api_version: str,
offline_mode_waffle_flag_mock: MagicMock,
is_offline_mode_enabled_mock: MagicMock,
) -> None:
url = reverse('blocks_info_in_course', kwargs={'api_version': api_version})
is_offline_mode_enabled_mock.return_value = offline_mode_waffle_flag_mock

response = self.verify_response(url=url)

self.assertEqual(response.status_code, status.HTTP_200_OK)
for block_info in response.data['blocks'].values():
self.assertNotIn('offline_download', block_info)


class TestCourseEnrollmentDetailsView(MobileAPITestCase, MilestonesTestCaseMixin): # lint-amnesty, pylint: disable=test-inherits-tests
"""
Expand Down
6 changes: 6 additions & 0 deletions lms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -3312,6 +3312,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.apps.OfflineModeConfig',

'lms.djangoapps.experiments',

Expand Down Expand Up @@ -5648,3 +5649,8 @@ def _should_send_learning_badge_events(settings):
# .. toggle_creation_date: 2024-11-10
# .. toggle_target_removal_date: 2025-06-01
USE_EXTRACTED_VIDEO_BLOCK = False

# .. setting_name: RETIREMENT_SERVICE_WORKER_USERNAME
# .. setting_default: offline_mode_worker
# .. setting_description: Set the username for generating offline content. The user is used for rendering blocks.
OFFLINE_SERVICE_WORKER_USERNAME = "offline_mode_worker"
101 changes: 101 additions & 0 deletions lms/static/js/courseware/bridge.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
/**
* JS bridge for communication between the native mobile apps and the xblock.
*
* This script is used to send data about student's answer to the native mobile apps (IOS and Android)
* and to receive data about student's answer from the native mobile apps to fill the form
* with the student's answer, disable xblock inputs and mark the problem as completed.
*
* Separate functions for each platform allow you to flexibly add platform-specific logic
* as needed without changing the naming on the mobile side.
*/

/**
* Sends a JSON-formatted message to the iOS bridge if available.
* @param {string} message - The JSON message to send.
*/
function sendMessageToIOS(message) {
try {
if (window?.webkit?.messageHandlers?.IOSBridge) {
window.webkit.messageHandlers.IOSBridge.postMessage(message);
console.log("Message sent to iOS:", message);
}
} catch (error) {
console.error("Failed to send message to iOS:", error);
}
}

/**
* Sends a JSON-formatted message to the Android bridge if available.
* @param {string} message - The JSON message to send.
*/
function sendMessageToAndroid(message) {
try {
if (window?.AndroidBridge) {
window.AndroidBridge.postMessage(message);
console.log("Message sent to Android:", message);
}
} catch (error) {
console.error("Failed to send message to Android:", error);
}
}

/**
* Receives a message from the mobile apps and fills the form with the student's answer,
* disables xblock inputs and marks the problem as completed with appropriate message.
*
* @param {string} message The stringified JSON object about the student's answer from the native mobile app.
*/
function markProblemCompleted(message) {
let data;
try {
data = JSON.parse(message).data
} catch (error) {
console.error("Failed to parse message:", error)
return
}
const problemContainer = $(".xblock-student_view");

const submitButton = problemContainer.find(".submit-attempt-container .submit");
const notificationContainer = problemContainer.find(".notification-gentle-alert");

submitButton.attr({disabled: "disabled"});
notificationContainer.find(".notification-message").text("Answer submitted");
notificationContainer.find(".icon").remove();
notificationContainer.show();

data.split("&").forEach(function (item) {
const [inputId, answer] = item.split('=', 2);
problemContainer.find(
`input[id$="${answer}"], input[id$="${inputId}"]`
).each(function () {
this.disabled = true;
if (this.type === "checkbox" || this.type === "radio") {
this.checked = true;
} else {
this.value = answer;
}
})
})
}

/**
* Overrides the default $.ajax function to intercept the requests to the "handler/xmodule_handler/problem_check"
* endpoint and send the data to the native mobile apps.
*
* @param {Object} options The data object for the ajax request
*/
const originalAjax = $.ajax;
$.ajax = function (options) {
if (options.url && options.url.endsWith("handler/xmodule_handler/problem_check")) {
if (options.data) {
// Replace spaces with URLEncoded value to ensure correct parsing on the backend
let optionsCopy = {...options};
optionsCopy.data = optionsCopy.data.replace(/\+/g, '%20');

sendMessageToIOS(JSON.stringify(optionsCopy))
sendMessageToAndroid(JSON.stringify(optionsCopy))
}
}
return originalAjax.call(this, options);
}

97 changes: 97 additions & 0 deletions lms/static/js/spec/courseware/bridge_spec.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
describe('JS bridge for communication between native mobile apps and the xblock', function() {
beforeEach(function() {
// Mock objects for IOS and Android bridges
window.webkit = {
messageHandlers: {
IOSBridge: {
postMessage: jasmine.createSpy('postMessage')
}
}
};
window.AndroidBridge = {
postMessage: jasmine.createSpy('postMessage')
};
});

describe('sendMessageToIOS', function() {
it('should call postMessage on IOSBridge with the correct message', function() {
const message = JSON.stringify({answer: 'test'});
sendMessageToIOS(message);
expect(window.webkit.messageHandlers.IOSBridge.postMessage).toHaveBeenCalledWith(message);
});
});

describe('sendMessageToAndroid', function() {
it('should call postMessage on AndroidBridge with the correct message', function() {
const message = JSON.stringify({answer: 'test'});
sendMessageToAndroid(message);
expect(window.AndroidBridge.postMessage).toHaveBeenCalledWith(message);
});
});

describe('markProblemCompleted', function() {
it('should correctly parse the message and update the DOM elements', function() {
const message = JSON.stringify({
data: 'input1=answer1&input2=answer2'
});
const problemContainer = $('<div class="xblock-student_view">'
+ '<div class="submit-attempt-container">'
+ '<button class="submit"></button>'
+ '</div>'
+ '<div class="notification-gentle-alert">'
+ '<div class="notification-message"></div>'
+ '</div>'
+ '<input id="input1">'
+ '<input id="input2">'
+ '<input id="answer1">'
+ '<input id="answer2">'
+ '</div>');
$('body').append(problemContainer);

markProblemCompleted(message);

expect(problemContainer.find('.submit-attempt-container .submit').attr('disabled')).toBe('disabled');
expect(problemContainer.find('.notification-gentle-alert .notification-message').html()).toBe('Answer submitted.');
expect(problemContainer.find('.notification-gentle-alert').css('display')).not.toBe('none');
expect(problemContainer.find('#input1').val()).toBe('answer1');
expect(problemContainer.find('#input2').val()).toBe('answer2');
expect(problemContainer.find('#answer1').prop('disabled')).toBe(true);
expect(problemContainer.find('#answer2').prop('disabled')).toBe(true);

problemContainer.remove();
});
});

describe('$.ajax', function() {
beforeEach(function() {
spyOn($, 'ajax').and.callThrough();
});

it('should intercept the request to problem_check and send data to the native apps', function() {
const ajaxOptions = {
url: 'http://example.com/handler/xmodule_handler/problem_check',
data: {answer: 'test'}
};

$.ajax(ajaxOptions);

expect(window.webkit.messageHandlers.IOSBridge.postMessage).toHaveBeenCalledWith(JSON.stringify(ajaxOptions));
expect(window.AndroidBridge.postMessage).toHaveBeenCalledWith(JSON.stringify(ajaxOptions));
});

it('should call the original $.ajax function', function() {
const ajaxOptions = {
url: 'http://example.com/handler/xmodule_handler/problem_check',
data: {answer: 'test'}
};

const originalAjax = spyOn($, 'ajax').and.callFake(function(options) {
return originalAjax.and.callThrough().call(this, options);
});

$.ajax(ajaxOptions);

expect(originalAjax).toHaveBeenCalledWith(ajaxOptions);
});
});
});
3 changes: 3 additions & 0 deletions lms/templates/courseware/courseware-chromeless.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,9 @@
<%static:include path="common/templates/${template_name}.underscore" />
</script>
% endfor
% if is_offline_content:
<script type="text/javascript" src="${static.url('js/courseware/bridge.js')}"></script>
% endif
<%
header_file = None
%>
Expand Down
2 changes: 2 additions & 0 deletions openedx/core/djangoapps/content/block_structure/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@
from opaque_keys.edx.keys import CourseKey

from xmodule.capa.responsetypes import LoncapaProblemError
from xmodule.modulestore.django import modulestore, SignalHandler
from openedx.core.djangoapps.content.block_structure import api
from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order

Expand Down Expand Up @@ -44,6 +45,7 @@ def update_course_in_cache_v2(self, **kwargs):
course_id (string) - The string serialized value of the course key.
"""
_update_course_in_cache(self, **kwargs)
SignalHandler(modulestore()).send('course_cache_updated', course_key=CourseKey.from_string(kwargs['course_id']))


@block_structure_task()
Expand Down
Empty file.
Loading
Loading