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: Offline mode #2590

Open
wants to merge 18 commits into
base: master
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
916c452
feat: [AXM-542] create xblock renderer
NiedielnitsevIvan Nov 21, 2024
95dbc8e
feat: [AXM-589] Add retry to content generation task
NiedielnitsevIvan Nov 21, 2024
8df4312
feat: [AXM-644] Add authorization via cms worker for content generati…
NiedielnitsevIvan Nov 21, 2024
253ca5d
fix: [AXM-748] fix problem block offline generations
NiedielnitsevIvan Nov 21, 2024
bc39c7d
feat: [AXM-749] Implement s3 storage supporting
NiedielnitsevIvan Nov 21, 2024
713bf2e
fix: [AXM-791] fix error 404 handling and optimize for not modified b…
NiedielnitsevIvan Nov 21, 2024
f204488
refactor: [AXM-361] refactor JS bridge for IOS and Android
NiedielnitsevIvan Nov 21, 2024
f25bfa2
feat: [AXM-755] save external files and fonts to offline block content
NiedielnitsevIvan Nov 21, 2024
7d8de39
fix: don't display icon in offline mode
NiedielnitsevIvan Nov 21, 2024
1993991
test: [AXM-636] Cover Offline Mode API with unit tests
NiedielnitsevIvan Nov 21, 2024
2413153
fix: encode spaces as %20
NiedielnitsevIvan Nov 21, 2024
3033a6b
refactor: update path to store blocks
NiedielnitsevIvan Nov 21, 2024
c82412e
fix: provide a file URL by using url method
NiedielnitsevIvan Nov 21, 2024
8fe54a3
style: remote extra import
NiedielnitsevIvan Nov 21, 2024
ad37902
style: fix code style issues
NiedielnitsevIvan Nov 29, 2024
00e2cd5
fix: add js bridge to xblock html
NiedielnitsevIvan Dec 4, 2024
653f18d
fix: fix data sent to the bidges
NiedielnitsevIvan Jan 24, 2025
ac6aa7e
refactor: change blocks getting logic
NiedielnitsevIvan Feb 3, 2025
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
12 changes: 12 additions & 0 deletions cms/djangoapps/contentstore/signals/handlers.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
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 @@ -21,19 +22,22 @@
CoursewareSearchIndexer,
LibrarySearchIndexer,
)
from cms.djangoapps.contentstore.utils import get_cms_api_client
from common.djangoapps.track.event_transaction_utils import get_event_transaction_id, get_event_transaction_type
from common.djangoapps.util.block_utils import yield_dynamic_block_descendants
from lms.djangoapps.grades.api import task_compute_all_grades_for_course
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,14 @@ 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):
client = get_cms_api_client()
client.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
17 changes: 17 additions & 0 deletions cms/djangoapps/contentstore/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
import html
import logging
import re
import requests
from collections import defaultdict
from contextlib import contextmanager
from datetime import datetime, timezone
Expand All @@ -14,8 +15,10 @@

from bs4 import BeautifulSoup
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.exceptions import ValidationError
from django.urls import reverse
from edx_rest_api_client.auth import SuppliedJwtAuth
from django.utils import translation
from django.utils.text import Truncator
from django.utils.translation import gettext as _
Expand Down Expand Up @@ -96,6 +99,7 @@
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from openedx.core.djangoapps.models.course_details import CourseDetails
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.lib.courses import course_image_url
from openedx.core.lib.html_to_text import html_to_text
from openedx.features.content_type_gating.models import ContentTypeGatingConfig
Expand All @@ -113,6 +117,7 @@

IMPORTABLE_FILE_TYPES = ('.tar.gz', '.zip')
log = logging.getLogger(__name__)
User = get_user_model()


def add_instructor(course_key, requesting_user, new_instructor):
Expand Down Expand Up @@ -2344,3 +2349,15 @@ def get_xblock_render_context(request, block):
return str(exc)

return ""


def get_cms_api_client():
"""
Returns an API client which can be used to make requests from the CMS service.
"""
user = User.objects.get(username=settings.CMS_SERVICE_USER_NAME)
jwt = create_jwt_for_user(user)
client = requests.Session()
client.auth = SuppliedJwtAuth(jwt)

return client
2 changes: 2 additions & 0 deletions cms/envs/common.py
Original file line number Diff line number Diff line change
Expand Up @@ -2523,6 +2523,8 @@
EXAMS_SERVICE_URL = 'http://localhost:18740/api/v1'
EXAMS_SERVICE_USERNAME = 'edx_exams_worker'

CMS_SERVICE_USER_NAME = 'edxapp_cms_worker'

FINANCIAL_REPORTS = {
'STORAGE_TYPE': 'localfs',
'BUCKET': None,
Expand Down
1 change: 1 addition & 0 deletions cms/envs/production.py
Original file line number Diff line number Diff line change
Expand Up @@ -168,6 +168,7 @@ def get_env_setting(setting):
ENTERPRISE_CONSENT_API_URL = ENV_TOKENS.get('ENTERPRISE_CONSENT_API_URL', LMS_INTERNAL_ROOT_URL + '/consent/api/v1/')
AUTHORING_API_URL = ENV_TOKENS.get('AUTHORING_API_URL', '')
# Note that FEATURES['PREVIEW_LMS_BASE'] gets read in from the environment file.
CMS_SERVICE_USER_NAME = ENV_TOKENS.get('CMS_SERVICE_USER_NAME', CMS_SERVICE_USER_NAME)

CHAT_COMPLETION_API = ENV_TOKENS.get('CHAT_COMPLETION_API', '')
CHAT_COMPLETION_API_KEY = ENV_TOKENS.get('CHAT_COMPLETION_API_KEY', '')
Expand Down
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
87 changes: 85 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,9 +20,11 @@
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 openedx.features.offline_mode.constants import DEFAULT_OFFLINE_SUPPORTED_XBLOCKS
from openedx.features.offline_mode.toggles import ENABLE_OFFLINE_MODE
from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
Expand Down Expand Up @@ -430,6 +432,87 @@ 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

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)

@override_waffle_flag(ENABLE_OFFLINE_MODE, active=True)
@patch('openedx.features.offline_mode.html_manipulator.save_mathjax_to_xblock_assets')
def test_create_offline_content_integration_test(self, save_mathjax_to_xblock_assets_mock: MagicMock) -> None:
UserFactory.create(username='offline_mode_worker', password='password', is_staff=True)
handle_course_published_url = reverse('offline_mode:handle_course_published')
self.client.login(username='offline_mode_worker', password='password')

handler_response = self.client.post(handle_course_published_url, {'course_id': str(self.course.id)})
self.assertEqual(handler_response.status_code, status.HTTP_200_OK)

url = reverse('blocks_info_in_course', kwargs={'api_version': API_V4})

response = self.verify_response(url=url)
self.assertEqual(response.status_code, status.HTTP_200_OK)
for block_info in response.data['blocks'].values():
if block_type := block_info.get('type'):
if block_type in DEFAULT_OFFLINE_SUPPORTED_XBLOCKS:
expected_offline_content_url = f'/uploads/{self.course.id}/{block_info["block_id"]}.zip'
self.assertIn('offline_download', block_info)
self.assertIn('file_url', block_info['offline_download'])
self.assertIn('last_modified', block_info['offline_download'])
self.assertIn('file_size', block_info['offline_download'])
self.assertEqual(expected_offline_content_url, block_info['offline_download']['file_url'])


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 @@ -3330,6 +3330,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 Expand Up @@ -5558,3 +5559,8 @@ def _should_send_learning_badge_events(settings):


LMS_COMM_DEFAULT_FROM_EMAIL = "[email protected]"

# .. 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"
Loading