From 37951309cb1ecbd375d834a2e269063323fa6a70 Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Tue, 19 Mar 2024 18:09:04 +0200 Subject: [PATCH 01/26] feat: [AXM-24] Update structure for course enrollments API (#2515) * feat: [AXM-24] Update structure for course enrollments API * style: [AXM-24] Improve code style * fix: [AXM-24] Fix student's latest enrollment filter --- lms/djangoapps/mobile_api/users/tests.py | 209 ++++++++++++++++++++++- lms/djangoapps/mobile_api/users/views.py | 90 ++++++++-- lms/djangoapps/mobile_api/utils.py | 1 + lms/urls.py | 2 +- 4 files changed, 286 insertions(+), 16 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 65b1fba65ce3..08dc255426c2 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -18,6 +18,7 @@ from django.utils.timezone import now from milestones.tests.utils import MilestonesTestCaseMixin from opaque_keys.edx.keys import CourseKey +from rest_framework import status from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment @@ -27,6 +28,7 @@ from lms.djangoapps.certificates.data import CertificateStatuses from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.courseware.access_response import MilestoneAccessError, StartDateError, VisibilityError +from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.mobile_api.models import MobileConfig from lms.djangoapps.mobile_api.testutils import ( MobileAPITestCase, @@ -34,7 +36,7 @@ MobileAuthUserTestMixin, MobileCourseAccessTestMixin ) -from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3 +from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3, API_V4 from openedx.core.lib.courses import course_image_url from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration from openedx.features.course_duration_limits.models import CourseDurationLimitConfig @@ -406,6 +408,211 @@ def test_pagination_enrollment(self): assert "next" in response.data["enrollments"] assert "previous" in response.data["enrollments"] + def test_student_dont_have_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + expected_result = { + 'configs': { + 'iap_configs': {} + }, + 'enrollments': { + 'next': None, + 'previous': None, + 'count': 0, + 'num_pages': 1, + 'current_page': 1, + 'start': 0, + 'results': [] + } + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_result, response.data) + self.assertNotIn('primary', response.data) + + def test_student_have_one_enrollment(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course.id) + expected_enrollments = { + 'next': None, + 'previous': None, + 'count': 0, + 'num_pages': 1, + 'current_page': 1, + 'start': 0, + 'results': [] + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_enrollments, response.data['enrollments']) + self.assertIn('primary', response.data) + self.assertEqual(str(course.id), response.data['primary']['course']['id']) + + def test_student_have_two_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course_first = CourseFactory.create(org="edx", mobile_available=True) + course_second = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course_first.id) + self.enroll(course_second.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(len(response.data['enrollments']['results']), 1) + self.assertEqual(response.data['enrollments']['count'], 1) + self.assertEqual(response.data['enrollments']['results'][0]['course']['id'], str(course_first.id)) + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(course_second.id)) + + def test_student_have_more_then_ten_enrollments(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(15)] + for course in courses: + self.enroll(course.id) + latest_enrolment = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(latest_enrolment.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 15) + self.assertEqual(response.data['enrollments']['num_pages'], 2) + self.assertEqual(len(response.data['enrollments']['results']), 10) + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(latest_enrolment.id)) + + def test_student_have_progress_in_old_course_and_enroll_newest_course(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + old_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(old_course.id) + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + new_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(new_course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 6) + self.assertEqual(len(response.data['enrollments']['results']), 6) + # check that we have the new_course in primary section + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) + + # doing progress in the old_course + StudentModule.objects.create( + student=self.user, + course_id=old_course.id, + module_state_key=old_course.location, + ) + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 6) + self.assertEqual(len(response.data['enrollments']['results']), 6) + # check that now we have the old_course in primary section + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(old_course.id)) + + # enroll to the newest course + newest_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(newest_course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 7) + self.assertEqual(len(response.data['enrollments']['results']), 7) + # check that now we have the newest_course in primary section + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(newest_course.id)) + + def test_student_enrolled_only_not_mobile_available_courses(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=False) for _ in range(3)] + for course in courses: + self.enroll(course.id) + expected_result = { + "configs": { + "iap_configs": {} + }, + "enrollments": { + "next": None, + "previous": None, + "count": 0, + "num_pages": 1, + "current_page": 1, + "start": 0, + "results": [] + } + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(expected_result, response.data) + self.assertNotIn('primary', response.data) + + def test_do_progress_in_not_mobile_available_course(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + not_mobile_available = CourseFactory.create(org="edx", mobile_available=False) + self.enroll(not_mobile_available.id) + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + new_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(new_course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 5) + self.assertEqual(len(response.data['enrollments']['results']), 5) + # check that we have the new_course in primary section + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) + + # doing progress in the not_mobile_available course + StudentModule.objects.create( + student=self.user, + course_id=not_mobile_available.id, + module_state_key=not_mobile_available.location, + ) + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 5) + self.assertEqual(len(response.data['enrollments']['results']), 5) + # check that we have the new_course in primary section in the same way + self.assertIn('primary', response.data) + self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin): diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index 049678dcd7ba..d6d77dc1edf6 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -4,6 +4,7 @@ import logging +from typing import List, Optional from completion.exceptions import UnavailableCompletionData from completion.utilities import get_key_to_last_completed_block @@ -29,9 +30,10 @@ from lms.djangoapps.courseware.courses import get_current_child from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block_for_descriptor +from lms.djangoapps.courseware.models import StudentModule from lms.djangoapps.courseware.views.index import save_positions_recursively_up from lms.djangoapps.mobile_api.models import MobileConfig -from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3 +from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3, API_V4 from openedx.features.course_duration_limits.access import check_course_expired from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.exceptions import ItemNotFoundError # lint-amnesty, pylint: disable=wrong-import-order @@ -263,6 +265,10 @@ class UserCourseEnrollmentsList(generics.ListAPIView): An additional attribute "expiration" has been added to the response, which lists the date when access to the course will expire or null if it doesn't expire. + In v4 we added to the response primary object. Primary object contains the latest user's enrollment + or course where user has the latest progress. Primary object has been cut from user's + enrolments array and inserted into separated section with key `primary`. + **Example Request** GET /api/mobile/v1/users/{username}/course_enrollments/ @@ -343,6 +349,29 @@ def get_serializer_class(self): def get_queryset(self): api_version = self.kwargs.get('api_version') + mobile_available = self.get_mobile_available_enrollments() + + not_duration_limited = ( + enrollment for enrollment in mobile_available + if check_course_expired(self.request.user, enrollment.course) == ACCESS_GRANTED + ) + + if api_version == API_V4: + primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() + if primary_enrollment_obj: + mobile_available.remove(primary_enrollment_obj) + + if api_version == API_V05: + # for v0.5 don't return expired courses + return list(not_duration_limited) + else: + # return all courses, with associated expiration + return mobile_available + + def get_mobile_available_enrollments(self) -> List[Optional[CourseEnrollment]]: + """ + Gets list with `CourseEnrollment` for mobile available courses. + """ enrollments = self.queryset.filter( user__username=self.kwargs['username'], is_active=True @@ -357,31 +386,64 @@ def get_queryset(self): enrollment for enrollment in same_org if is_mobile_available_for_user(self.request.user, enrollment.course_overview) ) - not_duration_limited = ( - enrollment for enrollment in mobile_available - if check_course_expired(self.request.user, enrollment.course) == ACCESS_GRANTED - ) - - if api_version == API_V05: - # for v0.5 don't return expired courses - return list(not_duration_limited) - else: - # return all courses, with associated expiration - return list(mobile_available) + return list(mobile_available) def list(self, request, *args, **kwargs): response = super().list(request, *args, **kwargs) api_version = self.kwargs.get('api_version') - if api_version in (API_V2, API_V3): + if api_version in (API_V2, API_V3, API_V4): enrollment_data = { 'configs': MobileConfig.get_structured_configs(), 'enrollments': response.data } + if api_version == API_V4: + primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() + if primary_enrollment_obj: + serializer = self.get_serializer(primary_enrollment_obj) + enrollment_data.update({'primary': serializer.data}) + return Response(enrollment_data) return response + def get_primary_enrollment_by_latest_enrollment_or_progress(self) -> Optional[CourseEnrollment]: + """ + Gets primary enrollment obj by latest enrollment or latest progress on the course. + """ + mobile_available = self.get_mobile_available_enrollments() + if not mobile_available: + return None + + mobile_available_course_ids = [enrollment.course_id for enrollment in mobile_available] + + latest_enrollment = self.queryset.filter( + user__username=self.kwargs['username'], + is_active=True, + course__id__in=mobile_available_course_ids, + ).order_by('-created').first() + + if not latest_enrollment: + return None + + latest_progress = StudentModule.objects.filter( + student__username=self.kwargs['username'], + course_id__in=mobile_available_course_ids, + ).order_by('-modified').first() + + if not latest_progress: + return latest_enrollment + + enrollment_with_latest_progress = self.queryset.filter( + course_id=latest_progress.course_id, + user__username=self.kwargs['username'], + ).first() + + if latest_enrollment.created > latest_progress.modified: + return latest_enrollment + else: + return enrollment_with_latest_progress + # pylint: disable=attribute-defined-outside-init @property def paginator(self): @@ -394,7 +456,7 @@ def paginator(self): super().paginator # pylint: disable=expression-not-assigned api_version = self.kwargs.get('api_version') - if self._paginator is None and api_version == API_V3: + if self._paginator is None and api_version in (API_V3, API_V4): self._paginator = DefaultPagination() return self._paginator diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index 73a0cfea0827..9204b27ab49b 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -6,6 +6,7 @@ API_V1 = 'v1' API_V2 = 'v2' API_V3 = 'v3' +API_V4 = 'v4' def parsed_version(version): diff --git a/lms/urls.py b/lms/urls.py index 5ac6283fddc7..15e374dd8551 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -218,7 +218,7 @@ if settings.FEATURES.get('ENABLE_MOBILE_REST_API'): urlpatterns += [ - re_path(r'^api/mobile/(?Pv(3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), + re_path(r'^api/mobile/(?Pv(4|3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), ] urlpatterns += [ From a6ecd16ea9f2a01172bb54586c23585a620c0bb4 Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Fri, 22 Mar 2024 14:01:30 +0200 Subject: [PATCH 02/26] feat: [AXM-47] Add course_status field to primary object (#2517) --- .../mobile_api/users/serializers.py | 87 +++++++++++++++++ lms/djangoapps/mobile_api/users/tests.py | 94 +++++++++++++++++-- lms/djangoapps/mobile_api/users/views.py | 24 ++++- 3 files changed, 196 insertions(+), 9 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index d7005e5f68e7..944f3c36defd 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -2,7 +2,10 @@ Serializer for user API """ +from typing import Dict, List, Optional, Tuple +from completion.exceptions import UnavailableCompletionData +from completion.utilities import get_key_to_last_completed_block from rest_framework import serializers from rest_framework.reverse import reverse @@ -11,7 +14,11 @@ from common.djangoapps.util.course import get_encoded_course_sharing_utm_params, get_link_for_about_page from lms.djangoapps.certificates.api import certificate_downloadable_status from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.courseware.block_render import get_block_for_descriptor +from lms.djangoapps.courseware.courses import get_current_child +from lms.djangoapps.courseware.model_data import FieldDataCache from openedx.features.course_duration_limits.access import get_user_course_expiration_date +from xmodule.modulestore.django import modulestore class CourseOverviewField(serializers.RelatedField): # lint-amnesty, pylint: disable=abstract-method @@ -141,6 +148,86 @@ class Meta: lookup_field = 'username' +class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer): + """ + Serializes CourseEnrollment models for API v4. + + Adds `course_status` field into serializer data. + """ + course_status = serializers.SerializerMethodField() + + def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[str]]]: + """ + Gets course status for the given user's enrollments. + """ + try: + block_id = str(get_key_to_last_completed_block(model.user, model.course.id)) + except UnavailableCompletionData: + block_id = "" + + if not block_id: + return None + + request = self.context.get('request') + path, unit_name = self._get_last_visited_block_path_and_unit_name(request, model) + path_ids = [str(block.location) for block in path] + + return { + 'last_visited_module_id': path_ids[0], + 'last_visited_module_path': path_ids, + 'last_visited_block_id': block_id, + 'last_visited_unit_display_name': unit_name, + } + + @staticmethod + def _get_last_visited_block_path_and_unit_name( + request: 'Request', # noqa: F821 + model: CourseEnrollment, + ) -> Tuple[List[Optional['XBlock']], Optional[str]]: # noqa: F821 + """ + Returns the path to the latest block and unit name visited by the current user. + + If there is no such visit, the first item deep enough down the course + tree is used. + """ + course = modulestore().get_course(model.course.id) + field_data_cache = FieldDataCache.cache_for_block_descendents( + course.id, model.user, course, depth=3) + + course_block = get_block_for_descriptor( + model.user, request, course, field_data_cache, course.id, course=course + ) + + unit_name = '' + path = [course_block] if course_block else [] + chapter = get_current_child(course_block, min_depth=3) + if chapter is not None: + path.append(chapter) + section = get_current_child(chapter, min_depth=2) + if section is not None: + path.append(section) + unit = get_current_child(section, min_depth=1) + if unit is not None: + unit_name = unit.display_name + + path.reverse() + return path, unit_name + + class Meta: + model = CourseEnrollment + fields = ( + 'audit_access_expires', + 'created', + 'mode', + 'is_active', + 'course', + 'certificate', + 'course_modes', + 'course_status', + ) + lookup_field = 'username' + + class UserSerializer(serializers.ModelSerializer): """ Serializes User models diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 08dc255426c2..8000576937bc 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -4,7 +4,7 @@ import datetime -from unittest.mock import patch +from unittest.mock import MagicMock, patch from urllib.parse import parse_qs import ddt @@ -492,8 +492,8 @@ def test_student_have_more_then_ten_enrollments(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['enrollments']['count'], 15) - self.assertEqual(response.data['enrollments']['num_pages'], 2) - self.assertEqual(len(response.data['enrollments']['results']), 10) + self.assertEqual(response.data['enrollments']['num_pages'], 3) + self.assertEqual(len(response.data['enrollments']['results']), 5) self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(latest_enrolment.id)) @@ -514,7 +514,7 @@ def test_student_have_progress_in_old_course_and_enroll_newest_course(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['enrollments']['count'], 6) - self.assertEqual(len(response.data['enrollments']['results']), 6) + self.assertEqual(len(response.data['enrollments']['results']), 5) # check that we have the new_course in primary section self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) @@ -529,7 +529,7 @@ def test_student_have_progress_in_old_course_and_enroll_newest_course(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['enrollments']['count'], 6) - self.assertEqual(len(response.data['enrollments']['results']), 6) + self.assertEqual(len(response.data['enrollments']['results']), 5) # check that now we have the old_course in primary section self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(old_course.id)) @@ -542,7 +542,7 @@ def test_student_have_progress_in_old_course_and_enroll_newest_course(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['enrollments']['count'], 7) - self.assertEqual(len(response.data['enrollments']['results']), 7) + self.assertEqual(len(response.data['enrollments']['results']), 5) # check that now we have the newest_course in primary section self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(newest_course.id)) @@ -613,6 +613,88 @@ def test_do_progress_in_not_mobile_available_course(self): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) + def test_pagination_for_user_enrollments_api_v4(self): + """ + Tests `UserCourseEnrollmentsV4Pagination`, api_version == v4. + """ + self.login() + courses = [CourseFactory.create(org="my_org", mobile_available=True) for _ in range(15)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['enrollments']['count'], 14) + self.assertEqual(response.data['enrollments']['num_pages'], 3) + self.assertEqual(response.data['enrollments']['current_page'], 1) + self.assertEqual(len(response.data['enrollments']['results']), 5) + self.assertIn('next', response.data['enrollments']) + self.assertIn('previous', response.data['enrollments']) + self.assertIn('primary', response.data) + + def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['primary']['course_status'], None) + + @patch('lms.djangoapps.mobile_api.users.serializers.get_key_to_last_completed_block') + def test_course_status_in_primary_obj_when_student_have_progress( + self, + get_last_completed_block_mock: MagicMock, + ): + """ + Testing modified `UserCourseEnrollmentsList` view with api_version == v4. + """ + self.login() + # create test course structure + course = CourseFactory.create(org="edx", mobile_available=True) + section = BlockFactory.create( + parent=course, + category="chapter", + display_name="section", + ) + subsection = BlockFactory.create( + parent=section, + category="sequential", + display_name="subsection", + ) + vertical = BlockFactory.create( + parent=subsection, + category="vertical", + display_name="test unit", + ) + problem = BlockFactory.create( + parent=vertical, + category="problem", + display_name="problem", + ) + self.enroll(course.id) + get_last_completed_block_mock.return_value = problem.location + expected_course_status = { + 'last_visited_module_id': str(subsection.location), + 'last_visited_module_path': [ + str(subsection.location), + str(section.location), + str(course.location) + ], + 'last_visited_block_id': str(problem.location), + 'last_visited_unit_display_name': vertical.display_name, + } + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['primary']['course_status'], expected_course_status) + get_last_completed_block_mock.assert_called_once_with(self.user, course.id) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin): diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index d6d77dc1edf6..acf8b7179590 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -40,7 +40,12 @@ from .. import errors from ..decorators import mobile_course_access, mobile_view -from .serializers import CourseEnrollmentSerializer, CourseEnrollmentSerializerv05, UserSerializer +from .serializers import ( + CourseEnrollmentSerializer, + CourseEnrollmentSerializerModifiedForPrimary, + CourseEnrollmentSerializerv05, + UserSerializer, +) log = logging.getLogger(__name__) @@ -400,7 +405,10 @@ def list(self, request, *args, **kwargs): if api_version == API_V4: primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() if primary_enrollment_obj: - serializer = self.get_serializer(primary_enrollment_obj) + serializer = CourseEnrollmentSerializerModifiedForPrimary( + primary_enrollment_obj, + context=self.get_serializer_context(), + ) enrollment_data.update({'primary': serializer.data}) return Response(enrollment_data) @@ -456,8 +464,10 @@ def paginator(self): super().paginator # pylint: disable=expression-not-assigned api_version = self.kwargs.get('api_version') - if self._paginator is None and api_version in (API_V3, API_V4): + if self._paginator is None and api_version == API_V3: self._paginator = DefaultPagination() + if self._paginator is None and api_version == API_V4: + self._paginator = UserCourseEnrollmentsV4Pagination() return self._paginator @@ -472,3 +482,11 @@ def my_user_info(request, api_version): # updating it from the oauth2 related code is too complex user_logged_in.send(sender=User, user=request.user, request=request) return redirect("user-detail", api_version=api_version, username=request.user.username) + + +class UserCourseEnrollmentsV4Pagination(DefaultPagination): + """ + Pagination for `UserCourseEnrollments` API v4. + """ + page_size = 5 + max_page_size = 50 From bbc5a01335e45e379f2c03372fc4361ed50a92a8 Mon Sep 17 00:00:00 2001 From: NiedielnitsevIvan <81557788+NiedielnitsevIvan@users.noreply.github.com> Date: Fri, 22 Mar 2024 19:00:32 +0200 Subject: [PATCH 03/26] feat: [AXM-40] add courses progress to enrollment endpoint (#2519) * fix: workaround for staticcollection introduced in e40a01c * feat: [AXM-40] add courses progress to enrollment endpoint * refactor: [AXM-40] add caching to improve performance * refactor: [AXM-40] add progress only for primary course * refactor: [AXM-40] refactor enrollment caching optimization --------- Co-authored-by: Glib Glugovskiy --- .../mobile_api/users/serializers.py | 31 +++++++++++++++++++ lms/djangoapps/mobile_api/users/views.py | 16 ++++++---- 2 files changed, 41 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 944f3c36defd..64dffed1f045 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -4,6 +4,7 @@ from typing import Dict, List, Optional, Tuple +from django.core.cache import cache from completion.exceptions import UnavailableCompletionData from completion.utilities import get_key_to_last_completed_block from rest_framework import serializers @@ -17,6 +18,8 @@ from lms.djangoapps.courseware.block_render import get_block_for_descriptor from lms.djangoapps.courseware.courses import get_current_child from lms.djangoapps.courseware.model_data import FieldDataCache +from lms.djangoapps.grades.api import CourseGradeFactory +from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.features.course_duration_limits.access import get_user_course_expiration_date from xmodule.modulestore.django import modulestore @@ -154,7 +157,11 @@ class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer): Adds `course_status` field into serializer data. """ + course_status = serializers.SerializerMethodField() + progress = serializers.SerializerMethodField() + + BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[str]]]: """ @@ -213,6 +220,29 @@ def _get_last_visited_block_path_and_unit_name( path.reverse() return path, unit_name + def get_progress(self, model: CourseEnrollment) -> Dict[str, int]: + """ + Returns the progress of the user in the course. + """ + assert isinstance(model, CourseEnrollment), f'Expected CourseEnrollment, got {type(model)}' + is_staff = bool(has_access(model.user, 'staff', model.course.id)) + + cache_key = f'course_block_structure_{str(model.course.id)}_{model.user.id}' + collected_block_structure = cache.get(cache_key) + if not collected_block_structure: + collected_block_structure = get_block_structure_manager(model.course.id).get_collected() + cache.set(cache_key, collected_block_structure, self.BLOCK_STRUCTURE_CACHE_TIMEOUT) + + course_grade = CourseGradeFactory().read(model.user, collected_block_structure=collected_block_structure) + + # recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not) + course_grade.update(visible_grades_only=True, has_staff_access=is_staff) + subsection_grades = list(course_grade.subsection_grades.values()) + return { + 'num_points_earned': sum(map(lambda x: x.graded_total.earned if x.graded else 0, subsection_grades)), + 'num_points_possible': sum(map(lambda x: x.graded_total.possible if x.graded else 0, subsection_grades)), + } + class Meta: model = CourseEnrollment fields = ( @@ -224,6 +254,7 @@ class Meta: 'certificate', 'course_modes', 'course_status', + 'progress', ) lookup_field = 'username' diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index acf8b7179590..2463ef963b9e 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -4,6 +4,7 @@ import logging +from functools import cached_property from typing import List, Optional from completion.exceptions import UnavailableCompletionData @@ -324,7 +325,7 @@ class UserCourseEnrollmentsList(generics.ListAPIView): certified). * url: URL to the downloadable version of the certificate, if exists. """ - queryset = CourseEnrollment.objects.all() + lookup_field = 'username' # In Django Rest Framework v3, there is a default pagination @@ -352,6 +353,13 @@ def get_serializer_class(self): return CourseEnrollmentSerializerv05 return CourseEnrollmentSerializer + @cached_property + def queryset(self): + return CourseEnrollment.objects.all().select_related('course', 'user').filter( + user__username=self.kwargs['username'], + is_active=True + ).order_by('created').reverse() + def get_queryset(self): api_version = self.kwargs.get('api_version') mobile_available = self.get_mobile_available_enrollments() @@ -377,14 +385,10 @@ def get_mobile_available_enrollments(self) -> List[Optional[CourseEnrollment]]: """ Gets list with `CourseEnrollment` for mobile available courses. """ - enrollments = self.queryset.filter( - user__username=self.kwargs['username'], - is_active=True - ).order_by('created').reverse() org = self.request.query_params.get('org', None) same_org = ( - enrollment for enrollment in enrollments + enrollment for enrollment in self.queryset if enrollment.course_overview and self.is_org(org, enrollment.course_overview.org) ) mobile_available = ( From c2881dcc8269589903a461af2c23077be5f76761 Mon Sep 17 00:00:00 2001 From: NiedielnitsevIvan <81557788+NiedielnitsevIvan@users.noreply.github.com> Date: Tue, 2 Apr 2024 16:51:55 +0300 Subject: [PATCH 04/26] feat: [AXM-53] add assertions for primary course (#2522) * feat: [AXM-53] add assertions for primary course * test: [AXM-53] fix tests * style: [AXM-53] change future_assignment default value to None * refactor: [AXM-53] add some optimization for assignments collecting --- .../mobile_api/users/serializers.py | 45 ++++++++++++++++--- lms/djangoapps/mobile_api/users/tests.py | 25 ++++++++--- lms/djangoapps/mobile_api/users/views.py | 12 ++++- 3 files changed, 67 insertions(+), 15 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 64dffed1f045..0a17dbb5a82f 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -2,6 +2,7 @@ Serializer for user API """ +from datetime import datetime from typing import Dict, List, Optional, Tuple from django.core.cache import cache @@ -16,8 +17,10 @@ from lms.djangoapps.certificates.api import certificate_downloadable_status from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.block_render import get_block_for_descriptor -from lms.djangoapps.courseware.courses import get_current_child +from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc +from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks, get_current_child from lms.djangoapps.courseware.model_data import FieldDataCache +from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer from lms.djangoapps.grades.api import CourseGradeFactory from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.features.course_duration_limits.access import get_user_course_expiration_date @@ -160,9 +163,14 @@ class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer): course_status = serializers.SerializerMethodField() progress = serializers.SerializerMethodField() + course_assignments = serializers.SerializerMethodField() BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.course = modulestore().get_course(self.instance.course.id) + def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[str]]]: """ Gets course status for the given user's enrollments. @@ -186,8 +194,8 @@ def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[ 'last_visited_unit_display_name': unit_name, } - @staticmethod def _get_last_visited_block_path_and_unit_name( + self, request: 'Request', # noqa: F821 model: CourseEnrollment, ) -> Tuple[List[Optional['XBlock']], Optional[str]]: # noqa: F821 @@ -197,12 +205,10 @@ def _get_last_visited_block_path_and_unit_name( If there is no such visit, the first item deep enough down the course tree is used. """ - course = modulestore().get_course(model.course.id) - field_data_cache = FieldDataCache.cache_for_block_descendents( - course.id, model.user, course, depth=3) + field_data_cache = FieldDataCache.cache_for_block_descendents(self.course.id, model.user, self.course, depth=3) course_block = get_block_for_descriptor( - model.user, request, course, field_data_cache, course.id, course=course + model.user, request, self.course, field_data_cache, self.course.id, course=self.course ) unit_name = '' @@ -243,6 +249,32 @@ def get_progress(self, model: CourseEnrollment) -> Dict[str, int]: 'num_points_possible': sum(map(lambda x: x.graded_total.possible if x.graded else 0, subsection_grades)), } + def get_course_assignments(self, model: CourseEnrollment) -> Optional[Dict[str, List[Dict[str, str]]]]: + """ + Returns the future assignment data and past assignments data for the user in the course. + """ + assignments = get_course_assignment_date_blocks( + self.course, + model.user, + self.context.get('request'), + include_past_dates=True + ) + next_assignment = None + past_assignment = [] + + timezone = get_user_timezone_or_last_seen_timezone_or_utc(model.user) + for assignment in sorted(assignments, key=lambda x: x.date): + if assignment.date < datetime.now(timezone): + past_assignment.append(assignment) + else: + next_assignment = DateSummarySerializer(assignment).data + break + + return { + 'future_assignment': next_assignment, + 'past_assignments': DateSummarySerializer(past_assignment, many=True).data, + } + class Meta: model = CourseEnrollment fields = ( @@ -255,6 +287,7 @@ class Meta: 'course_modes', 'course_status', 'progress', + 'course_assignments', ) lookup_field = 'username' diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 8000576937bc..f1a9798c8108 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -417,6 +417,7 @@ def test_student_dont_have_enrollments(self): 'configs': { 'iap_configs': {} }, + 'user_timezone': 'UTC', 'enrollments': { 'next': None, 'previous': None, @@ -434,7 +435,8 @@ def test_student_dont_have_enrollments(self): self.assertDictEqual(expected_result, response.data) self.assertNotIn('primary', response.data) - def test_student_have_one_enrollment(self): + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_student_have_one_enrollment(self, cache_mock: MagicMock): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -458,7 +460,8 @@ def test_student_have_one_enrollment(self): self.assertIn('primary', response.data) self.assertEqual(str(course.id), response.data['primary']['course']['id']) - def test_student_have_two_enrollments(self): + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_student_have_two_enrollments(self, cache_mock: MagicMock): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -477,7 +480,8 @@ def test_student_have_two_enrollments(self): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(course_second.id)) - def test_student_have_more_then_ten_enrollments(self): + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_student_have_more_then_ten_enrollments(self, cache_mock: MagicMock): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -497,7 +501,8 @@ def test_student_have_more_then_ten_enrollments(self): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(latest_enrolment.id)) - def test_student_have_progress_in_old_course_and_enroll_newest_course(self): + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_student_have_progress_in_old_course_and_enroll_newest_course(self, cache_mock: MagicMock): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -559,6 +564,7 @@ def test_student_enrolled_only_not_mobile_available_courses(self): "configs": { "iap_configs": {} }, + "user_timezone": "UTC", "enrollments": { "next": None, "previous": None, @@ -576,7 +582,8 @@ def test_student_enrolled_only_not_mobile_available_courses(self): self.assertDictEqual(expected_result, response.data) self.assertNotIn('primary', response.data) - def test_do_progress_in_not_mobile_available_course(self): + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_do_progress_in_not_mobile_available_course(self, cache_mock: MagicMock): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -613,7 +620,8 @@ def test_do_progress_in_not_mobile_available_course(self): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) - def test_pagination_for_user_enrollments_api_v4(self): + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_pagination_for_user_enrollments_api_v4(self, cache_mock: MagicMock): """ Tests `UserCourseEnrollmentsV4Pagination`, api_version == v4. """ @@ -632,7 +640,8 @@ def test_pagination_for_user_enrollments_api_v4(self): self.assertIn('previous', response.data['enrollments']) self.assertIn('primary', response.data) - def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self): + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self, cache_mock: MagicMock): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -645,10 +654,12 @@ def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['primary']['course_status'], None) + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) @patch('lms.djangoapps.mobile_api.users.serializers.get_key_to_last_completed_block') def test_course_status_in_primary_obj_when_student_have_progress( self, get_last_completed_block_mock: MagicMock, + cache_mock: MagicMock ): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index 2463ef963b9e..2c5e7736b288 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -12,7 +12,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.auth.signals import user_logged_in from django.db import transaction -from django.shortcuts import redirect +from django.shortcuts import get_object_or_404, redirect from django.utils import dateparse from django.utils.decorators import method_decorator from opaque_keys import InvalidKeyError @@ -28,6 +28,7 @@ from common.djangoapps.student.models import CourseEnrollment, User # lint-amnesty, pylint: disable=reimported from lms.djangoapps.courseware.access import is_mobile_available_for_user from lms.djangoapps.courseware.access_utils import ACCESS_GRANTED +from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc from lms.djangoapps.courseware.courses import get_current_child from lms.djangoapps.courseware.model_data import FieldDataCache from lms.djangoapps.courseware.block_render import get_block_for_descriptor @@ -358,7 +359,7 @@ def queryset(self): return CourseEnrollment.objects.all().select_related('course', 'user').filter( user__username=self.kwargs['username'], is_active=True - ).order_by('created').reverse() + ).order_by('-created') def get_queryset(self): api_version = self.kwargs.get('api_version') @@ -404,6 +405,7 @@ def list(self, request, *args, **kwargs): if api_version in (API_V2, API_V3, API_V4): enrollment_data = { 'configs': MobileConfig.get_structured_configs(), + 'user_timezone': str(get_user_timezone_or_last_seen_timezone_or_utc(self.get_user())), 'enrollments': response.data } if api_version == API_V4: @@ -419,6 +421,12 @@ def list(self, request, *args, **kwargs): return response + def get_user(self) -> User: + """ + Get user object by username. + """ + return get_object_or_404(User, username=self.kwargs['username']) + def get_primary_enrollment_by_latest_enrollment_or_progress(self) -> Optional[CourseEnrollment]: """ Gets primary enrollment obj by latest enrollment or latest progress on the course. From d0cb0916aabe96ae25371e1c061a6296cd13e4df Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Mon, 8 Apr 2024 13:03:33 +0300 Subject: [PATCH 05/26] feat: [AXM-200] Implement user's enrolments status API (#2530) * feat: [AXM-24] Update structure for course enrollments API (#2515) * feat: [AXM-24] Update structure for course enrollments API * style: [AXM-24] Improve code style * fix: [AXM-24] Fix student's latest enrollment filter * feat: [AXM-47] Add course_status field to primary object (#2517) * feat: [AXM-40] add courses progress to enrollment endpoint (#2519) * fix: workaround for staticcollection introduced in e40a01c * feat: [AXM-40] add courses progress to enrollment endpoint * refactor: [AXM-40] add caching to improve performance * refactor: [AXM-40] add progress only for primary course * refactor: [AXM-40] refactor enrollment caching optimization --------- Co-authored-by: Glib Glugovskiy * feat: [AXM-53] add assertions for primary course (#2522) * feat: [AXM-53] add assertions for primary course * test: [AXM-53] fix tests * style: [AXM-53] change future_assignment default value to None * refactor: [AXM-53] add some optimization for assignments collecting * feat: [AXM-200] Implement user's enrolments status API * style: [AXM-200] Improve code style * refactor: [AXM-200] Divide get method into smaller methods --------- Co-authored-by: NiedielnitsevIvan <81557788+NiedielnitsevIvan@users.noreply.github.com> Co-authored-by: Glib Glugovskiy --- lms/djangoapps/mobile_api/users/tests.py | 135 +++++++++++++++++++++++ lms/djangoapps/mobile_api/users/urls.py | 7 +- lms/djangoapps/mobile_api/users/views.py | 122 +++++++++++++++++++- 3 files changed, 261 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index f1a9798c8108..bad320b9db05 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -9,6 +9,7 @@ import ddt import pytz +from completion.models import BlockCompletion from completion.test_utils import CompletionWaffleTestMixin, submit_completions_for_testing from django.conf import settings from django.db import transaction @@ -1068,3 +1069,137 @@ def test_discussion_tab_url(self, discussion_tab_enabled): assert isinstance(discussion_url, str) else: assert discussion_url is None + + +@ddt.ddt +class UserEnrollmentsStatus(MobileAPITestCase, MobileAuthUserTestMixin): + """ + Tests for /api/mobile/{api_version}/users//enrollments_status/ + """ + + REVERSE_INFO = {'name': 'user-enrollments-status', 'params': ['username', 'api_version']} + + def test_no_mobile_available_courses(self) -> None: + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=False) for _ in range(3)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V1) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, []) + + def test_no_enrollments(self) -> None: + self.login() + for _ in range(3): + CourseFactory.create(org="edx", mobile_available=True) + + response = self.api_response(api_version=API_V1) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, []) + + def test_user_have_only_active_enrollments_and_no_completions(self) -> None: + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(3)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V1) + + expected_response = [ + {'course_id': str(courses[0].course_id), 'course_name': courses[0].display_name, 'is_active': True}, + {'course_id': str(courses[1].course_id), 'course_name': courses[1].display_name, 'is_active': True}, + {'course_id': str(courses[2].course_id), 'course_name': courses[2].display_name, 'is_active': True}, + ] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, expected_response) + + def test_user_have_active_and_inactive_enrollments_and_no_completions(self) -> None: + self.login() + courses = [CourseFactory.create(org="edx", mobile_available=True) for _ in range(3)] + for course in courses: + self.enroll(course.id) + old_course = CourseFactory.create(org="edx", mobile_available=True) + self.enroll(old_course.id) + old_enrollment = CourseEnrollment.objects.filter(user=self.user, course=old_course.course_id).first() + old_enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=31) + old_enrollment.save() + + response = self.api_response(api_version=API_V1) + + expected_response = [ + {'course_id': str(courses[0].course_id), 'course_name': courses[0].display_name, 'is_active': True}, + {'course_id': str(courses[1].course_id), 'course_name': courses[1].display_name, 'is_active': True}, + {'course_id': str(courses[2].course_id), 'course_name': courses[2].display_name, 'is_active': True}, + {'course_id': str(old_course.course_id), 'course_name': old_course.display_name, 'is_active': False} + ] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, expected_response) + + @ddt.data( + (27, True), + (28, True), + (29, True), + (31, False), + (32, False), + ) + @ddt.unpack + def test_different_enrollment_dates(self, enrolled_days_ago: int, is_active_status: bool) -> None: + self.login() + course = CourseFactory.create(org="edx", mobile_available=True, run='1001') + self.enroll(course.id) + enrollment = CourseEnrollment.objects.filter(user=self.user, course=course.course_id).first() + enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=enrolled_days_ago) + enrollment.save() + + response = self.api_response(api_version=API_V1) + + expected_response = [ + {'course_id': str(course.course_id), 'course_name': course.display_name, 'is_active': is_active_status} + ] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, expected_response) + + @ddt.data( + (27, True), + (28, True), + (29, True), + (31, False), + (32, False), + ) + @ddt.unpack + def test_different_completion_dates(self, completed_days_ago: int, is_active_status: bool) -> None: + self.login() + course = CourseFactory.create(org="edx", mobile_available=True, run='1010') + section = BlockFactory.create( + parent=course, + category='chapter', + ) + self.enroll(course.id) + enrollment = CourseEnrollment.objects.filter(user=self.user, course=course.course_id).first() + # make enrollment older 30 days ago + enrollment.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=50) + enrollment.save() + completion = BlockCompletion.objects.create( + user=self.user, + context_key=course.context_key, + block_type='course', + block_key=section.location, + completion=0.5, + ) + completion.created = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=completed_days_ago) + completion.save() + + response = self.api_response(api_version=API_V1) + + expected_response = [ + {'course_id': str(course.course_id), 'course_name': course.display_name, 'is_active': is_active_status} + ] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data, expected_response) diff --git a/lms/djangoapps/mobile_api/users/urls.py b/lms/djangoapps/mobile_api/users/urls.py index 266644246e88..874730d4d0f0 100644 --- a/lms/djangoapps/mobile_api/users/urls.py +++ b/lms/djangoapps/mobile_api/users/urls.py @@ -6,7 +6,7 @@ from django.conf import settings from django.urls import re_path -from .views import UserCourseEnrollmentsList, UserCourseStatus, UserDetail +from .views import UserCourseEnrollmentsList, UserCourseStatus, UserDetail, UserEnrollmentsStatus urlpatterns = [ re_path('^' + settings.USERNAME_PATTERN + '$', UserDetail.as_view(), name='user-detail'), @@ -17,5 +17,8 @@ ), re_path(f'^{settings.USERNAME_PATTERN}/course_status_info/{settings.COURSE_ID_PATTERN}', UserCourseStatus.as_view(), - name='user-course-status') + name='user-course-status'), + re_path(f'^{settings.USERNAME_PATTERN}/enrollments_status/', + UserEnrollmentsStatus.as_view(), + name='user-enrollments-status') ] diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index 2c5e7736b288..e862f3893dd7 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -3,11 +3,14 @@ """ +import datetime import logging from functools import cached_property -from typing import List, Optional +from typing import Dict, List, Optional +import pytz from completion.exceptions import UnavailableCompletionData +from completion.models import BlockCompletion from completion.utilities import get_key_to_last_completed_block from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.auth.signals import user_logged_in @@ -502,3 +505,120 @@ class UserCourseEnrollmentsV4Pagination(DefaultPagination): """ page_size = 5 max_page_size = 50 + + +@mobile_view(is_user=True) +class UserEnrollmentsStatus(views.APIView): + """ + **Use Case** + + Get information about user's enrolments status. + + Returns active enrolment status if user was enrolled for the course + less than 30 days ago or has progressed in the course in the last 30 days. + Otherwise, the registration is considered inactive. + + **Example Request** + + GET /api/mobile/{api_version}/users//enrollments_status/ + + **Response Values** + + If the request for information about the user's enrolments is successful, the + request returns an HTTP 200 "OK" response. + + The HTTP 200 response has the following values. + + * course_id (str): The course id associated with the user's enrollment. + * course_name (str): The course name associated with the user's enrollment. + * is_active (bool): User's course enrolment status. + + + The HTTP 200 response contains a list of dictionaries that contain info + about each user's enrolment status. + + **Example Response** + + ```json + [ + { + "course_id": "course-v1:a+a+a", + "course_name": "a", + "is_active": true + }, + { + "course_id": "course-v1:b+b+b", + "course_name": "b", + "is_active": true + }, + { + "course_id": "course-v1:c+c+c", + "course_name": "c", + "is_active": false + }, + ... + ] + ``` + """ + def get(self, request, *args, **kwargs) -> Response: + """ + Gets user's enrollments status. + """ + active_status_date = datetime.datetime.now(pytz.UTC) - datetime.timedelta(days=30) + username = kwargs.get('username') + course_ids_where_user_has_completions = self._get_course_ids_where_user_has_completions( + username, + active_status_date, + ) + enrollments_status = self._build_enrollments_status_dict( + username, + active_status_date, + course_ids_where_user_has_completions + ) + return Response(enrollments_status) + + def _build_enrollments_status_dict( + self, + username: str, + active_status_date: datetime, + course_ids: List[str], + ) -> List[Dict[str, bool]]: + """ + Builds list with dictionaries with user's enrolments statuses. + """ + user_enrollments = CourseEnrollment.objects.filter( + user__username=username, + is_active=True, + ) + mobile_available = [ + enrollment for enrollment in user_enrollments + if is_mobile_available_for_user(self.request.user, enrollment.course_overview) + ] + enrollments_status = [] + for user_enrollment in mobile_available: + course_id = str(user_enrollment.course_overview.id) + enrollments_status.append( + { + 'course_id': course_id, + 'course_name': user_enrollment.course_overview.display_name, + 'is_active': bool( + course_id in course_ids + or user_enrollment.created > active_status_date + ) + } + ) + return enrollments_status + + @staticmethod + def _get_course_ids_where_user_has_completions( + username: str, + active_status_date: datetime, + ) -> List[str]: + """ + Gets course ids where user has completions. + """ + user_completions_last_month = BlockCompletion.objects.filter( + user__username=username, + created__gte=active_status_date + ) + return [str(completion.block_key.course_key) for completion in user_completions_last_month] From a32b1441b8d93d261252289ca443f9f23b0ed89a Mon Sep 17 00:00:00 2001 From: NiedielnitsevIvan <81557788+NiedielnitsevIvan@users.noreply.github.com> Date: Mon, 8 Apr 2024 15:21:33 +0300 Subject: [PATCH 06/26] feat: [AXM-33] create enrollments filtering by course completion statuses (#2532) * feat: [AXM-33] create enrollments filtering by course completion statuses * test: [AXM-33] add tests for filtrations * style: [AXM-33] fix pylint issues --- .../student/models/course_enrollment.py | 60 ++++++ lms/djangoapps/mobile_api/users/enums.py | 22 ++ .../mobile_api/users/serializers.py | 2 +- lms/djangoapps/mobile_api/users/tests.py | 196 ++++++++++++++++++ lms/djangoapps/mobile_api/users/views.py | 56 +++-- .../features/course_duration_limits/access.py | 4 +- 6 files changed, 325 insertions(+), 15 deletions(-) create mode 100644 lms/djangoapps/mobile_api/users/enums.py diff --git a/common/djangoapps/student/models/course_enrollment.py b/common/djangoapps/student/models/course_enrollment.py index 318a3afd8316..74cb5723167d 100644 --- a/common/djangoapps/student/models/course_enrollment.py +++ b/common/djangoapps/student/models/course_enrollment.py @@ -129,11 +129,71 @@ class UnenrollmentNotAllowed(CourseEnrollmentException): pass +class CourseEnrollmentQuerySet(models.QuerySet): + """ + Custom queryset for CourseEnrollment with Table-level filter methods. + """ + + def active(self): + """ + Returns a queryset of CourseEnrollment objects for courses that are currently active. + """ + return self.filter(is_active=True) + + def without_certificates(self, user_username): + """ + Returns a queryset of CourseEnrollment objects for courses that do not have a certificate. + """ + from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel + course_ids_with_certificates = GeneratedCertificate.objects.filter( + user__username=user_username + ).values_list('course_id', flat=True) + return self.exclude(course_id__in=course_ids_with_certificates) + + def with_certificates(self, user_username): + """ + Returns a queryset of CourseEnrollment objects for courses that have a certificate. + """ + from lms.djangoapps.certificates.models import GeneratedCertificate # pylint: disable=import-outside-toplevel + course_ids_with_certificates = GeneratedCertificate.objects.filter( + user__username=user_username + ).values_list('course_id', flat=True) + return self.filter(course_id__in=course_ids_with_certificates) + + def in_progress(self, user_username, time_zone=UTC): + """ + Returns a queryset of CourseEnrollment objects for courses that are currently in progress. + """ + now = datetime.now(time_zone) + return self.active().without_certificates(user_username).filter( + Q(course__start__lte=now, course__end__gte=now) + | Q(course__start__isnull=True, course__end__isnull=True) + | Q(course__start__isnull=True, course__end__gte=now) + | Q(course__start__lte=now, course__end__isnull=True), + ) + + def completed(self, user_username): + """ + Returns a queryset of CourseEnrollment objects for courses that have been completed. + """ + return self.active().with_certificates(user_username) + + def expired(self, user_username, time_zone=UTC): + """ + Returns a queryset of CourseEnrollment objects for courses that have expired. + """ + now = datetime.now(time_zone) + return self.active().without_certificates(user_username).filter(course__end__lt=now) + + class CourseEnrollmentManager(models.Manager): """ Custom manager for CourseEnrollment with Table-level filter methods. """ + def get_queryset(self): + return CourseEnrollmentQuerySet(self.model, using=self._db) + def is_small_course(self, course_id): """ Returns false if the number of enrollments are one greater than 'max_enrollments' else true diff --git a/lms/djangoapps/mobile_api/users/enums.py b/lms/djangoapps/mobile_api/users/enums.py new file mode 100644 index 000000000000..2a072b082fff --- /dev/null +++ b/lms/djangoapps/mobile_api/users/enums.py @@ -0,0 +1,22 @@ +""" +Enums for mobile_api users app. +""" +from enum import Enum + + +class EnrollmentStatuses(Enum): + """ + Enum for enrollment statuses. + """ + + ALL = 'all' + IN_PROGRESS = 'in_progress' + COMPLETED = 'completed' + EXPIRED = 'expired' + + @classmethod + def values(cls): + """ + Returns string representation of all enum values. + """ + return [e.value for e in cls] diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 0a17dbb5a82f..82eb3edee278 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -110,7 +110,7 @@ def get_audit_access_expires(self, model): """ Returns expiration date for a course audit expiration, if any or null """ - return get_user_course_expiration_date(model.user, model.course) + return get_user_course_expiration_date(model.user, model.course, model) def get_certificate(self, model): """Returns the information about the user's certificate in the course.""" diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index bad320b9db05..31ff2a059ef9 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -37,6 +37,7 @@ MobileAuthUserTestMixin, MobileCourseAccessTestMixin ) +from lms.djangoapps.mobile_api.users.enums import EnrollmentStatuses from lms.djangoapps.mobile_api.utils import API_V1, API_V05, API_V2, API_V3, API_V4 from openedx.core.lib.courses import course_image_url from openedx.core.djangoapps.discussions.models import DiscussionsConfiguration @@ -707,6 +708,201 @@ def test_course_status_in_primary_obj_when_student_have_progress( self.assertEqual(response.data['primary']['course_status'], expected_course_status) get_last_completed_block_mock.assert_called_once_with(self.user, course.id) + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_user_enrollment_api_v4_in_progress_status(self, cache_mock: MagicMock): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.IN_PROGRESS.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 2) + self.assertEqual(enrollments['results'][1]['course']['id'], str(actual_course.id)) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_completed_status(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=infinite_course.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.COMPLETED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_expired_status(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.EXPIRED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(old_course.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_expired_course_with_certificate(self): + """ + Testing that the API returns a course with + an expiration date in the past if the user has a certificate for this course. + """ + self.login() + expired_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + expired_course_with_cert = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=expired_course_with_cert.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(expired_course_with_cert.id) + self.enroll(expired_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.COMPLETED.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 1) + self.assertEqual(enrollments['results'][0]['course']['id'], str(expired_course_with_cert.id)) + self.assertNotIn('primary', response.data) + + def test_user_enrollment_api_v4_status_all(self): + """ + Testing + """ + self.login() + old_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.THREE_YEARS_AGO, + end=self.LAST_WEEK + ) + actual_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=self.NEXT_WEEK + ) + infinite_course = CourseFactory.create( + org="edx", + mobile_available=True, + start=self.LAST_WEEK, + end=None + ) + GeneratedCertificateFactory.create( + user=self.user, + course_id=infinite_course.id, + status=CertificateStatuses.downloadable, + mode='verified', + ) + + self.enroll(old_course.id) + self.enroll(actual_course.id) + self.enroll(infinite_course.id) + + response = self.api_response(api_version=API_V4, data={'status': EnrollmentStatuses.ALL.value}) + enrollments = response.data['enrollments'] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(enrollments['count'], 3) + self.assertEqual(enrollments['results'][0]['course']['id'], str(infinite_course.id)) + self.assertEqual(enrollments['results'][1]['course']['id'], str(actual_course.id)) + self.assertEqual(enrollments['results'][2]['course']['id'], str(old_course.id)) + self.assertNotIn('primary', response.data) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin): diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index e862f3893dd7..b255ef72eda2 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -45,6 +45,7 @@ from .. import errors from ..decorators import mobile_course_access, mobile_view +from .enums import EnrollmentStatuses from .serializers import ( CourseEnrollmentSerializer, CourseEnrollmentSerializerModifiedForPrimary, @@ -359,13 +360,33 @@ def get_serializer_class(self): @cached_property def queryset(self): - return CourseEnrollment.objects.all().select_related('course', 'user').filter( - user__username=self.kwargs['username'], + """ + Find and return the list of course enrollments for the user. + + In v4 added filtering by statuses. + """ + api_version = self.kwargs.get('api_version') + status = self.request.GET.get('status') + username = self.kwargs['username'] + + queryset = CourseEnrollment.objects.all().select_related('course', 'user').filter( + user__username=username, is_active=True ).order_by('-created') + if api_version == API_V4 and status in EnrollmentStatuses.values(): + if status == EnrollmentStatuses.IN_PROGRESS.value: + queryset = queryset.in_progress(user_username=username, time_zone=self.user_timezone) + elif status == EnrollmentStatuses.COMPLETED.value: + queryset = queryset.completed(user_username=username) + elif status == EnrollmentStatuses.EXPIRED.value: + queryset = queryset.expired(user_username=username, time_zone=self.user_timezone) + + return queryset + def get_queryset(self): api_version = self.kwargs.get('api_version') + status = self.request.GET.get('status') mobile_available = self.get_mobile_available_enrollments() not_duration_limited = ( @@ -373,7 +394,7 @@ def get_queryset(self): if check_course_expired(self.request.user, enrollment.course) == ACCESS_GRANTED ) - if api_version == API_V4: + if api_version == API_V4 and status not in EnrollmentStatuses.values(): primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() if primary_enrollment_obj: mobile_available.remove(primary_enrollment_obj) @@ -404,26 +425,37 @@ def get_mobile_available_enrollments(self) -> List[Optional[CourseEnrollment]]: def list(self, request, *args, **kwargs): response = super().list(request, *args, **kwargs) api_version = self.kwargs.get('api_version') + status = self.request.GET.get('status') if api_version in (API_V2, API_V3, API_V4): enrollment_data = { 'configs': MobileConfig.get_structured_configs(), - 'user_timezone': str(get_user_timezone_or_last_seen_timezone_or_utc(self.get_user())), + 'user_timezone': str(self.user_timezone), 'enrollments': response.data } - if api_version == API_V4: - primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() - if primary_enrollment_obj: - serializer = CourseEnrollmentSerializerModifiedForPrimary( - primary_enrollment_obj, - context=self.get_serializer_context(), - ) - enrollment_data.update({'primary': serializer.data}) + if api_version == API_V4 and status not in EnrollmentStatuses.values(): + if status in EnrollmentStatuses.values(): + enrollment_data.update({'primary': None}) + else: + primary_enrollment_obj = self.get_primary_enrollment_by_latest_enrollment_or_progress() + if primary_enrollment_obj: + serializer = CourseEnrollmentSerializerModifiedForPrimary( + primary_enrollment_obj, + context=self.get_serializer_context(), + ) + enrollment_data.update({'primary': serializer.data}) return Response(enrollment_data) return response + @cached_property + def user_timezone(self): + """ + Get the user's timezone. + """ + return get_user_timezone_or_last_seen_timezone_or_utc(self.get_user()) + def get_user(self) -> User: """ Get user object by username. diff --git a/openedx/features/course_duration_limits/access.py b/openedx/features/course_duration_limits/access.py index ff817a315054..08a94702a5e0 100644 --- a/openedx/features/course_duration_limits/access.py +++ b/openedx/features/course_duration_limits/access.py @@ -68,7 +68,7 @@ def get_user_course_duration(user, course): return get_expected_duration(course.id) -def get_user_course_expiration_date(user, course): +def get_user_course_expiration_date(user, course, enrollment=None): """ Return expiration date for given user course pair. Return None if the course does not expire. @@ -81,7 +81,7 @@ def get_user_course_expiration_date(user, course): if access_duration is None: return None - enrollment = CourseEnrollment.get_enrollment(user, course.id) + enrollment = CourseEnrollment.get_enrollment(user, course.id) if not enrollment else enrollment if enrollment is None or enrollment.mode != CourseMode.AUDIT: return None From 8f9affdac9434bdf7d4526ba309066907fb57d14 Mon Sep 17 00:00:00 2001 From: NiedielnitsevIvan <81557788+NiedielnitsevIvan@users.noreply.github.com> Date: Wed, 10 Apr 2024 17:55:02 +0300 Subject: [PATCH 07/26] feat: [AXM-236] Add progress for other courses (#2536) --- .../mobile_api/users/serializers.py | 57 ++++++++++++------- lms/djangoapps/mobile_api/users/views.py | 3 + 2 files changed, 40 insertions(+), 20 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 82eb3edee278..991205cc3091 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -106,6 +106,8 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): audit_access_expires = serializers.SerializerMethodField() course_modes = serializers.SerializerMethodField() + BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour + def get_audit_access_expires(self, model): """ Returns expiration date for a course audit expiration, if any or null @@ -137,6 +139,40 @@ def get_course_modes(self, obj): for mode in course_modes ] + def to_representation(self, instance): + """ + Override the to_representation method to add the course_status field to the serialized data. + """ + data = super().to_representation(instance) + if 'progress' in self.context.get('requested_fields', []): + data['progress'] = self.calculate_progress(instance) + + return data + + def calculate_progress(self, model: CourseEnrollment) -> Dict[str, int]: + """ + Calculate the progress of the user in the course. + :param model: + :return: + """ + is_staff = bool(has_access(model.user, 'staff', model.course.id)) + + cache_key = f'course_block_structure_{str(model.course.id)}_{model.user.id}' + collected_block_structure = cache.get(cache_key) + if not collected_block_structure: + collected_block_structure = get_block_structure_manager(model.course.id).get_collected() + cache.set(cache_key, collected_block_structure, self.BLOCK_STRUCTURE_CACHE_TIMEOUT) + + course_grade = CourseGradeFactory().read(model.user, collected_block_structure=collected_block_structure) + + # recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not) + course_grade.update(visible_grades_only=True, has_staff_access=is_staff) + subsection_grades = list(course_grade.subsection_grades.values()) + return { + 'num_points_earned': sum(map(lambda x: x.graded_total.earned if x.graded else 0, subsection_grades)), + 'num_points_possible': sum(map(lambda x: x.graded_total.possible if x.graded else 0, subsection_grades)), + } + class Meta: model = CourseEnrollment fields = ('audit_access_expires', 'created', 'mode', 'is_active', 'course', 'certificate', 'course_modes') @@ -165,8 +201,6 @@ class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer): progress = serializers.SerializerMethodField() course_assignments = serializers.SerializerMethodField() - BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour - def __init__(self, *args, **kwargs): super().__init__(*args, **kwargs) self.course = modulestore().get_course(self.instance.course.id) @@ -230,24 +264,7 @@ def get_progress(self, model: CourseEnrollment) -> Dict[str, int]: """ Returns the progress of the user in the course. """ - assert isinstance(model, CourseEnrollment), f'Expected CourseEnrollment, got {type(model)}' - is_staff = bool(has_access(model.user, 'staff', model.course.id)) - - cache_key = f'course_block_structure_{str(model.course.id)}_{model.user.id}' - collected_block_structure = cache.get(cache_key) - if not collected_block_structure: - collected_block_structure = get_block_structure_manager(model.course.id).get_collected() - cache.set(cache_key, collected_block_structure, self.BLOCK_STRUCTURE_CACHE_TIMEOUT) - - course_grade = CourseGradeFactory().read(model.user, collected_block_structure=collected_block_structure) - - # recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not) - course_grade.update(visible_grades_only=True, has_staff_access=is_staff) - subsection_grades = list(course_grade.subsection_grades.values()) - return { - 'num_points_earned': sum(map(lambda x: x.graded_total.earned if x.graded else 0, subsection_grades)), - 'num_points_possible': sum(map(lambda x: x.graded_total.possible if x.graded else 0, subsection_grades)), - } + return self.calculate_progress(model) def get_course_assignments(self, model: CourseEnrollment) -> Optional[Dict[str, List[Dict[str, str]]]]: """ diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index b255ef72eda2..2fd5d79cd00c 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -349,7 +349,10 @@ def is_org(self, check_org, course_org): def get_serializer_context(self): context = super().get_serializer_context() + requested_fields = self.request.GET.get('requested_fields', '') + context['api_version'] = self.kwargs.get('api_version') + context['requested_fields'] = requested_fields.split(',') return context def get_serializer_class(self): From ebcdde6d0c6083e04f1790f8f05068e43d15c4eb Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Tue, 16 Apr 2024 09:57:19 +0300 Subject: [PATCH 08/26] fix: [AXM-277] Change _get_last_visited_block_path_and_unit_name method implementation (#2540) --- .../mobile_api/users/serializers.py | 53 ++++++++----------- 1 file changed, 22 insertions(+), 31 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 991205cc3091..76453dc4a6b4 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -3,11 +3,13 @@ """ from datetime import datetime -from typing import Dict, List, Optional, Tuple +from typing import Dict, List, Optional, Tuple, Union from django.core.cache import cache from completion.exceptions import UnavailableCompletionData from completion.utilities import get_key_to_last_completed_block +from opaque_keys import InvalidKeyError +from opaque_keys.edx.keys import UsageKey from rest_framework import serializers from rest_framework.reverse import reverse @@ -16,15 +18,14 @@ from common.djangoapps.util.course import get_encoded_course_sharing_utm_params, get_link_for_about_page from lms.djangoapps.certificates.api import certificate_downloadable_status from lms.djangoapps.courseware.access import has_access -from lms.djangoapps.courseware.block_render import get_block_for_descriptor from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc -from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks, get_current_child -from lms.djangoapps.courseware.model_data import FieldDataCache +from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer from lms.djangoapps.grades.api import CourseGradeFactory from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager from openedx.features.course_duration_limits.access import get_user_course_expiration_date from xmodule.modulestore.django import modulestore +from xmodule.modulestore.exceptions import ItemNotFoundError class CourseOverviewField(serializers.RelatedField): # lint-amnesty, pylint: disable=abstract-method @@ -217,8 +218,10 @@ def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[ if not block_id: return None - request = self.context.get('request') - path, unit_name = self._get_last_visited_block_path_and_unit_name(request, model) + path, unit_name = self._get_last_visited_block_path_and_unit_name(block_id) + if not path and unit_name: + return None + path_ids = [str(block.location) for block in path] return { @@ -228,37 +231,25 @@ def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[ 'last_visited_unit_display_name': unit_name, } + @staticmethod def _get_last_visited_block_path_and_unit_name( - self, - request: 'Request', # noqa: F821 - model: CourseEnrollment, - ) -> Tuple[List[Optional['XBlock']], Optional[str]]: # noqa: F821 + block_id: str + ) -> Union[Tuple[None, None], Tuple[List['XBlock'], str]]: # noqa: F821 """ Returns the path to the latest block and unit name visited by the current user. - - If there is no such visit, the first item deep enough down the course - tree is used. """ - field_data_cache = FieldDataCache.cache_for_block_descendents(self.course.id, model.user, self.course, depth=3) + try: + last_visited_block = modulestore().get_item(UsageKey.from_string(block_id)) + vertical = last_visited_block.get_parent() + sequential = vertical.get_parent() + chapter = sequential.get_parent() + course = chapter.get_parent() + except (ItemNotFoundError, InvalidKeyError, AttributeError): + return None, None - course_block = get_block_for_descriptor( - model.user, request, self.course, field_data_cache, self.course.id, course=self.course - ) + path = [sequential, chapter, course] - unit_name = '' - path = [course_block] if course_block else [] - chapter = get_current_child(course_block, min_depth=3) - if chapter is not None: - path.append(chapter) - section = get_current_child(chapter, min_depth=2) - if section is not None: - path.append(section) - unit = get_current_child(section, min_depth=1) - if unit is not None: - unit_name = unit.display_name - - path.reverse() - return path, unit_name + return path, vertical.display_name def get_progress(self, model: CourseEnrollment) -> Dict[str, int]: """ From 18f5eb8bb011900a4354f857bb7899dfd6932b53 Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Thu, 25 Apr 2024 15:29:34 +0300 Subject: [PATCH 09/26] feat: [AXM-297] Add progress to assignments in BlocksInfoInCourseView API (#2546) * feat: [AXM-297, AXM-310] Add progress to assignments and total course progress * feat: [AXM-297] Add progress to assignments * style: [AXM-297] Try to fix linters (add docstrings) * refactor: [AXM-297] Add typing, refactor methods --- .../mobile_api/course_info/constants.py | 5 +++ .../mobile_api/course_info/utils.py | 43 +++++++++++++++++++ .../mobile_api/course_info/views.py | 43 ++++++++++++++++++- .../tests/test_course_info_views.py | 28 ++++++++++++ 4 files changed, 118 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/mobile_api/course_info/constants.py create mode 100644 lms/djangoapps/mobile_api/course_info/utils.py diff --git a/lms/djangoapps/mobile_api/course_info/constants.py b/lms/djangoapps/mobile_api/course_info/constants.py new file mode 100644 index 000000000000..d62cb463951a --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/constants.py @@ -0,0 +1,5 @@ +""" +Common constants for the `course_info` API. +""" + +BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour diff --git a/lms/djangoapps/mobile_api/course_info/utils.py b/lms/djangoapps/mobile_api/course_info/utils.py new file mode 100644 index 000000000000..141c32da4cf4 --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/utils.py @@ -0,0 +1,43 @@ +""" +Common utils for the `course_info` API. +""" + +import logging +from typing import List, Optional, Union + +from django.core.cache import cache + +from lms.djangoapps.courseware.access import has_access +from lms.djangoapps.grades.api import CourseGradeFactory +from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager + +log = logging.getLogger(__name__) + + +def calculate_progress( + user: 'User', # noqa: F821 + course_id: 'CourseLocator', # noqa: F821 + cache_timeout: int, +) -> Optional[List[Union['ReadSubsectionGrade', 'ZeroSubsectionGrade']]]: # noqa: F821 + """ + Calculate the progress of the user in the course. + """ + is_staff = bool(has_access(user, 'staff', course_id)) + + try: + cache_key = f'course_block_structure_{str(course_id)}_{user.id}' + collected_block_structure = cache.get(cache_key) + if not collected_block_structure: + collected_block_structure = get_block_structure_manager(course_id).get_collected() + cache.set(cache_key, collected_block_structure, cache_timeout) + + course_grade = CourseGradeFactory().read(user, collected_block_structure=collected_block_structure) + + # recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not) + course_grade.update(visible_grades_only=True, has_staff_access=is_staff) + subsection_grades = list(course_grade.subsection_grades.values()) + except Exception as err: # pylint: disable=broad-except + log.warning(f'Could not get grades for the course: {course_id}, error: {err}') + return [] + + return subsection_grades diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index bd34336cc824..c6de108727d8 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -3,7 +3,7 @@ """ import logging -from typing import Optional, Union +from typing import Dict, Optional, Union import django from django.contrib.auth import get_user_model @@ -20,11 +20,13 @@ from lms.djangoapps.courseware.courses import get_course_info_section_block from lms.djangoapps.course_goals.models import UserActivity from lms.djangoapps.course_api.blocks.views import BlocksInCourseView +from lms.djangoapps.mobile_api.course_info.constants import BLOCK_STRUCTURE_CACHE_TIMEOUT from lms.djangoapps.mobile_api.course_info.serializers import ( CourseInfoOverviewSerializer, CourseAccessSerializer, MobileCourseEnrollmentSerializer ) +from lms.djangoapps.mobile_api.course_info.utils import calculate_progress from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.lib.api.view_utils import view_auth_classes from openedx.core.lib.xblock_utils import get_course_update_items @@ -357,6 +359,12 @@ def list(self, request, **kwargs): # pylint: disable=W0221 course_info_context = {} if requested_user := self.get_requested_user(request.user, requested_username): + self._extend_sequential_info_with_assignment_progress( + requested_user, + course_key, + response.data['blocks'], + ) + course_info_context = { 'user': requested_user } @@ -380,3 +388,36 @@ def list(self, request, **kwargs): # pylint: disable=W0221 response.data.update(course_data) return response + + @staticmethod + def _extend_sequential_info_with_assignment_progress( + requested_user: User, + course_id: CourseKey, + blocks_info_data: Dict[str, Dict], + ) -> None: + """ + Extends sequential xblock info with assignment's name and progress. + """ + subsection_grades = calculate_progress(requested_user, course_id, BLOCK_STRUCTURE_CACHE_TIMEOUT) + grades_with_locations = {str(grade.location): grade for grade in subsection_grades} + + for block_id, block_info in blocks_info_data.items(): + if block_info['type'] == 'sequential': + grade = grades_with_locations.get(block_id) + if grade: + graded_total = grade.graded_total if grade.graded else None + points_earned = graded_total.earned if graded_total else 0 + points_possible = graded_total.possible if graded_total else 0 + assignment_type = grade.format + else: + points_earned, points_possible, assignment_type = 0, 0, None + + block_info.update( + { + 'assignment_progress': { + 'assignment_type': assignment_type, + 'num_points_earned': points_earned, + 'num_points_possible': points_possible, + } + } + ) diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_views.py b/lms/djangoapps/mobile_api/tests/test_course_info_views.py index 25fe08980379..ca4750d121bf 100644 --- a/lms/djangoapps/mobile_api/tests/test_course_info_views.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_views.py @@ -422,3 +422,31 @@ def test_course_modes(self): self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertListEqual(response.data['course_modes'], expected_course_modes) + + def test_extend_sequential_info_with_assignment_progress_get_only_sequential(self) -> None: + response = self.verify_response(url=self.url, params={'block_types_filter': 'sequential'}) + + expected_results = ( + { + 'assignment_type': 'Lecture Sequence', + 'num_points_earned': 0.0, + 'num_points_possible': 0.0 + }, + { + 'assignment_type': None, + 'num_points_earned': 0.0, + 'num_points_possible': 0.0 + }, + ) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for sequential_info, assignment_progress in zip(response.data['blocks'].values(), expected_results): + self.assertDictEqual(sequential_info['assignment_progress'], assignment_progress) + + @ddt.data('chapter', 'vertical', 'problem', 'video', 'html') + def test_extend_sequential_info_with_assignment_progress_for_other_types(self, block_type: 'str') -> None: + response = self.verify_response(url=self.url, params={'block_types_filter': block_type}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for block_info in response.data['blocks'].values(): + self.assertNotEqual('assignment_progress', block_info) From cdfa6fa5aad2822d36753ad63d9e823953bbbe5a Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Mon, 29 Apr 2024 13:28:28 +0300 Subject: [PATCH 10/26] feat: [AXM-288] Change response to represent Future assignments the same way as past assignments (#2550) * feat: [AXM-288] Change response to represent Future assignments the same way as past assignments * refactor: [AXM-288] Refactor get_course_assignments Co-authored-by: monteri <36768631+monteri@users.noreply.github.com> * refactor: [AXM-288] Refactor get_course_assignments method --------- Co-authored-by: monteri <36768631+monteri@users.noreply.github.com> --- .../mobile_api/users/serializers.py | 24 ++++++++++++------- lms/djangoapps/mobile_api/users/tests.py | 15 ++++++++++++ 2 files changed, 31 insertions(+), 8 deletions(-) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 76453dc4a6b4..82389199ef72 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -257,7 +257,7 @@ def get_progress(self, model: CourseEnrollment) -> Dict[str, int]: """ return self.calculate_progress(model) - def get_course_assignments(self, model: CourseEnrollment) -> Optional[Dict[str, List[Dict[str, str]]]]: + def get_course_assignments(self, model: CourseEnrollment) -> Dict[str, Optional[List[Dict[str, str]]]]: """ Returns the future assignment data and past assignments data for the user in the course. """ @@ -267,20 +267,28 @@ def get_course_assignments(self, model: CourseEnrollment) -> Optional[Dict[str, self.context.get('request'), include_past_dates=True ) - next_assignment = None - past_assignment = [] + past_assignments = [] + future_assignments = [] timezone = get_user_timezone_or_last_seen_timezone_or_utc(model.user) for assignment in sorted(assignments, key=lambda x: x.date): if assignment.date < datetime.now(timezone): - past_assignment.append(assignment) + past_assignments.append(assignment) else: - next_assignment = DateSummarySerializer(assignment).data - break + if not assignment.complete: + future_assignments.append(assignment) + + if future_assignments: + future_assignment_date = future_assignments[0].date.date() + next_assignments = [ + assignment for assignment in future_assignments if assignment.date.date() == future_assignment_date + ] + else: + next_assignments = [] return { - 'future_assignment': next_assignment, - 'past_assignments': DateSummarySerializer(past_assignment, many=True).data, + 'future_assignments': DateSummarySerializer(next_assignments, many=True).data, + 'past_assignments': DateSummarySerializer(past_assignments, many=True).data, } class Meta: diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 31ff2a059ef9..0cd959570c50 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -903,6 +903,21 @@ def test_user_enrollment_api_v4_status_all(self): self.assertEqual(enrollments['results'][2]['course']['id'], str(old_course.id)) self.assertNotIn('primary', response.data) + @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) + def test_response_contains_primary_enrollment_assignments_info(self, cache_mock: MagicMock): + self.login() + course = CourseFactory.create(org='edx', mobile_available=True) + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_assignments', response.data['primary']) + self.assertIn('past_assignments', response.data['primary']['course_assignments']) + self.assertIn('future_assignments', response.data['primary']['course_assignments']) + self.assertListEqual(response.data['primary']['course_assignments']['past_assignments'], []) + self.assertListEqual(response.data['primary']['course_assignments']['future_assignments'], []) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin): From 97392f5a3c2aa37e12c955951e5eec8d2f4ccad1 Mon Sep 17 00:00:00 2001 From: Ivan Niedielnitsev <81557788+niedielnitsevivan@users.noreply.github.com> Date: Mon, 29 Apr 2024 15:42:04 +0200 Subject: [PATCH 11/26] feat: [AXM-252] add settings for edx-ace push notifications (#2541) * feat: [AXM-252] create policy for push notifications * feat: [AXM-252] add API for store device token * feat: [AXM-252] add settings for edx-ace push notifications * chore: [AXM-252] add edx-ace and django-push-notification to dev requirements * chore: [AXM-252] update edx-ace version * fix: [AXM-252] add create token edndpoint to urls * chore: [AXM-252] update django push notifications version * style: [AXM-252] fix code style issues after review * chore: [AXM-252] bump edx-ace version * refactor: [AXM-252] some push notif policy refactoring * chore: [AXM-252] change edx-ace branch to mob-develop * chore: [AXM-252] recompile requirements after rebase --- .../mobile_api/notifications/urls.py | 10 ++ .../mobile_api/notifications/views.py | 50 +++++++ lms/djangoapps/mobile_api/urls.py | 1 + .../djangoapps/ace_common/settings/common.py | 29 ++++ .../ace_common/settings/production.py | 22 +++ openedx/core/djangoapps/ace_common/utils.py | 20 +++ .../core/djangoapps/notifications/policies.py | 41 +++++ requirements/edx/base.txt | 87 ++++++++++- requirements/edx/development.txt | 140 +++++++++++++++++- requirements/edx/doc.txt | 116 ++++++++++++++- requirements/edx/github.in | 3 + requirements/edx/testing.txt | 117 ++++++++++++++- setup.py | 3 +- 13 files changed, 626 insertions(+), 13 deletions(-) create mode 100644 lms/djangoapps/mobile_api/notifications/urls.py create mode 100644 lms/djangoapps/mobile_api/notifications/views.py create mode 100644 openedx/core/djangoapps/ace_common/utils.py create mode 100644 openedx/core/djangoapps/notifications/policies.py diff --git a/lms/djangoapps/mobile_api/notifications/urls.py b/lms/djangoapps/mobile_api/notifications/urls.py new file mode 100644 index 000000000000..17b970916a47 --- /dev/null +++ b/lms/djangoapps/mobile_api/notifications/urls.py @@ -0,0 +1,10 @@ +from django.urls import path +from .views import GCMDeviceViewSet + + +CREATE_GCM_DEVICE = GCMDeviceViewSet.as_view({'post': 'create'}) + + +urlpatterns = [ + path('create-token/', CREATE_GCM_DEVICE, name='gcmdevice-list'), +] diff --git a/lms/djangoapps/mobile_api/notifications/views.py b/lms/djangoapps/mobile_api/notifications/views.py new file mode 100644 index 000000000000..aeab8c5445c9 --- /dev/null +++ b/lms/djangoapps/mobile_api/notifications/views.py @@ -0,0 +1,50 @@ +from django.conf import settings +from rest_framework import status +from rest_framework.response import Response + +from edx_ace.push_notifications.views import GCMDeviceViewSet as GCMDeviceViewSetBase + +from ..decorators import mobile_view + + +@mobile_view(is_user=True) +class GCMDeviceViewSet(GCMDeviceViewSetBase): + """ + **Use Case** + This endpoint allows clients to register a device for push notifications. + + If the device is already registered, the existing registration will be updated. + If setting PUSH_NOTIFICATIONS_SETTINGS is not configured, the endpoint will return a 501 error. + + **Example Request** + POST /api/mobile/{version}/notifications/create-token/ + **POST Parameters** + The body of the POST request can include the following parameters. + * name (optional) - A name of the device. + * registration_id (required) - The device token of the device. + * device_id (optional) - ANDROID_ID / TelephonyManager.getDeviceId() (always as hex) + * active (optional) - Whether the device is active, default is True. + If False, the device will not receive notifications. + * cloud_message_type (required) - You should choose FCM or GCM. Currently, only FCM is supported. + * application_id (optional) - Opaque application identity, should be filled in for multiple + key/certificate access. + **Example Response** + ```json + { + "id": 1, + "name": "My Device", + "registration_id": "fj3j4", + "device_id": 1234, + "active": true, + "date_created": "2024-04-18T07:39:37.132787Z", + "cloud_message_type": "FCM", + "application_id": "my_app_id" + } + ``` + """ + + def create(self, request, *args, **kwargs): + if not getattr(settings, 'PUSH_NOTIFICATIONS_SETTINGS', None): + return Response('Push notifications are not configured.', status.HTTP_501_NOT_IMPLEMENTED) + + return super().create(request, *args, **kwargs) diff --git a/lms/djangoapps/mobile_api/urls.py b/lms/djangoapps/mobile_api/urls.py index 1ad34ced5de9..c7aacc0b669a 100644 --- a/lms/djangoapps/mobile_api/urls.py +++ b/lms/djangoapps/mobile_api/urls.py @@ -10,5 +10,6 @@ urlpatterns = [ path('users/', include('lms.djangoapps.mobile_api.users.urls')), path('my_user_info', my_user_info, name='user-info'), + path('notifications/', include('lms.djangoapps.mobile_api.notifications.urls')), path('course_info/', include('lms.djangoapps.mobile_api.course_info.urls')), ] diff --git a/openedx/core/djangoapps/ace_common/settings/common.py b/openedx/core/djangoapps/ace_common/settings/common.py index 11bfbce5c59f..58341470ed8d 100644 --- a/openedx/core/djangoapps/ace_common/settings/common.py +++ b/openedx/core/djangoapps/ace_common/settings/common.py @@ -1,11 +1,14 @@ """ Settings for ace_common app. """ +from openedx.core.djangoapps.ace_common.utils import setup_firebase_app ACE_ROUTING_KEY = 'edx.lms.core.default' def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function-docstring, missing-module-docstring + if 'push_notifications' not in settings.INSTALLED_APPS: + settings.INSTALLED_APPS.append('push_notifications') settings.ACE_ENABLED_CHANNELS = [ 'django_email' ] @@ -22,3 +25,29 @@ def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function settings.ACE_ROUTING_KEY = ACE_ROUTING_KEY settings.FEATURES['test_django_plugin'] = True + settings.FCM_APP_NAME = 'fcm-edx-platform' + + if getattr(settings, 'FIREBASE_SETUP_STATUS', None) is None: + settings.ACE_CHANNEL_DEFAULT_PUSH = 'push_notification' + + # Note: To local development with Firebase, you must set FIREBASE_CREDENTIALS. + settings.FCM_APP_NAME = 'fcm-edx-platform' + settings.FIREBASE_CREDENTIALS = None + + if firebase_app := setup_firebase_app(settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME): + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': firebase_app, + }, + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } + settings.FIREBASE_SETUP_STATUS = True + else: + settings.FIREBASE_SETUP_STATUS = False diff --git a/openedx/core/djangoapps/ace_common/settings/production.py b/openedx/core/djangoapps/ace_common/settings/production.py index cc4da91c18db..b7ac5b12db17 100644 --- a/openedx/core/djangoapps/ace_common/settings/production.py +++ b/openedx/core/djangoapps/ace_common/settings/production.py @@ -1,4 +1,5 @@ """Common environment variables unique to the ace_common plugin.""" +from openedx.core.djangoapps.ace_common.utils import setup_firebase_app def plugin_settings(settings): @@ -26,3 +27,24 @@ def plugin_settings(settings): settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL = settings.ENV_TOKENS.get( 'ACE_CHANNEL_TRANSACTIONAL_EMAIL', settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL ) + settings.FCM_APP_NAME = settings.ENV_TOKENS.get('FCM_APP_NAME', 'fcm-edx-platform') + settings.FIREBASE_CREDENTIALS = settings.ENV_TOKENS.get('FIREBASE_CREDENTIALS', {}) + + if getattr(settings, 'FIREBASE_SETUP_STATUS', None) is None: + if firebase_app := setup_firebase_app(settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME): + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': firebase_app, + }, + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } + settings.FIREBASE_SETUP_STATUS = True + else: + settings.FIREBASE_SETUP_STATUS = False diff --git a/openedx/core/djangoapps/ace_common/utils.py b/openedx/core/djangoapps/ace_common/utils.py new file mode 100644 index 000000000000..adf5586dc449 --- /dev/null +++ b/openedx/core/djangoapps/ace_common/utils.py @@ -0,0 +1,20 @@ +""" +Utility functions for edx-ace. +""" +import logging + +log = logging.getLogger(__name__) + + +def setup_firebase_app(firebase_credentials, app_name='fcm-app'): + """ + Returns a Firebase app instance if the Firebase credentials are provided. + """ + try: + import firebase_admin # pylint: disable=import-outside-toplevel + except ImportError: + log.error('Could not import firebase_admin package.') + return + if firebase_credentials: + certificate = firebase_admin.credentials.Certificate(firebase_credentials) + return firebase_admin.initialize_app(certificate, name=app_name) diff --git a/openedx/core/djangoapps/notifications/policies.py b/openedx/core/djangoapps/notifications/policies.py new file mode 100644 index 000000000000..768d05e62efd --- /dev/null +++ b/openedx/core/djangoapps/notifications/policies.py @@ -0,0 +1,41 @@ +"""Policies for the notifications app.""" + +from edx_ace.channel import ChannelType +from edx_ace.policy import Policy, PolicyResult +from opaque_keys.edx.keys import CourseKey + +from .models import CourseNotificationPreference + + +class CoursePushNotificationOptout(Policy): + """ + Course Push Notification optOut Policy. + """ + + def check(self, message): + """ + Check if the user has opted out of push notifications for the given course. + :param message: + :return: + """ + course_ids = message.context.get('course_ids', []) + app_label = message.context.get('app_label') + + if not (app_label or message.context.get('send_push_notification', False)): + return PolicyResult(deny={ChannelType.PUSH}) + + course_keys = [CourseKey.from_string(course_id) for course_id in course_ids] + for course_key in course_keys: + course_notification_preference = CourseNotificationPreference.get_user_course_preference( + message.recipient.lms_user_id, + course_key + ) + push_notification_preference = course_notification_preference.get_notification_type_config( + app_label, + notification_type='push', + ).get('push', False) + + if not push_notification_preference: + return PolicyResult(deny={ChannelType.PUSH}) + + return PolicyResult(deny=frozenset()) diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 6d9100d3d075..5171438a9519 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -4,6 +4,12 @@ # # make upgrade # +-e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications + # via -r requirements/edx/github.in +-e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace + # via + # -r requirements/edx/github.in + # -r requirements/edx/kernel.in -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/github.in acid-xblock==0.3.1 @@ -90,6 +96,10 @@ botocore==1.34.104 # s3transfer bridgekeeper==0.9 # via -r requirements/edx/kernel.in +cachecontrol==0.14.0 + # via firebase-admin +cachetools==5.3.3 + # via google-auth camel-converter[pydantic]==3.1.2 # via meilisearch celery==5.4.0 @@ -195,6 +205,7 @@ django==4.2.13 # django-multi-email-field # django-mysql # django-oauth-toolkit + # django-push-notifications # django-sekizai # django-ses # django-statici18n @@ -400,8 +411,6 @@ drf-yasg==1.21.5 # -c requirements/edx/../constraints.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.8.0 - # via -r requirements/edx/kernel.in edx-api-doc-tools==1.8.0 # via # -r requirements/edx/kernel.in @@ -558,6 +567,8 @@ fastavro==1.9.4 # via openedx-events filelock==3.14.0 # via snowflake-connector-python +firebase-admin==5.0.0 + # via edx-ace frozenlist==1.4.1 # via # aiohttp @@ -578,6 +589,49 @@ geoip2==4.8.0 # via -r requirements/edx/kernel.in glob2==0.7 # via -r requirements/edx/kernel.in +google-api-core[grpc]==1.34.1 + # via + # firebase-admin + # google-api-python-client + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage +google-api-python-client==2.127.0 + # via firebase-admin +google-auth==2.29.0 + # via + # google-api-core + # google-api-python-client + # google-auth-httplib2 + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage +google-auth-httplib2==0.2.0 + # via google-api-python-client +google-cloud-core==2.4.1 + # via + # google-cloud-firestore + # google-cloud-storage +google-cloud-firestore==2.16.0 + # via firebase-admin +google-cloud-storage==2.14.0 + # via firebase-admin +google-crc32c==1.5.0 + # via + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.0 + # via google-cloud-storage +googleapis-common-protos==1.63.0 + # via + # google-api-core + # grpcio-status +grpcio==1.62.2 + # via + # google-api-core + # grpcio-status +grpcio-status==1.48.2 + # via google-api-core gunicorn==22.0.0 # via -r requirements/edx/kernel.in help-tokens==2.4.0 @@ -586,6 +640,10 @@ html5lib==1.1 # via # -r requirements/edx/kernel.in # ora2 +httplib2==0.22.0 + # via + # google-api-python-client + # google-auth-httplib2 icalendar==5.0.12 # via -r requirements/edx/kernel.in idna==3.7 @@ -718,6 +776,8 @@ monotonic==1.6 # py2neo mpmath==1.3.0 # via sympy +msgpack==1.0.8 + # via cachecontrol multidict==6.0.5 # via # aiohttp @@ -831,6 +891,15 @@ polib==1.2.0 # via edx-i18n-tools prompt-toolkit==3.0.43 # via click-repl +proto-plus==1.23.0 + # via google-cloud-firestore +protobuf==3.20.3 + # via + # google-api-core + # google-cloud-firestore + # googleapis-common-protos + # grpcio-status + # proto-plus psutil==5.9.8 # via # -r requirements/edx/paver.txt @@ -840,7 +909,12 @@ py2neo @ https://github.com/overhangio/py2neo/releases/download/2021.2.3/py2neo- # -c requirements/edx/../constraints.txt # -r requirements/edx/bundled.in pyasn1==0.6.0 - # via pgpy + # via + # pgpy + # pyasn1-modules + # rsa +pyasn1-modules==0.4.0 + # via google-auth pycountry==23.12.11 # via -r requirements/edx/kernel.in pycparser==2.22 @@ -901,6 +975,7 @@ pyopenssl==24.1.0 pyparsing==3.1.2 # via # chem + # httplib2 # openedx-calc pyrsistent==0.20.0 # via optimizely-sdk @@ -984,6 +1059,7 @@ requests==2.31.0 # -r requirements/edx/paver.txt # algoliasearch # analytics-python + # cachecontrol # coreapi # django-oauth-toolkit # edx-bulk-grades @@ -991,6 +1067,8 @@ requests==2.31.0 # edx-enterprise # edx-rest-api-client # geoip2 + # google-api-core + # google-cloud-storage # mailsnake # meilisearch # openai @@ -1012,6 +1090,8 @@ rpds-py==0.18.1 # via # jsonschema # referencing +rsa==4.9 + # via google-auth ruamel-yaml==0.18.6 # via drf-yasg ruamel-yaml-clib==0.2.8 @@ -1154,6 +1234,7 @@ uritemplate==4.1.1 # coreapi # drf-spectacular # drf-yasg + # google-api-python-client urllib3==1.26.18 # via # -c requirements/edx/../constraints.txt diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index ddd16667104a..d2b8908dce09 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -4,6 +4,14 @@ # # make upgrade # +-e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt +-e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via # -r requirements/edx/doc.txt @@ -168,9 +176,16 @@ build==1.2.1 # via # -r requirements/edx/../pip-tools.txt # pip-tools +cachecontrol==0.14.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # firebase-admin cachetools==5.3.3 # via + # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt + # google-auth # tox camel-converter[pydantic]==3.1.2 # via @@ -367,6 +382,7 @@ django==4.2.13 # django-multi-email-field # django-mysql # django-oauth-toolkit + # django-push-notifications # django-sekizai # django-ses # django-statici18n @@ -661,10 +677,6 @@ drf-yasg==1.21.5 # -r requirements/edx/testing.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.8.0 - # via - # -r requirements/edx/doc.txt - # -r requirements/edx/testing.txt edx-api-doc-tools==1.8.0 # via # -r requirements/edx/doc.txt @@ -914,6 +926,11 @@ filelock==3.14.0 # snowflake-connector-python # tox # virtualenv +firebase-admin==5.0.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # edx-ace freezegun==1.5.1 # via -r requirements/edx/testing.txt frozenlist==1.4.1 @@ -953,10 +970,83 @@ glob2==0.7 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt +google-api-core[grpc]==1.34.1 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # firebase-admin + # google-api-python-client + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage +google-api-python-client==2.127.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # firebase-admin +google-auth==2.29.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-api-core + # google-api-python-client + # google-auth-httplib2 + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage +google-auth-httplib2==0.2.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-api-python-client +google-cloud-core==2.4.1 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-cloud-firestore + # google-cloud-storage +google-cloud-firestore==2.16.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # firebase-admin +google-cloud-storage==2.14.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # firebase-admin +google-crc32c==1.5.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-cloud-storage +googleapis-common-protos==1.63.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-api-core + # grpcio-status grimp==3.2 # via # -r requirements/edx/testing.txt # import-linter +grpcio==1.62.2 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-api-core + # grpcio-status +grpcio-status==1.48.2 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-api-core gunicorn==22.0.0 # via # -r requirements/edx/doc.txt @@ -979,6 +1069,12 @@ httpcore==1.0.5 # via # -r requirements/edx/testing.txt # httpx +httplib2==0.22.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-api-python-client + # google-auth-httplib2 httpretty==1.1.4 # via -r requirements/edx/testing.txt httptools==0.6.1 @@ -1237,6 +1333,11 @@ mpmath==1.3.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # sympy +msgpack==1.0.8 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # cachecontrol multidict==6.0.5 # via # -r requirements/edx/doc.txt @@ -1449,6 +1550,20 @@ prompt-toolkit==3.0.43 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # click-repl +proto-plus==1.23.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-cloud-firestore +protobuf==3.20.3 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-api-core + # google-cloud-firestore + # googleapis-common-protos + # grpcio-status + # proto-plus psutil==5.9.8 # via # -r requirements/edx/doc.txt @@ -1468,6 +1583,13 @@ pyasn1==0.6.0 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # pgpy + # pyasn1-modules + # rsa +pyasn1-modules==0.4.0 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-auth pycodestyle==2.8.0 # via # -c requirements/edx/../constraints.txt @@ -1598,6 +1720,7 @@ pyparsing==3.1.2 # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt # chem + # httplib2 # openedx-calc pyproject-api==1.6.1 # via @@ -1767,6 +1890,7 @@ requests==2.31.0 # -r requirements/edx/testing.txt # algoliasearch # analytics-python + # cachecontrol # coreapi # django-oauth-toolkit # djangorestframework-stubs @@ -1775,6 +1899,8 @@ requests==2.31.0 # edx-enterprise # edx-rest-api-client # geoip2 + # google-api-core + # google-cloud-storage # mailsnake # meilisearch # openai @@ -1805,6 +1931,11 @@ rpds-py==0.18.1 # -r requirements/edx/testing.txt # jsonschema # referencing +rsa==4.9 + # via + # -r requirements/edx/doc.txt + # -r requirements/edx/testing.txt + # google-auth ruamel-yaml==0.18.6 # via # -r requirements/edx/doc.txt @@ -2144,6 +2275,7 @@ uritemplate==4.1.1 # coreapi # drf-spectacular # drf-yasg + # google-api-python-client urllib3==1.26.18 # via # -c requirements/edx/../constraints.txt diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index 926d43bab5d8..e1c01186f330 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -4,6 +4,10 @@ # # make upgrade # +-e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications + # via -r requirements/edx/base.txt +-e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace + # via -r requirements/edx/base.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/base.txt accessible-pygments==0.0.4 @@ -119,6 +123,14 @@ botocore==1.34.104 # s3transfer bridgekeeper==0.9 # via -r requirements/edx/base.txt +cachecontrol==0.14.0 + # via + # -r requirements/edx/base.txt + # firebase-admin +cachetools==5.3.3 + # via + # -r requirements/edx/base.txt + # google-auth camel-converter[pydantic]==3.1.2 # via # -r requirements/edx/base.txt @@ -245,6 +257,7 @@ django==4.2.13 # django-multi-email-field # django-mysql # django-oauth-toolkit + # django-push-notifications # django-sekizai # django-ses # django-statici18n @@ -474,8 +487,6 @@ drf-yasg==1.21.5 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.8.0 - # via -r requirements/edx/base.txt edx-api-doc-tools==1.8.0 # via # -r requirements/edx/base.txt @@ -642,6 +653,10 @@ filelock==3.14.0 # via # -r requirements/edx/base.txt # snowflake-connector-python +firebase-admin==5.0.0 + # via + # -r requirements/edx/base.txt + # edx-ace frozenlist==1.4.1 # via # -r requirements/edx/base.txt @@ -669,6 +684,67 @@ gitpython==3.1.43 # via -r requirements/edx/doc.in glob2==0.7 # via -r requirements/edx/base.txt +google-api-core[grpc]==1.34.1 + # via + # -r requirements/edx/base.txt + # firebase-admin + # google-api-python-client + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage +google-api-python-client==2.127.0 + # via + # -r requirements/edx/base.txt + # firebase-admin +google-auth==2.29.0 + # via + # -r requirements/edx/base.txt + # google-api-core + # google-api-python-client + # google-auth-httplib2 + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage +google-auth-httplib2==0.2.0 + # via + # -r requirements/edx/base.txt + # google-api-python-client +google-cloud-core==2.4.1 + # via + # -r requirements/edx/base.txt + # google-cloud-firestore + # google-cloud-storage +google-cloud-firestore==2.16.0 + # via + # -r requirements/edx/base.txt + # firebase-admin +google-cloud-storage==2.14.0 + # via + # -r requirements/edx/base.txt + # firebase-admin +google-crc32c==1.5.0 + # via + # -r requirements/edx/base.txt + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.0 + # via + # -r requirements/edx/base.txt + # google-cloud-storage +googleapis-common-protos==1.63.0 + # via + # -r requirements/edx/base.txt + # google-api-core + # grpcio-status +grpcio==1.62.2 + # via + # -r requirements/edx/base.txt + # google-api-core + # grpcio-status +grpcio-status==1.48.2 + # via + # -r requirements/edx/base.txt + # google-api-core gunicorn==22.0.0 # via -r requirements/edx/base.txt help-tokens==2.4.0 @@ -677,6 +753,11 @@ html5lib==1.1 # via # -r requirements/edx/base.txt # ora2 +httplib2==0.22.0 + # via + # -r requirements/edx/base.txt + # google-api-python-client + # google-auth-httplib2 icalendar==5.0.12 # via -r requirements/edx/base.txt idna==3.7 @@ -843,6 +924,10 @@ mpmath==1.3.0 # via # -r requirements/edx/base.txt # sympy +msgpack==1.0.8 + # via + # -r requirements/edx/base.txt + # cachecontrol multidict==6.0.5 # via # -r requirements/edx/base.txt @@ -979,6 +1064,18 @@ prompt-toolkit==3.0.43 # via # -r requirements/edx/base.txt # click-repl +proto-plus==1.23.0 + # via + # -r requirements/edx/base.txt + # google-cloud-firestore +protobuf==3.20.3 + # via + # -r requirements/edx/base.txt + # google-api-core + # google-cloud-firestore + # googleapis-common-protos + # grpcio-status + # proto-plus psutil==5.9.8 # via # -r requirements/edx/base.txt @@ -991,6 +1088,12 @@ pyasn1==0.6.0 # via # -r requirements/edx/base.txt # pgpy + # pyasn1-modules + # rsa +pyasn1-modules==0.4.0 + # via + # -r requirements/edx/base.txt + # google-auth pycountry==23.12.11 # via -r requirements/edx/base.txt pycparser==2.22 @@ -1068,6 +1171,7 @@ pyparsing==3.1.2 # via # -r requirements/edx/base.txt # chem + # httplib2 # openedx-calc pyrsistent==0.20.0 # via @@ -1163,6 +1267,7 @@ requests==2.31.0 # -r requirements/edx/base.txt # algoliasearch # analytics-python + # cachecontrol # coreapi # django-oauth-toolkit # edx-bulk-grades @@ -1170,6 +1275,8 @@ requests==2.31.0 # edx-enterprise # edx-rest-api-client # geoip2 + # google-api-core + # google-cloud-storage # mailsnake # meilisearch # openai @@ -1193,6 +1300,10 @@ rpds-py==0.18.1 # -r requirements/edx/base.txt # jsonschema # referencing +rsa==4.9 + # via + # -r requirements/edx/base.txt + # google-auth ruamel-yaml==0.18.6 # via # -r requirements/edx/base.txt @@ -1407,6 +1518,7 @@ uritemplate==4.1.1 # coreapi # drf-spectacular # drf-yasg + # google-api-python-client urllib3==1.26.18 # via # -c requirements/edx/../constraints.txt diff --git a/requirements/edx/github.in b/requirements/edx/github.in index 6ec36d3a0681..7729b94e1ca3 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -90,3 +90,6 @@ # django42 support PR merged but new release is pending. # https://github.com/openedx/edx-platform/issues/33431 -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack + +-e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace +-e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index 75a30e5dcb95..c255defa15e1 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -4,6 +4,10 @@ # # make upgrade # +-e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications + # via -r requirements/edx/base.txt +-e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace + # via -r requirements/edx/base.txt -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack # via -r requirements/edx/base.txt acid-xblock==0.3.1 @@ -122,8 +126,15 @@ botocore==1.34.104 # s3transfer bridgekeeper==0.9 # via -r requirements/edx/base.txt +cachecontrol==0.14.0 + # via + # -r requirements/edx/base.txt + # firebase-admin cachetools==5.3.3 - # via tox + # via + # -r requirements/edx/base.txt + # google-auth + # tox camel-converter[pydantic]==3.1.2 # via # -r requirements/edx/base.txt @@ -281,6 +292,7 @@ django==4.2.13 # django-multi-email-field # django-mysql # django-oauth-toolkit + # django-push-notifications # django-sekizai # django-ses # django-statici18n @@ -507,8 +519,6 @@ drf-yasg==1.21.5 # -r requirements/edx/base.txt # django-user-tasks # edx-api-doc-tools -edx-ace==1.8.0 - # via -r requirements/edx/base.txt edx-api-doc-tools==1.8.0 # via # -r requirements/edx/base.txt @@ -697,6 +707,10 @@ filelock==3.14.0 # snowflake-connector-python # tox # virtualenv +firebase-admin==5.0.0 + # via + # -r requirements/edx/base.txt + # edx-ace freezegun==1.5.1 # via -r requirements/edx/testing.in frozenlist==1.4.1 @@ -722,8 +736,69 @@ geoip2==4.8.0 # via -r requirements/edx/base.txt glob2==0.7 # via -r requirements/edx/base.txt +google-api-core[grpc]==1.34.1 + # via + # -r requirements/edx/base.txt + # firebase-admin + # google-api-python-client + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage +google-api-python-client==2.127.0 + # via + # -r requirements/edx/base.txt + # firebase-admin +google-auth==2.29.0 + # via + # -r requirements/edx/base.txt + # google-api-core + # google-api-python-client + # google-auth-httplib2 + # google-cloud-core + # google-cloud-firestore + # google-cloud-storage +google-auth-httplib2==0.2.0 + # via + # -r requirements/edx/base.txt + # google-api-python-client +google-cloud-core==2.4.1 + # via + # -r requirements/edx/base.txt + # google-cloud-firestore + # google-cloud-storage +google-cloud-firestore==2.16.0 + # via + # -r requirements/edx/base.txt + # firebase-admin +google-cloud-storage==2.14.0 + # via + # -r requirements/edx/base.txt + # firebase-admin +google-crc32c==1.5.0 + # via + # -r requirements/edx/base.txt + # google-cloud-storage + # google-resumable-media +google-resumable-media==2.7.0 + # via + # -r requirements/edx/base.txt + # google-cloud-storage +googleapis-common-protos==1.63.0 + # via + # -r requirements/edx/base.txt + # google-api-core + # grpcio-status grimp==3.2 # via import-linter +grpcio==1.62.2 + # via + # -r requirements/edx/base.txt + # google-api-core + # grpcio-status +grpcio-status==1.48.2 + # via + # -r requirements/edx/base.txt + # google-api-core gunicorn==22.0.0 # via -r requirements/edx/base.txt h11==0.14.0 @@ -738,6 +813,11 @@ html5lib==1.1 # ora2 httpcore==1.0.5 # via httpx +httplib2==0.22.0 + # via + # -r requirements/edx/base.txt + # google-api-python-client + # google-auth-httplib2 httpretty==1.1.4 # via -r requirements/edx/testing.in httptools==0.6.1 @@ -928,6 +1008,10 @@ mpmath==1.3.0 # via # -r requirements/edx/base.txt # sympy +msgpack==1.0.8 + # via + # -r requirements/edx/base.txt + # cachecontrol multidict==6.0.5 # via # -r requirements/edx/base.txt @@ -1077,6 +1161,18 @@ prompt-toolkit==3.0.43 # via # -r requirements/edx/base.txt # click-repl +proto-plus==1.23.0 + # via + # -r requirements/edx/base.txt + # google-cloud-firestore +protobuf==3.20.3 + # via + # -r requirements/edx/base.txt + # google-api-core + # google-cloud-firestore + # googleapis-common-protos + # grpcio-status + # proto-plus psutil==5.9.8 # via # -r requirements/edx/base.txt @@ -1093,6 +1189,12 @@ pyasn1==0.6.0 # via # -r requirements/edx/base.txt # pgpy + # pyasn1-modules + # rsa +pyasn1-modules==0.4.0 + # via + # -r requirements/edx/base.txt + # google-auth pycodestyle==2.8.0 # via # -c requirements/edx/../constraints.txt @@ -1190,6 +1292,7 @@ pyparsing==3.1.2 # via # -r requirements/edx/base.txt # chem + # httplib2 # openedx-calc pyproject-api==1.6.1 # via tox @@ -1322,6 +1425,7 @@ requests==2.31.0 # -r requirements/edx/base.txt # algoliasearch # analytics-python + # cachecontrol # coreapi # django-oauth-toolkit # edx-bulk-grades @@ -1329,6 +1433,8 @@ requests==2.31.0 # edx-enterprise # edx-rest-api-client # geoip2 + # google-api-core + # google-cloud-storage # mailsnake # meilisearch # openai @@ -1354,6 +1460,10 @@ rpds-py==0.18.1 # -r requirements/edx/base.txt # jsonschema # referencing +rsa==4.9 + # via + # -r requirements/edx/base.txt + # google-auth ruamel-yaml==0.18.6 # via # -r requirements/edx/base.txt @@ -1567,6 +1677,7 @@ uritemplate==4.1.1 # coreapi # drf-spectacular # drf-yasg + # google-api-python-client urllib3==1.26.18 # via # -c requirements/edx/../constraints.txt diff --git a/setup.py b/setup.py index 4bbbe894fc77..188072354fd2 100644 --- a/setup.py +++ b/setup.py @@ -129,7 +129,8 @@ 'discussions_link = openedx.core.djangoapps.discussions.transformers:DiscussionsTopicLinkTransformer', ], "openedx.ace.policy": [ - "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout" + "bulk_email_optout = lms.djangoapps.bulk_email.policies:CourseEmailOptout", + "bulk_push_notification_optout = openedx.core.djangoapps.notifications.policies:CoursePushNotificationOptout", # lint-amnesty, pylint: disable=line-too-long ], "openedx.call_to_action": [ "personalized_learner_schedules = openedx.features.personalized_learner_schedules.call_to_action:PersonalizedLearnerScheduleCallToAction" # lint-amnesty, pylint: disable=line-too-long From 874e9c4181bc0cd6b3ec25cccce00252e1d6cc9b Mon Sep 17 00:00:00 2001 From: Ivan Niedielnitsev <81557788+NiedielnitsevIvan@users.noreply.github.com> Date: Mon, 29 Apr 2024 21:16:53 +0300 Subject: [PATCH 12/26] feat: [AXM-271] Add push notification event to discussions (#2548) * feat: [AXM-271] Add push notification event to discussions * refactor: [AXM-252] add extra context to push notification --- lms/djangoapps/discussion/signals/handlers.py | 1 + lms/djangoapps/discussion/tasks.py | 5 +++++ .../edx_ace/responsenotification/push/body.txt | 3 +++ .../edx_ace/responsenotification/push/subject.txt | 3 +++ lms/djangoapps/discussion/tests/test_tasks.py | 8 +++++++- .../django_comment_common/comment_client/comment.py | 10 +++++++++- 6 files changed, 28 insertions(+), 2 deletions(-) create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 8c4991cd0289..ead8128a0fe0 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -113,6 +113,7 @@ def create_message_context(comment, site): 'course_id': str(thread.course_id), 'comment_id': comment.id, 'comment_body': comment.body, + 'comment_body_text': comment.body_text, 'comment_author_id': comment.user_id, 'comment_created_at': comment.created_at, # comment_client models dates are already serialized 'thread_id': thread.id, diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index d483a82dbd66..7d19cbb9cd63 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -218,6 +218,11 @@ def _build_message_context(context): # lint-amnesty, pylint: disable=missing-fu 'thread_username': thread_author.username, 'comment_username': comment_author.username, 'post_link': post_link, + 'push_notification_extra_context': { + 'notification_type': 'forum_comment', + 'thread_id': context['thread_id'], + 'comment_id': context['comment_id'], + }, 'comment_created_at': date.deserialize(context['comment_created_at']), 'thread_created_at': date.deserialize(context['thread_created_at']) }) diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt new file mode 100644 index 000000000000..145d8344d3a1 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/body.txt @@ -0,0 +1,3 @@ +{% load i18n %} +{% blocktrans trimmed %}{{ comment_username }} replied to {{ thread_title }}:{% endblocktrans %} +{{ comment_body_text }} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt new file mode 100644 index 000000000000..a49eb5dec1c2 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/responsenotification/push/subject.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% blocktrans %}Response to {{ thread_title }}{% endblocktrans %} \ No newline at end of file diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index f6cce4437546..d3d3edb1f9e2 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -271,6 +271,7 @@ def test_send_discussion_email_notification(self, user_subscribed): expected_message_context.update({ 'comment_author_id': self.comment_author.id, 'comment_body': comment['body'], + 'comment_body_text': comment.body_text, 'comment_created_at': ONE_HOUR_AGO, 'comment_id': comment['id'], 'comment_username': self.comment_author.username, @@ -283,7 +284,12 @@ def test_send_discussion_email_notification(self, user_subscribed): 'thread_commentable_id': thread['commentable_id'], 'post_link': f'https://{site.domain}{self.mock_permalink.return_value}', 'site': site, - 'site_id': site.id + 'site_id': site.id, + 'push_notification_extra_context': { + 'notification_type': 'forum_comment', + 'thread_id': thread['id'], + 'comment_id': comment['id'], + }, }) expected_recipient = Recipient(self.thread_author.id, self.thread_author.email) actual_message = self.mock_ace_send.call_args_list[0][0][0] diff --git a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py index 0b7a695a1c3e..c86f7eb40515 100644 --- a/openedx/core/djangoapps/django_comment_common/comment_client/comment.py +++ b/openedx/core/djangoapps/django_comment_common/comment_client/comment.py @@ -1,5 +1,5 @@ # pylint: disable=missing-docstring,protected-access - +from bs4 import BeautifulSoup from openedx.core.djangoapps.django_comment_common.comment_client import models, settings @@ -99,6 +99,14 @@ def unFlagAbuse(self, user, voteable, removeAll): ) voteable._update_from_response(response) + @property + def body_text(self): + """ + Return the text content of the comment html body. + """ + soup = BeautifulSoup(self.body, 'html.parser') + return soup.get_text() + def _url_for_thread_comments(thread_id): return f"{settings.PREFIX}/threads/{thread_id}/comments" From 47dc3958a6c5a602016b970bbaaf96e1d3dfe4af Mon Sep 17 00:00:00 2001 From: Kyrylo Kireiev <90455454+KyryloKireiev@users.noreply.github.com> Date: Mon, 13 May 2024 15:38:06 +0300 Subject: [PATCH 13/26] feat: [AXM-287,310,331] Change course progress calculation logic (#2553) * feat: [AXM-287,310,331] Change course progress calculation logic * style: [AXM-287,310,331] Remove commented code * fix: [AXM-287,310,331] Change course assignments gather logic --- .../course_api/blocks/tests/test_views.py | 8 +- lms/djangoapps/courseware/courses.py | 8 +- .../mobile_api/course_info/serializers.py | 27 +++- .../mobile_api/course_info/views.py | 7 +- .../tests/test_course_info_serializers.py | 37 ++++- .../mobile_api/users/serializers.py | 49 +++--- lms/djangoapps/mobile_api/users/tests.py | 144 +++++++++++++++--- lms/djangoapps/mobile_api/users/views.py | 4 + 8 files changed, 226 insertions(+), 58 deletions(-) diff --git a/lms/djangoapps/course_api/blocks/tests/test_views.py b/lms/djangoapps/course_api/blocks/tests/test_views.py index c1f673652096..4b9823328114 100644 --- a/lms/djangoapps/course_api/blocks/tests/test_views.py +++ b/lms/djangoapps/course_api/blocks/tests/test_views.py @@ -3,7 +3,7 @@ """ from datetime import datetime from unittest import mock -from unittest.mock import Mock +from unittest.mock import MagicMock, Mock from urllib.parse import urlencode, urlunparse import ddt @@ -209,8 +209,9 @@ def test_not_authenticated_public_course_with_all_blocks(self): self.query_params['all_blocks'] = True self.verify_response(403) + @mock.patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) @mock.patch("lms.djangoapps.course_api.blocks.forms.permissions.is_course_public", Mock(return_value=True)) - def test_not_authenticated_public_course_with_blank_username(self): + def test_not_authenticated_public_course_with_blank_username(self, get_course_assignment_mock: MagicMock) -> None: """ Verify behaviour when accessing course blocks of a public course for anonymous user anonymously. """ @@ -368,7 +369,8 @@ def test_extra_field_when_not_requested(self): block_data['type'] == 'course' ) - def test_data_researcher_access(self): + @mock.patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) + def test_data_researcher_access(self, get_course_assignment_mock: MagicMock) -> None: """ Test if data researcher has access to the api endpoint """ diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 2fc727623541..ee0d12ce1a52 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -5,7 +5,7 @@ import logging from collections import defaultdict, namedtuple -from datetime import datetime +from datetime import datetime, timedelta import six import pytz @@ -587,7 +587,7 @@ def get_course_blocks_completion_summary(course_key, user): @request_cached() -def get_course_assignments(course_key, user, include_access=False): # lint-amnesty, pylint: disable=too-many-statements +def get_course_assignments(course_key, user, include_access=False, include_without_due=False,): # lint-amnesty, pylint: disable=too-many-statements """ Returns a list of assignment (at the subsection/sequential level) due dates for the given course. @@ -607,6 +607,10 @@ def get_course_assignments(course_key, user, include_access=False): # lint-amne for subsection_key in block_data.get_children(section_key): due = block_data.get_xblock_field(subsection_key, 'due') graded = block_data.get_xblock_field(subsection_key, 'graded', False) + + if not due and include_without_due: + due = now + timedelta(days=1000) + if due and graded: first_component_block_id = get_first_component_of_block(subsection_key, block_data) contains_gated_content = include_access and block_data.get_xblock_field( diff --git a/lms/djangoapps/mobile_api/course_info/serializers.py b/lms/djangoapps/mobile_api/course_info/serializers.py index d7a9471088aa..d5ad89eb69a5 100644 --- a/lms/djangoapps/mobile_api/course_info/serializers.py +++ b/lms/djangoapps/mobile_api/course_info/serializers.py @@ -2,7 +2,7 @@ Course Info serializers """ from rest_framework import serializers -from typing import Union +from typing import Dict, Union from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment @@ -13,6 +13,7 @@ from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.access import administrative_accesses_to_course_for_user from lms.djangoapps.courseware.access_utils import check_course_open_for_learner +from lms.djangoapps.courseware.courses import get_course_assignments from lms.djangoapps.mobile_api.users.serializers import ModeSerializer from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_duration_limits.access import get_user_course_expiration_date @@ -31,6 +32,7 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer): course_sharing_utm_parameters = serializers.SerializerMethodField() course_about = serializers.SerializerMethodField('get_course_about_url') course_modes = serializers.SerializerMethodField() + course_progress = serializers.SerializerMethodField() class Meta: model = CourseOverview @@ -47,6 +49,7 @@ class Meta: 'course_sharing_utm_parameters', 'course_about', 'course_modes', + 'course_progress', ) @staticmethod @@ -75,6 +78,28 @@ def get_course_modes(self, course_overview): for mode in course_modes ] + def get_course_progress(self, obj: 'CourseOverview') -> Dict[str, int]: # noqa: F821 #here + """ + Gets course progress calculated by course assignments. + """ + course_assignments = get_course_assignments( + obj.id, + self.context.get('user'), + include_without_due=True, + ) + + total_assignments_count = 0 + assignments_completed = 0 + + if course_assignments: + total_assignments_count = len(course_assignments) + assignments_completed = len([assignment for assignment in course_assignments if assignment.complete]) + + return { + 'total_assignments_count': total_assignments_count, + 'assignments_completed': assignments_completed, + } + class MobileCourseEnrollmentSerializer(serializers.ModelSerializer): """ diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index c6de108727d8..40d586839680 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -271,6 +271,11 @@ class BlocksInfoInCourseView(BlocksInCourseView): course, chapter, sequential, vertical, html, problem, video, and discussion. display_name: (str) The display name of the block. + course_progress: (dict) Contains information about how many assignments are in the course + and how many assignments the student has completed. + Included here: + * total_assignments_count: (int) Total course's assignments count. + * assignments_completed: (int) Assignments witch the student has completed. **Returns** @@ -366,7 +371,7 @@ def list(self, request, **kwargs): # pylint: disable=W0221 ) course_info_context = { - 'user': requested_user + 'user': requested_user, } user_enrollment = CourseEnrollment.get_enrollment(user=requested_user, course_key=course_key) course_data.update({ diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py b/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py index 51d9acba54cc..c18d22f0ae99 100644 --- a/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py @@ -147,7 +147,8 @@ def setUp(self): self.user = UserFactory() self.course_overview = CourseOverviewFactory() - def test_get_media(self): + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) + def test_get_media(self, get_course_assignment_mock: MagicMock) -> None: output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertIn('media', output_data) @@ -156,16 +157,46 @@ def test_get_media(self): self.assertIn('small', output_data['media']['image']) self.assertIn('large', output_data['media']['image']) + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) @patch('lms.djangoapps.mobile_api.course_info.serializers.get_link_for_about_page', return_value='mock_about_link') - def test_get_course_sharing_utm_parameters(self, mock_get_link_for_about_page: MagicMock) -> None: + def test_get_course_sharing_utm_parameters( + self, + mock_get_link_for_about_page: MagicMock, + get_course_assignment_mock: MagicMock, + ) -> None: output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertEqual(output_data['course_about'], mock_get_link_for_about_page.return_value) mock_get_link_for_about_page.assert_called_once_with(self.course_overview) - def test_get_course_modes(self): + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) + def test_get_course_modes(self, get_course_assignment_mock: MagicMock) -> None: expected_course_modes = [{'slug': 'audit', 'sku': None, 'android_sku': None, 'ios_sku': None, 'min_price': 0}] output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data self.assertListEqual(output_data['course_modes'], expected_course_modes) + + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments', return_value=[]) + def test_get_course_progress_no_assignments(self, get_course_assignment_mock: MagicMock) -> None: + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data + + self.assertIn('course_progress', output_data) + self.assertDictEqual(output_data['course_progress'], expected_course_progress) + get_course_assignment_mock.assert_called_once_with(self.course_overview.id, self.user, include_without_due=True) + + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_course_assignments') + def test_get_course_progress_with_assignments(self, get_course_assignment_mock: MagicMock) -> None: + assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data + + self.assertIn('course_progress', output_data) + self.assertDictEqual(output_data['course_progress'], expected_course_progress) + get_course_assignment_mock.assert_called_once_with(self.course_overview.id, self.user, include_without_due=True) diff --git a/lms/djangoapps/mobile_api/users/serializers.py b/lms/djangoapps/mobile_api/users/serializers.py index 82389199ef72..dd82d9f99e49 100644 --- a/lms/djangoapps/mobile_api/users/serializers.py +++ b/lms/djangoapps/mobile_api/users/serializers.py @@ -5,7 +5,6 @@ from datetime import datetime from typing import Dict, List, Optional, Tuple, Union -from django.core.cache import cache from completion.exceptions import UnavailableCompletionData from completion.utilities import get_key_to_last_completed_block from opaque_keys import InvalidKeyError @@ -19,10 +18,9 @@ from lms.djangoapps.certificates.api import certificate_downloadable_status from lms.djangoapps.courseware.access import has_access from lms.djangoapps.courseware.context_processor import get_user_timezone_or_last_seen_timezone_or_utc -from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks +from lms.djangoapps.courseware.courses import get_course_assignment_date_blocks, get_course_assignments from lms.djangoapps.course_home_api.dates.serializers import DateSummarySerializer -from lms.djangoapps.grades.api import CourseGradeFactory -from openedx.core.djangoapps.content.block_structure.api import get_block_structure_manager +from lms.djangoapps.mobile_api.utils import API_V4 from openedx.features.course_duration_limits.access import get_user_course_expiration_date from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError @@ -107,8 +105,6 @@ class CourseEnrollmentSerializer(serializers.ModelSerializer): audit_access_expires = serializers.SerializerMethodField() course_modes = serializers.SerializerMethodField() - BLOCK_STRUCTURE_CACHE_TIMEOUT = 60 * 60 # 1 hour - def get_audit_access_expires(self, model): """ Returns expiration date for a course audit expiration, if any or null @@ -140,38 +136,37 @@ def get_course_modes(self, obj): for mode in course_modes ] - def to_representation(self, instance): + def to_representation(self, instance: CourseEnrollment) -> 'OrderedDict': # noqa: F821 """ Override the to_representation method to add the course_status field to the serialized data. """ data = super().to_representation(instance) - if 'progress' in self.context.get('requested_fields', []): - data['progress'] = self.calculate_progress(instance) + + if 'course_progress' in self.context.get('requested_fields', []) and self.context.get('api_version') == API_V4: + data['course_progress'] = self.calculate_progress(instance) return data def calculate_progress(self, model: CourseEnrollment) -> Dict[str, int]: """ Calculate the progress of the user in the course. - :param model: - :return: """ - is_staff = bool(has_access(model.user, 'staff', model.course.id)) + course_assignments = get_course_assignments( + model.course_id, + model.user, + include_without_due=True, + ) - cache_key = f'course_block_structure_{str(model.course.id)}_{model.user.id}' - collected_block_structure = cache.get(cache_key) - if not collected_block_structure: - collected_block_structure = get_block_structure_manager(model.course.id).get_collected() - cache.set(cache_key, collected_block_structure, self.BLOCK_STRUCTURE_CACHE_TIMEOUT) + total_assignments_count = 0 + assignments_completed = 0 - course_grade = CourseGradeFactory().read(model.user, collected_block_structure=collected_block_structure) + if course_assignments: + total_assignments_count = len(course_assignments) + assignments_completed = len([assignment for assignment in course_assignments if assignment.complete]) - # recalculate course grade from visible grades (stored grade was calculated over all grades, visible or not) - course_grade.update(visible_grades_only=True, has_staff_access=is_staff) - subsection_grades = list(course_grade.subsection_grades.values()) return { - 'num_points_earned': sum(map(lambda x: x.graded_total.earned if x.graded else 0, subsection_grades)), - 'num_points_possible': sum(map(lambda x: x.graded_total.possible if x.graded else 0, subsection_grades)), + 'total_assignments_count': total_assignments_count, + 'assignments_completed': assignments_completed, } class Meta: @@ -199,7 +194,7 @@ class CourseEnrollmentSerializerModifiedForPrimary(CourseEnrollmentSerializer): """ course_status = serializers.SerializerMethodField() - progress = serializers.SerializerMethodField() + course_progress = serializers.SerializerMethodField() course_assignments = serializers.SerializerMethodField() def __init__(self, *args, **kwargs): @@ -213,7 +208,7 @@ def get_course_status(self, model: CourseEnrollment) -> Optional[Dict[str, List[ try: block_id = str(get_key_to_last_completed_block(model.user, model.course.id)) except UnavailableCompletionData: - block_id = "" + block_id = '' if not block_id: return None @@ -251,7 +246,7 @@ def _get_last_visited_block_path_and_unit_name( return path, vertical.display_name - def get_progress(self, model: CourseEnrollment) -> Dict[str, int]: + def get_course_progress(self, model: CourseEnrollment) -> Dict[str, int]: """ Returns the progress of the user in the course. """ @@ -302,7 +297,7 @@ class Meta: 'certificate', 'course_modes', 'course_status', - 'progress', + 'course_progress', 'course_assignments', ) lookup_field = 'username' diff --git a/lms/djangoapps/mobile_api/users/tests.py b/lms/djangoapps/mobile_api/users/tests.py index 0cd959570c50..3b3e947689ce 100644 --- a/lms/djangoapps/mobile_api/users/tests.py +++ b/lms/djangoapps/mobile_api/users/tests.py @@ -4,7 +4,7 @@ import datetime -from unittest.mock import MagicMock, patch +from unittest.mock import MagicMock, Mock, patch from urllib.parse import parse_qs import ddt @@ -437,8 +437,7 @@ def test_student_dont_have_enrollments(self): self.assertDictEqual(expected_result, response.data) self.assertNotIn('primary', response.data) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_student_have_one_enrollment(self, cache_mock: MagicMock): + def test_student_have_one_enrollment(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -462,8 +461,7 @@ def test_student_have_one_enrollment(self, cache_mock: MagicMock): self.assertIn('primary', response.data) self.assertEqual(str(course.id), response.data['primary']['course']['id']) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_student_have_two_enrollments(self, cache_mock: MagicMock): + def test_student_have_two_enrollments(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -482,8 +480,7 @@ def test_student_have_two_enrollments(self, cache_mock: MagicMock): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(course_second.id)) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_student_have_more_then_ten_enrollments(self, cache_mock: MagicMock): + def test_student_have_more_then_ten_enrollments(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -503,8 +500,7 @@ def test_student_have_more_then_ten_enrollments(self, cache_mock: MagicMock): self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(latest_enrolment.id)) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_student_have_progress_in_old_course_and_enroll_newest_course(self, cache_mock: MagicMock): + def test_student_have_progress_in_old_course_and_enroll_newest_course(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -584,8 +580,7 @@ def test_student_enrolled_only_not_mobile_available_courses(self): self.assertDictEqual(expected_result, response.data) self.assertNotIn('primary', response.data) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_do_progress_in_not_mobile_available_course(self, cache_mock: MagicMock): + def test_do_progress_in_not_mobile_available_course(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -622,8 +617,7 @@ def test_do_progress_in_not_mobile_available_course(self, cache_mock: MagicMock) self.assertIn('primary', response.data) self.assertEqual(response.data['primary']['course']['id'], str(new_course.id)) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_pagination_for_user_enrollments_api_v4(self, cache_mock: MagicMock): + def test_pagination_for_user_enrollments_api_v4(self): """ Tests `UserCourseEnrollmentsV4Pagination`, api_version == v4. """ @@ -642,8 +636,7 @@ def test_pagination_for_user_enrollments_api_v4(self, cache_mock: MagicMock): self.assertIn('previous', response.data['enrollments']) self.assertIn('primary', response.data) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self, cache_mock: MagicMock): + def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. """ @@ -656,12 +649,10 @@ def test_course_status_in_primary_obj_when_student_doesnt_have_progress(self, ca self.assertEqual(response.status_code, status.HTTP_200_OK) self.assertEqual(response.data['primary']['course_status'], None) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) @patch('lms.djangoapps.mobile_api.users.serializers.get_key_to_last_completed_block') def test_course_status_in_primary_obj_when_student_have_progress( self, get_last_completed_block_mock: MagicMock, - cache_mock: MagicMock ): """ Testing modified `UserCourseEnrollmentsList` view with api_version == v4. @@ -708,8 +699,7 @@ def test_course_status_in_primary_obj_when_student_have_progress( self.assertEqual(response.data['primary']['course_status'], expected_course_status) get_last_completed_block_mock.assert_called_once_with(self.user, course.id) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_user_enrollment_api_v4_in_progress_status(self, cache_mock: MagicMock): + def test_user_enrollment_api_v4_in_progress_status(self): """ Testing """ @@ -903,8 +893,7 @@ def test_user_enrollment_api_v4_status_all(self): self.assertEqual(enrollments['results'][2]['course']['id'], str(old_course.id)) self.assertNotIn('primary', response.data) - @patch('lms.djangoapps.mobile_api.users.serializers.cache.set', return_value=None) - def test_response_contains_primary_enrollment_assignments_info(self, cache_mock: MagicMock): + def test_response_contains_primary_enrollment_assignments_info(self): self.login() course = CourseFactory.create(org='edx', mobile_available=True) self.enroll(course.id) @@ -918,6 +907,119 @@ def test_response_contains_primary_enrollment_assignments_info(self, cache_mock: self.assertListEqual(response.data['primary']['course_assignments']['past_assignments'], []) self.assertListEqual(response.data['primary']['course_assignments']['future_assignments'], []) + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments', return_value=[]) + def test_course_progress_in_primary_enrollment_with_no_assignments( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + course = CourseFactory.create(org='edx', mobile_available=True) + self.enroll(course.id) + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + + @patch( + 'lms.djangoapps.mobile_api.users.serializers.CourseEnrollmentSerializerModifiedForPrimary' + '.get_course_assignments' + ) + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments') + def test_course_progress_in_primary_enrollment_with_assignments( + self, + get_course_assignment_mock: MagicMock, + assignments_mock: MagicMock, + ) -> None: + self.login() + course = CourseFactory.create(org='edx', mobile_available=True) + self.enroll(course.id) + course_assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = course_assignments_mock + student_assignments_mock = { + 'future_assignments': [], + 'past_assignments': [], + } + assignments_mock.return_value = student_assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments') + def test_course_progress_for_secondary_enrollments_no_query_param( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + + response = self.api_response(api_version=API_V4) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for enrollment in response.data['enrollments']['results']: + self.assertNotIn('course_progress', enrollment) + + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments') + def test_course_progress_for_secondary_enrollments_with_query_param( + self, + get_course_assignment_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(5)] + for course in courses: + self.enroll(course.id) + expected_course_progress = {'total_assignments_count': 0, 'assignments_completed': 0} + + response = self.api_response(api_version=API_V4, data={'requested_fields': 'course_progress'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + for enrollment in response.data['enrollments']['results']: + self.assertIn('course_progress', enrollment) + self.assertDictEqual(enrollment['course_progress'], expected_course_progress) + + @patch( + 'lms.djangoapps.mobile_api.users.serializers.CourseEnrollmentSerializerModifiedForPrimary' + '.get_course_assignments' + ) + @patch('lms.djangoapps.mobile_api.users.serializers.get_course_assignments') + def test_course_progress_for_secondary_enrollments_with_query_param_and_assignments( + self, + get_course_assignment_mock: MagicMock, + assignments_mock: MagicMock, + ) -> None: + self.login() + courses = [CourseFactory.create(org='edx', mobile_available=True) for _ in range(2)] + for course in courses: + self.enroll(course.id) + course_assignments_mock = [ + Mock(complete=False), Mock(complete=False), Mock(complete=True), Mock(complete=True), Mock(complete=True) + ] + get_course_assignment_mock.return_value = course_assignments_mock + student_assignments_mock = { + 'future_assignments': [], + 'past_assignments': [], + } + assignments_mock.return_value = student_assignments_mock + expected_course_progress = {'total_assignments_count': 5, 'assignments_completed': 3} + + response = self.api_response(api_version=API_V4, data={'requested_fields': 'course_progress'}) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertIn('course_progress', response.data['primary']) + self.assertDictEqual(response.data['primary']['course_progress'], expected_course_progress) + self.assertIn('course_progress', response.data['enrollments']['results'][0]) + self.assertDictEqual(response.data['enrollments']['results'][0]['course_progress'], expected_course_progress) + @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) class TestUserEnrollmentCertificates(UrlResetMixin, MobileAPITestCase, MilestonesTestCaseMixin): diff --git a/lms/djangoapps/mobile_api/users/views.py b/lms/djangoapps/mobile_api/users/views.py index 2fd5d79cd00c..f3a5baca76a4 100644 --- a/lms/djangoapps/mobile_api/users/views.py +++ b/lms/djangoapps/mobile_api/users/views.py @@ -329,6 +329,10 @@ class UserCourseEnrollmentsList(generics.ListAPIView): * mode: The type of certificate registration for this course (honor or certified). * url: URL to the downloadable version of the certificate, if exists. + * course_progress: Contains information about how many assignments are in the course + and how many assignments the student has completed. + * total_assignments_count: Total course's assignments count. + * assignments_completed: Assignments witch the student has completed. """ lookup_field = 'username' From e5697e7b325ae85d45ea39525c139d7a11454d40 Mon Sep 17 00:00:00 2001 From: Ivan Niedielnitsev <81557788+NiedielnitsevIvan@users.noreply.github.com> Date: Tue, 14 May 2024 15:46:54 +0300 Subject: [PATCH 14/26] feat: [AXM-373] Add push notification event about course invitations (#2557) * feat: [AXM-373] add push notifications for user enroll * feat: [AXM-373] add push notifications for user unenroll * feat: [AXM-373] add push notifications for add course beta testers * feat: [AXM-373] add push notifications for remove course beta testers * style: [AXM-373] remove debugger * fix: [AXM-373] fix after demo --- lms/djangoapps/instructor/enrollment.py | 20 +++++++++++++++++++ .../edx_ace/addbetatester/push/body.txt | 5 +++++ .../edx_ace/addbetatester/push/subject.txt | 4 ++++ .../edx_ace/allowedenroll/push/body.txt | 5 +++++ .../edx_ace/allowedenroll/push/subject.txt | 4 ++++ .../edx_ace/allowedunenroll/push/body.txt | 5 +++++ .../edx_ace/allowedunenroll/push/subject.txt | 4 ++++ .../edx_ace/enrolledunenroll/push/body.txt | 5 +++++ .../edx_ace/enrolledunenroll/push/subject.txt | 4 ++++ .../edx_ace/enrollenrolled/push/body.txt | 5 +++++ .../edx_ace/enrollenrolled/push/subject.txt | 4 ++++ .../edx_ace/removebetatester/push/body.txt | 5 +++++ .../edx_ace/removebetatester/push/subject.txt | 4 ++++ .../core/djangoapps/notifications/policies.py | 2 +- 14 files changed, 75 insertions(+), 1 deletion(-) create mode 100644 lms/templates/instructor/edx_ace/addbetatester/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/addbetatester/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/allowedenroll/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt create mode 100644 lms/templates/instructor/edx_ace/removebetatester/push/body.txt create mode 100644 lms/templates/instructor/edx_ace/removebetatester/push/subject.txt diff --git a/lms/djangoapps/instructor/enrollment.py b/lms/djangoapps/instructor/enrollment.py index 300543def6c2..4643cc307ad9 100644 --- a/lms/djangoapps/instructor/enrollment.py +++ b/lms/djangoapps/instructor/enrollment.py @@ -141,6 +141,14 @@ def enroll_email(course_id, student_email, auto_enroll=False, email_students=Fal """ previous_state = EmailEnrollmentState(course_id, student_email) enrollment_obj = None + if email_params: + email_params.update({ + 'app_label': 'instructor', + 'push_notification_extra_context': { + 'notification_type': 'enroll', + 'course_id': str(course_id), + }, + }) if previous_state.user and previous_state.user.is_active: # if the student is currently unenrolled, don't enroll them in their # previous mode @@ -194,6 +202,13 @@ def unenroll_email(course_id, student_email, email_students=False, email_params= representing state before and after the action. """ previous_state = EmailEnrollmentState(course_id, student_email) + if email_params: + email_params.update({ + 'app_label': 'instructor', + 'push_notification_extra_context': { + 'notification_type': 'unenroll', + }, + }) if previous_state.enrollment: CourseEnrollment.unenroll_by_email(student_email, course_id) if email_students: @@ -232,6 +247,11 @@ def send_beta_role_email(action, user, email_params): email_params['email_address'] = user.email email_params['user_id'] = user.id email_params['full_name'] = user.profile.name + email_params['app_label'] = 'instructor' + email_params['push_notification_extra_context'] = { + 'notification_type': email_params['message_type'], + 'course_id': str(getattr(email_params.get('course'), 'id', '')), + } else: raise ValueError(f"Unexpected action received '{action}' - expected 'add' or 'remove'") trying_to_add_inactive_user = not user.is_active and action == 'add' diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/body.txt b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt new file mode 100644 index 000000000000..9070f05db483 --- /dev/null +++ b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }}{% endblocktrans %} +{% blocktrans %}You have been invited to be a beta tester for {{ course_name }} at {{ site_name }} by a member of the course staff.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt b/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt new file mode 100644 index 000000000000..ca1838195f55 --- /dev/null +++ b/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been invited to a betca test for {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt new file mode 100644 index 000000000000..cddc8d38e29b --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear student,{% endblocktrans %} +{% blocktrans %}You have been invited to join {{ course_name }} at {{ site_name }} by a member of the course staff.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt new file mode 100644 index 000000000000..1cbe2a29ab05 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been invited to register for {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt new file mode 100644 index 000000000000..5981633b96b8 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear Student,{% endblocktrans %} +{% blocktrans %}You have been unenrolled from the course {{ course_name }} by a member of the course staff. Please disregard the invitation previously sent.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt new file mode 100644 index 000000000000..99aaa1a9c305 --- /dev/null +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt new file mode 100644 index 000000000000..da9c3a3de00c --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }}{% endblocktrans %} +{% blocktrans %}You have been unenrolled from {{ course_name }} at {{ site_name }} by a member of the course staff. This course will no longer appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt new file mode 100644 index 000000000000..99aaa1a9c305 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been unenrolled from {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt new file mode 100644 index 000000000000..36bd69d7b72f --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }}{% endblocktrans %} +{% blocktrans %}You have been enrolled in {{ course_name }} at {{ site_name }} by a member of the course staff. This course will now appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt new file mode 100644 index 000000000000..ebe884b30f08 --- /dev/null +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been enrolled in {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/body.txt b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt new file mode 100644 index 000000000000..586a9385f988 --- /dev/null +++ b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt @@ -0,0 +1,5 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}Dear {{ full_name }}{% endblocktrans %} +{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }} by a member of the course staff. This course will remain on your dashboard, but you will no longer be part of the beta testing group.{% endblocktrans %} +{% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/subject.txt b/lms/templates/instructor/edx_ace/removebetatester/push/subject.txt new file mode 100644 index 000000000000..33dee3487f96 --- /dev/null +++ b/lms/templates/instructor/edx_ace/removebetatester/push/subject.txt @@ -0,0 +1,4 @@ +{% load i18n %} +{% autoescape off %} +{% blocktrans %}You have been removed from a beta test for {{ course_name }}{% endblocktrans %} +{% endautoescape %} diff --git a/openedx/core/djangoapps/notifications/policies.py b/openedx/core/djangoapps/notifications/policies.py index 768d05e62efd..8467eda9324e 100644 --- a/openedx/core/djangoapps/notifications/policies.py +++ b/openedx/core/djangoapps/notifications/policies.py @@ -21,7 +21,7 @@ def check(self, message): course_ids = message.context.get('course_ids', []) app_label = message.context.get('app_label') - if not (app_label or message.context.get('send_push_notification', False)): + if not (app_label or message.context.get('push_notification_extra_context', {})): return PolicyResult(deny={ChannelType.PUSH}) course_keys = [CourseKey.from_string(course_id) for course_id in course_ids] From 0d02744b67da40d801e7142b5912d07e116763e9 Mon Sep 17 00:00:00 2001 From: Ivan Niedielnitsev <81557788+NiedielnitsevIvan@users.noreply.github.com> Date: Wed, 22 May 2024 18:58:30 +0300 Subject: [PATCH 15/26] refactor: [AXM-475] refactor firebase settings (#2560) * refactor: [AXM-475] refactor firebase settings * fix: [AXM-475] fix device token API permissions * fix: [AXM-475] change texts for push notifs * fix: [AXM-475] update django-push-notifications version * refactor: [AXM-475] refactor firebase settings --- .../mobile_api/notifications/views.py | 2 +- .../edx_ace/addbetatester/push/body.txt | 2 +- .../edx_ace/allowedenroll/push/body.txt | 2 +- .../edx_ace/allowedunenroll/push/body.txt | 2 +- .../edx_ace/enrolledunenroll/push/body.txt | 2 +- .../edx_ace/enrollenrolled/push/body.txt | 2 +- .../edx_ace/removebetatester/push/body.txt | 2 +- .../djangoapps/ace_common/settings/common.py | 43 +++++++++---------- .../ace_common/settings/production.py | 33 +++++++------- openedx/core/djangoapps/ace_common/utils.py | 9 +++- requirements/edx/base.txt | 5 ++- requirements/edx/coverage.txt | 2 +- requirements/edx/development.txt | 7 +-- requirements/edx/doc.txt | 5 ++- requirements/edx/github.in | 2 +- requirements/edx/testing.txt | 7 +-- 16 files changed, 65 insertions(+), 62 deletions(-) diff --git a/lms/djangoapps/mobile_api/notifications/views.py b/lms/djangoapps/mobile_api/notifications/views.py index aeab8c5445c9..4c94ae576e76 100644 --- a/lms/djangoapps/mobile_api/notifications/views.py +++ b/lms/djangoapps/mobile_api/notifications/views.py @@ -7,7 +7,7 @@ from ..decorators import mobile_view -@mobile_view(is_user=True) +@mobile_view() class GCMDeviceViewSet(GCMDeviceViewSetBase): """ **Use Case** diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/body.txt b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt index 9070f05db483..6cd170cd5ce1 100644 --- a/lms/templates/instructor/edx_ace/addbetatester/push/body.txt +++ b/lms/templates/instructor/edx_ace/addbetatester/push/body.txt @@ -1,5 +1,5 @@ {% load i18n %} {% autoescape off %} {% blocktrans %}Dear {{ full_name }}{% endblocktrans %} -{% blocktrans %}You have been invited to be a beta tester for {{ course_name }} at {{ site_name }} by a member of the course staff.{% endblocktrans %} +{% blocktrans %}You have been invited to be a beta tester for {{ course_name }} at {{ site_name }}.{% endblocktrans %} {% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt index cddc8d38e29b..fc2e3cce4680 100644 --- a/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt +++ b/lms/templates/instructor/edx_ace/allowedenroll/push/body.txt @@ -1,5 +1,5 @@ {% load i18n %} {% autoescape off %} {% blocktrans %}Dear student,{% endblocktrans %} -{% blocktrans %}You have been invited to join {{ course_name }} at {{ site_name }} by a member of the course staff.{% endblocktrans %} +{% blocktrans %}You have been invited to join {{ course_name }} at {{ site_name }}.{% endblocktrans %} {% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt index 5981633b96b8..b825ce1d4d18 100644 --- a/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt +++ b/lms/templates/instructor/edx_ace/allowedunenroll/push/body.txt @@ -1,5 +1,5 @@ {% load i18n %} {% autoescape off %} {% blocktrans %}Dear Student,{% endblocktrans %} -{% blocktrans %}You have been unenrolled from the course {{ course_name }} by a member of the course staff. Please disregard the invitation previously sent.{% endblocktrans %} +{% blocktrans %}You have been unenrolled from the course {{ course_name }}. Please disregard the invitation previously sent.{% endblocktrans %} {% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt index da9c3a3de00c..ce94b24f6167 100644 --- a/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt +++ b/lms/templates/instructor/edx_ace/enrolledunenroll/push/body.txt @@ -1,5 +1,5 @@ {% load i18n %} {% autoescape off %} {% blocktrans %}Dear {{ full_name }}{% endblocktrans %} -{% blocktrans %}You have been unenrolled from {{ course_name }} at {{ site_name }} by a member of the course staff. This course will no longer appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% blocktrans %}You have been unenrolled from {{ course_name }} at {{ site_name }}. This course will no longer appear on your {{ site_name }} dashboard.{% endblocktrans %} {% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt index 36bd69d7b72f..883f14770512 100644 --- a/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt +++ b/lms/templates/instructor/edx_ace/enrollenrolled/push/body.txt @@ -1,5 +1,5 @@ {% load i18n %} {% autoescape off %} {% blocktrans %}Dear {{ full_name }}{% endblocktrans %} -{% blocktrans %}You have been enrolled in {{ course_name }} at {{ site_name }} by a member of the course staff. This course will now appear on your {{ site_name }} dashboard.{% endblocktrans %} +{% blocktrans %}You have been enrolled in {{ course_name }} at {{ site_name }}. This course will now appear on your {{ site_name }} dashboard.{% endblocktrans %} {% endautoescape %} diff --git a/lms/templates/instructor/edx_ace/removebetatester/push/body.txt b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt index 586a9385f988..4806be929b83 100644 --- a/lms/templates/instructor/edx_ace/removebetatester/push/body.txt +++ b/lms/templates/instructor/edx_ace/removebetatester/push/body.txt @@ -1,5 +1,5 @@ {% load i18n %} {% autoescape off %} {% blocktrans %}Dear {{ full_name }}{% endblocktrans %} -{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }} by a member of the course staff. This course will remain on your dashboard, but you will no longer be part of the beta testing group.{% endblocktrans %} +{% blocktrans %}You have been removed as a beta tester for {{ course_name }} at {{ site_name }}. This course will remain on your dashboard, but you will no longer be part of the beta testing group.{% endblocktrans %} {% endautoescape %} diff --git a/openedx/core/djangoapps/ace_common/settings/common.py b/openedx/core/djangoapps/ace_common/settings/common.py index 58341470ed8d..dd1d5b680763 100644 --- a/openedx/core/djangoapps/ace_common/settings/common.py +++ b/openedx/core/djangoapps/ace_common/settings/common.py @@ -27,27 +27,24 @@ def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function settings.FEATURES['test_django_plugin'] = True settings.FCM_APP_NAME = 'fcm-edx-platform' - if getattr(settings, 'FIREBASE_SETUP_STATUS', None) is None: - settings.ACE_CHANNEL_DEFAULT_PUSH = 'push_notification' - - # Note: To local development with Firebase, you must set FIREBASE_CREDENTIALS. - settings.FCM_APP_NAME = 'fcm-edx-platform' - settings.FIREBASE_CREDENTIALS = None - - if firebase_app := setup_firebase_app(settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME): - settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) - settings.ACE_ENABLED_POLICIES.append(settings.ACE_CHANNEL_DEFAULT_PUSH) - - settings.PUSH_NOTIFICATIONS_SETTINGS = { - 'CONFIG': 'push_notifications.conf.AppConfig', - 'APPLICATIONS': { - settings.FCM_APP_NAME: { - 'PLATFORM': 'FCM', - 'FIREBASE_APP': firebase_app, - }, + settings.ACE_CHANNEL_DEFAULT_PUSH = 'push_notification' + # Note: To local development with Firebase, you must set FIREBASE_CREDENTIALS. + settings.FCM_APP_NAME = 'fcm-edx-platform' + settings.FIREBASE_CREDENTIALS = None + + settings.FIREBASE_APP = setup_firebase_app(settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME) + + if getattr(settings, 'FIREBASE_APP', None): + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': settings.FIREBASE_APP, }, - 'UPDATE_ON_DUPLICATE_REG_ID': True, - } - settings.FIREBASE_SETUP_STATUS = True - else: - settings.FIREBASE_SETUP_STATUS = False + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } diff --git a/openedx/core/djangoapps/ace_common/settings/production.py b/openedx/core/djangoapps/ace_common/settings/production.py index b7ac5b12db17..d3409e13f306 100644 --- a/openedx/core/djangoapps/ace_common/settings/production.py +++ b/openedx/core/djangoapps/ace_common/settings/production.py @@ -27,24 +27,21 @@ def plugin_settings(settings): settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL = settings.ENV_TOKENS.get( 'ACE_CHANNEL_TRANSACTIONAL_EMAIL', settings.ACE_CHANNEL_TRANSACTIONAL_EMAIL ) - settings.FCM_APP_NAME = settings.ENV_TOKENS.get('FCM_APP_NAME', 'fcm-edx-platform') - settings.FIREBASE_CREDENTIALS = settings.ENV_TOKENS.get('FIREBASE_CREDENTIALS', {}) + settings.FCM_APP_NAME = settings.ENV_TOKENS.get('FCM_APP_NAME', settings.FCM_APP_NAME) + settings.FIREBASE_CREDENTIALS = settings.ENV_TOKENS.get('FIREBASE_CREDENTIALS', settings.FIREBASE_CREDENTIALS) - if getattr(settings, 'FIREBASE_SETUP_STATUS', None) is None: - if firebase_app := setup_firebase_app(settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME): - settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) - settings.ACE_ENABLED_POLICIES.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.FIREBASE_APP = setup_firebase_app(settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME) + if settings.FIREBASE_APP: + settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append(settings.ACE_CHANNEL_DEFAULT_PUSH) - settings.PUSH_NOTIFICATIONS_SETTINGS = { - 'CONFIG': 'push_notifications.conf.AppConfig', - 'APPLICATIONS': { - settings.FCM_APP_NAME: { - 'PLATFORM': 'FCM', - 'FIREBASE_APP': firebase_app, - }, + settings.PUSH_NOTIFICATIONS_SETTINGS = { + 'CONFIG': 'push_notifications.conf.AppConfig', + 'APPLICATIONS': { + settings.FCM_APP_NAME: { + 'PLATFORM': 'FCM', + 'FIREBASE_APP': settings.FIREBASE_APP, }, - 'UPDATE_ON_DUPLICATE_REG_ID': True, - } - settings.FIREBASE_SETUP_STATUS = True - else: - settings.FIREBASE_SETUP_STATUS = False + }, + 'UPDATE_ON_DUPLICATE_REG_ID': True, + } diff --git a/openedx/core/djangoapps/ace_common/utils.py b/openedx/core/djangoapps/ace_common/utils.py index adf5586dc449..508ac4033cd1 100644 --- a/openedx/core/djangoapps/ace_common/utils.py +++ b/openedx/core/djangoapps/ace_common/utils.py @@ -15,6 +15,11 @@ def setup_firebase_app(firebase_credentials, app_name='fcm-app'): except ImportError: log.error('Could not import firebase_admin package.') return + if firebase_credentials: - certificate = firebase_admin.credentials.Certificate(firebase_credentials) - return firebase_admin.initialize_app(certificate, name=app_name) + try: + app = firebase_admin.get_app(app_name) + except ValueError: + certificate = firebase_admin.credentials.Certificate(firebase_credentials) + app = firebase_admin.initialize_app(certificate, name=app_name) + return app diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 5171438a9519..cfc59346b6c7 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -4,7 +4,7 @@ # # make upgrade # --e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications # via -r requirements/edx/github.in -e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace # via @@ -567,7 +567,7 @@ fastavro==1.9.4 # via openedx-events filelock==3.14.0 # via snowflake-connector-python -firebase-admin==5.0.0 +firebase-admin==6.5.0 # via edx-ace frozenlist==1.4.1 # via @@ -946,6 +946,7 @@ pyjwt[crypto]==2.8.0 # edx-drf-extensions # edx-proctoring # edx-rest-api-client + # firebase-admin # pylti1p3 # snowflake-connector-python # social-auth-core diff --git a/requirements/edx/coverage.txt b/requirements/edx/coverage.txt index e150dc3fe238..e41cc829fa37 100644 --- a/requirements/edx/coverage.txt +++ b/requirements/edx/coverage.txt @@ -8,7 +8,7 @@ chardet==5.2.0 # via diff-cover coverage==7.5.1 # via -r requirements/edx/coverage.in -diff-cover==9.0.0 +diff-cover==7.7.0 # via -r requirements/edx/coverage.in jinja2==3.1.4 # via diff-cover diff --git a/requirements/edx/development.txt b/requirements/edx/development.txt index d2b8908dce09..b1e7e3a1242a 100644 --- a/requirements/edx/development.txt +++ b/requirements/edx/development.txt @@ -4,7 +4,7 @@ # # make upgrade # --e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -351,7 +351,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -diff-cover==9.0.0 +diff-cover==7.7.0 # via -r requirements/edx/testing.txt dill==0.3.8 # via @@ -926,7 +926,7 @@ filelock==3.14.0 # snowflake-connector-python # tox # virtualenv -firebase-admin==5.0.0 +firebase-admin==6.5.0 # via # -r requirements/edx/doc.txt # -r requirements/edx/testing.txt @@ -1651,6 +1651,7 @@ pyjwt[crypto]==2.8.0 # edx-drf-extensions # edx-proctoring # edx-rest-api-client + # firebase-admin # pylti1p3 # snowflake-connector-python # social-auth-core diff --git a/requirements/edx/doc.txt b/requirements/edx/doc.txt index e1c01186f330..33559d2837a7 100644 --- a/requirements/edx/doc.txt +++ b/requirements/edx/doc.txt @@ -4,7 +4,7 @@ # # make upgrade # --e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications # via -r requirements/edx/base.txt -e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace # via -r requirements/edx/base.txt @@ -653,7 +653,7 @@ filelock==3.14.0 # via # -r requirements/edx/base.txt # snowflake-connector-python -firebase-admin==5.0.0 +firebase-admin==6.5.0 # via # -r requirements/edx/base.txt # edx-ace @@ -1137,6 +1137,7 @@ pyjwt[crypto]==2.8.0 # edx-drf-extensions # edx-proctoring # edx-rest-api-client + # firebase-admin # pylti1p3 # snowflake-connector-python # social-auth-core diff --git a/requirements/edx/github.in b/requirements/edx/github.in index 7729b94e1ca3..0d81f31a051d 100644 --- a/requirements/edx/github.in +++ b/requirements/edx/github.in @@ -92,4 +92,4 @@ -e git+https://github.com/anupdhabarde/edx-proctoring-proctortrack.git@31c6c9923a51c903ae83760ecbbac191363aa2a2#egg=edx_proctoring_proctortrack -e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace --e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications diff --git a/requirements/edx/testing.txt b/requirements/edx/testing.txt index c255defa15e1..d04dffca0a16 100644 --- a/requirements/edx/testing.txt +++ b/requirements/edx/testing.txt @@ -4,7 +4,7 @@ # # make upgrade # --e git+https://github.com/jazzband/django-push-notifications.git@906fe52058bad36b6af2bb292fdb9292ccaa94e5#egg=django_push_notifications +-e git+https://github.com/jazzband/django-push-notifications.git@0f7918136b5e6a9aec83d6513aad5b0f12143a9f#egg=django_push_notifications # via -r requirements/edx/base.txt -e git+https://github.com/raccoongang/edx-ace.git@mob-develop#egg=edx_ace # via -r requirements/edx/base.txt @@ -267,7 +267,7 @@ defusedxml==0.7.1 # ora2 # python3-openid # social-auth-core -diff-cover==9.0.0 +diff-cover==7.7.0 # via -r requirements/edx/coverage.txt dill==0.3.8 # via pylint @@ -707,7 +707,7 @@ filelock==3.14.0 # snowflake-connector-python # tox # virtualenv -firebase-admin==5.0.0 +firebase-admin==6.5.0 # via # -r requirements/edx/base.txt # edx-ace @@ -1240,6 +1240,7 @@ pyjwt[crypto]==2.8.0 # edx-drf-extensions # edx-proctoring # edx-rest-api-client + # firebase-admin # pylti1p3 # snowflake-connector-python # social-auth-core From 4cfab2db8befde8f121555c445b010eabd7a640d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Wed, 29 May 2024 15:21:18 +0300 Subject: [PATCH 16/26] feat: [AXM-556] refactor discussion push notifications sending --- lms/djangoapps/discussion/tasks.py | 64 ++++++++++++++++--- .../edx_ace/commentnotification/push/body.txt | 3 + .../commentnotification/push/subject.txt | 3 + .../djangoapps/ace_common/settings/common.py | 2 +- .../ace_common/settings/production.py | 2 +- 5 files changed, 62 insertions(+), 12 deletions(-) create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt create mode 100644 lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index 7d19cbb9cd63..ce6baaa5873d 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -12,6 +12,7 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.contrib.sites.models import Site from edx_ace import ace +from edx_ace.channel import ChannelType from edx_ace.recipient import Recipient from edx_ace.utils import date from edx_django_utils.monitoring import set_code_owner_attribute @@ -74,6 +75,12 @@ def __init__(self, *args, **kwargs): self.options['transactional'] = True +class CommentNotification(BaseMessageType): + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.options['transactional'] = True + + @shared_task(base=LoggedTask) @set_code_owner_attribute def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function-docstring @@ -82,17 +89,40 @@ def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function if _should_send_message(context): context['site'] = Site.objects.get(id=context['site_id']) thread_author = User.objects.get(id=context['thread_author_id']) - with emulate_http_request(site=context['site'], user=thread_author): - message_context = _build_message_context(context) + comment_author = User.objects.get(id=context['comment_author_id']) + with emulate_http_request(site=context['site'], user=comment_author): + message_context = _build_message_context(context, notification_type='forum_response') message = ResponseNotification().personalize( Recipient(thread_author.id, thread_author.email), _get_course_language(context['course_id']), message_context ) - log.info('Sending forum comment email notification with context %s', message_context) - ace.send(message) + log.info('Sending forum comment notification with context %s', message_context) + if context['comment_author_id'] != context['thread_author_id']: + limit_to_channels = None + else: + limit_to_channels = [ChannelType.PUSH] + ace.send(message, limit_to_channels=limit_to_channels) _track_notification_sent(message, context) + elif _should_send_subcomment_message(context): + context['site'] = Site.objects.get(id=context['site_id']) + comment_author = User.objects.get(id=context['comment_author_id']) + thread_author = User.objects.get(id=context['thread_author_id']) + + with emulate_http_request(site=context['site'], user=comment_author): + message_context = _build_message_context(context) + message = CommentNotification().personalize( + Recipient(thread_author.id, thread_author.email), + _get_course_language(context['course_id']), + message_context + ) + log.info('Sending forum comment notification with context %s', message_context) + ace.send(message, limit_to_channels=[ChannelType.PUSH]) + _track_notification_sent(message, context) + else: + return + @shared_task(base=LoggedTask) @set_code_owner_attribute @@ -153,8 +183,17 @@ def _should_send_message(context): cc_thread_author = cc.User(id=context['thread_author_id'], course_id=context['course_id']) return ( _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and - _is_not_subcomment(context['comment_id']) and - _is_first_comment(context['comment_id'], context['thread_id']) + _is_not_subcomment(context['comment_id']) + ) + + +def _should_send_subcomment_message(context): + cc_thread_author = cc.User(id=context['thread_author_id'], course_id=context['course_id']) + comment_author_is_thread_author = context['comment_author_id'] == context['thread_author_id'] + return ( + _is_user_subscribed_to_thread(cc_thread_author, context['thread_id']) and + _is_subcomment(context['comment_id']) and + not comment_author_is_thread_author ) @@ -164,9 +203,13 @@ def _is_content_still_reported(context): return len(cc.Thread.find(context['thread_id']).abuse_flaggers) > 0 -def _is_not_subcomment(comment_id): +def _is_subcomment(comment_id): comment = cc.Comment.find(id=comment_id).retrieve() - return not getattr(comment, 'parent_id', None) + return getattr(comment, 'parent_id', None) + + +def _is_not_subcomment(comment_id): + return not _is_subcomment(comment_id) def _is_first_comment(comment_id, thread_id): # lint-amnesty, pylint: disable=missing-function-docstring @@ -204,7 +247,7 @@ def _get_course_language(course_id): return language -def _build_message_context(context): # lint-amnesty, pylint: disable=missing-function-docstring +def _build_message_context(context, notification_type='forum_comment'): # lint-amnesty, pylint: disable=missing-function-docstring message_context = get_base_template_context(context['site']) message_context.update(context) thread_author = User.objects.get(id=context['thread_author_id']) @@ -219,7 +262,8 @@ def _build_message_context(context): # lint-amnesty, pylint: disable=missing-fu 'comment_username': comment_author.username, 'post_link': post_link, 'push_notification_extra_context': { - 'notification_type': 'forum_comment', + 'notification_type': notification_type, + 'topic_id': context['thread_commentable_id'], 'thread_id': context['thread_id'], 'comment_id': context['comment_id'], }, diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt new file mode 100644 index 000000000000..391e3d8ef4d7 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/body.txt @@ -0,0 +1,3 @@ +{% load i18n %} +{% blocktrans trimmed %}{{ comment_username }} commented to {{ thread_title }}:{% endblocktrans %} +{{ comment_body_text }} diff --git a/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt new file mode 100644 index 000000000000..d2298a812990 --- /dev/null +++ b/lms/djangoapps/discussion/templates/discussion/edx_ace/commentnotification/push/subject.txt @@ -0,0 +1,3 @@ +{% load i18n %} + +{% blocktrans %}Comment to {{ thread_title }}{% endblocktrans %} diff --git a/openedx/core/djangoapps/ace_common/settings/common.py b/openedx/core/djangoapps/ace_common/settings/common.py index dd1d5b680763..9dd0962b2d3a 100644 --- a/openedx/core/djangoapps/ace_common/settings/common.py +++ b/openedx/core/djangoapps/ace_common/settings/common.py @@ -36,7 +36,7 @@ def plugin_settings(settings): # lint-amnesty, pylint: disable=missing-function if getattr(settings, 'FIREBASE_APP', None): settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) - settings.ACE_ENABLED_POLICIES.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append('bulk_push_notification_optout') settings.PUSH_NOTIFICATIONS_SETTINGS = { 'CONFIG': 'push_notifications.conf.AppConfig', diff --git a/openedx/core/djangoapps/ace_common/settings/production.py b/openedx/core/djangoapps/ace_common/settings/production.py index d3409e13f306..0d128850f536 100644 --- a/openedx/core/djangoapps/ace_common/settings/production.py +++ b/openedx/core/djangoapps/ace_common/settings/production.py @@ -33,7 +33,7 @@ def plugin_settings(settings): settings.FIREBASE_APP = setup_firebase_app(settings.FIREBASE_CREDENTIALS, settings.FCM_APP_NAME) if settings.FIREBASE_APP: settings.ACE_ENABLED_CHANNELS.append(settings.ACE_CHANNEL_DEFAULT_PUSH) - settings.ACE_ENABLED_POLICIES.append(settings.ACE_CHANNEL_DEFAULT_PUSH) + settings.ACE_ENABLED_POLICIES.append('bulk_push_notification_optout') settings.PUSH_NOTIFICATIONS_SETTINGS = { 'CONFIG': 'push_notifications.conf.AppConfig', From 036f9898a275f73d33731ddfcf13429ae48e65c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Wed, 29 May 2024 15:27:46 +0300 Subject: [PATCH 17/26] fix: fix typo --- lms/templates/instructor/edx_ace/addbetatester/push/subject.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt b/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt index ca1838195f55..973411afd35f 100644 --- a/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt +++ b/lms/templates/instructor/edx_ace/addbetatester/push/subject.txt @@ -1,4 +1,4 @@ {% load i18n %} {% autoescape off %} -{% blocktrans %}You have been invited to a betca test for {{ course_name }}{% endblocktrans %} +{% blocktrans %}You have been invited to a beta test for {{ course_name }}{% endblocktrans %} {% endautoescape %} From b8639e377a4a7753bb3f67b68ee6515a48c40c4b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Wed, 29 May 2024 16:06:54 +0300 Subject: [PATCH 18/26] test: [AXM-556] add topic_id to tests --- lms/djangoapps/discussion/tasks.py | 4 ++-- lms/djangoapps/discussion/tests/test_tasks.py | 16 +++++++++++++--- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index ce6baaa5873d..23af89888134 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -98,7 +98,7 @@ def send_ace_message(context): # lint-amnesty, pylint: disable=missing-function message_context ) log.info('Sending forum comment notification with context %s', message_context) - if context['comment_author_id'] != context['thread_author_id']: + if _is_first_comment(context['comment_id'], context['thread_id']): limit_to_channels = None else: limit_to_channels = [ChannelType.PUSH] @@ -263,7 +263,7 @@ def _build_message_context(context, notification_type='forum_comment'): # lint- 'post_link': post_link, 'push_notification_extra_context': { 'notification_type': notification_type, - 'topic_id': context['thread_commentable_id'], + 'topic_id': context.get('thread_commentable_id', ''), 'thread_id': context['thread_id'], 'comment_id': context['comment_id'], }, diff --git a/lms/djangoapps/discussion/tests/test_tasks.py b/lms/djangoapps/discussion/tests/test_tasks.py index d3d3edb1f9e2..be9d2e994b70 100644 --- a/lms/djangoapps/discussion/tests/test_tasks.py +++ b/lms/djangoapps/discussion/tests/test_tasks.py @@ -19,7 +19,7 @@ import openedx.core.djangoapps.django_comment_common.comment_client as cc from common.djangoapps.student.tests.factories import CourseEnrollmentFactory, UserFactory from lms.djangoapps.discussion.signals.handlers import ENABLE_FORUM_NOTIFICATIONS_FOR_SITE_KEY -from lms.djangoapps.discussion.tasks import _should_send_message, _track_notification_sent +from lms.djangoapps.discussion.tasks import _is_first_comment, _should_send_message, _track_notification_sent from openedx.core.djangoapps.ace_common.template_context import get_base_template_context from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory from openedx.core.djangoapps.django_comment_common.models import ForumsConfig @@ -222,6 +222,8 @@ def setUp(self): self.ace_send_patcher = mock.patch('edx_ace.ace.send') self.mock_ace_send = self.ace_send_patcher.start() + self.mock_message_patcher = mock.patch('lms.djangoapps.discussion.tasks.ResponseNotification') + self.mock_message = self.mock_message_patcher.start() thread_permalink = '/courses/discussion/dummy_discussion_id' self.permalink_patcher = mock.patch('lms.djangoapps.discussion.tasks.permalink', return_value=thread_permalink) @@ -231,10 +233,12 @@ def tearDown(self): super().tearDown() self.request_patcher.stop() self.ace_send_patcher.stop() + self.mock_message_patcher.stop() self.permalink_patcher.stop() @ddt.data(True, False) def test_send_discussion_email_notification(self, user_subscribed): + self.mock_message_patcher.stop() if user_subscribed: non_matching_id = 'not-a-match' # with per_page left with a default value of 1, this ensures @@ -286,7 +290,8 @@ def test_send_discussion_email_notification(self, user_subscribed): 'site': site, 'site_id': site.id, 'push_notification_extra_context': { - 'notification_type': 'forum_comment', + 'notification_type': 'forum_response', + 'topic_id': thread['commentable_id'], 'thread_id': thread['id'], 'comment_id': comment['id'], }, @@ -332,7 +337,12 @@ def run_should_not_send_email_test(self, thread, comment_dict): 'comment_id': comment_dict['id'], 'thread_id': thread['id'], }) - assert actual_result is False + # from edx_ace.channel import ChannelType + # import pdb; pdb.set_trace() + should_email_send = _is_first_comment(comment_dict['id'], thread['id']) + assert should_email_send is False + + # self.mock_ace_send.assert_called_once_with(self.mock_message, [ChannelType.PUSH]) assert not self.mock_ace_send.called def test_subcomment_should_not_send_email(self): From 7628201b38ca898f986a5c08d92dc51b15dde10d Mon Sep 17 00:00:00 2001 From: Vladyslav Zherebkin Date: Tue, 14 Sep 2021 15:44:37 +0200 Subject: [PATCH 19/26] feat: [ICNC-597] Added downloading functionality for HTML (cherry picked from commit 15ef236cdc3eb05da120449b8831b0d77aebb5bf) --- .../features/_mobile_extensions/__init__.py | 0 openedx/features/_mobile_extensions/apps.py | 15 +++ .../_mobile_extensions/html_block/__init__.py | 0 .../html_block/mobile_api_module.py | 125 ++++++++++++++++++ .../features/_mobile_extensions/signals.py | 11 ++ openedx/features/_mobile_extensions/tasks.py | 12 ++ setup.py | 6 + xmodule/html_block.py | 3 +- 8 files changed, 171 insertions(+), 1 deletion(-) create mode 100644 openedx/features/_mobile_extensions/__init__.py create mode 100644 openedx/features/_mobile_extensions/apps.py create mode 100644 openedx/features/_mobile_extensions/html_block/__init__.py create mode 100644 openedx/features/_mobile_extensions/html_block/mobile_api_module.py create mode 100644 openedx/features/_mobile_extensions/signals.py create mode 100644 openedx/features/_mobile_extensions/tasks.py diff --git a/openedx/features/_mobile_extensions/__init__.py b/openedx/features/_mobile_extensions/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/_mobile_extensions/apps.py b/openedx/features/_mobile_extensions/apps.py new file mode 100644 index 000000000000..906039ab5c2c --- /dev/null +++ b/openedx/features/_mobile_extensions/apps.py @@ -0,0 +1,15 @@ +import logging + +from django.apps import AppConfig + + +log = logging.getLogger(__name__) + + +class MobileExtensionsConfig(AppConfig): + name = 'openedx.features._mobile_extensions' + verbose_name = 'Mobile Extensions' + + def ready(self): + # Import signals to activate signal handler + from . import signals # pylint: disable=unused-variable diff --git a/openedx/features/_mobile_extensions/html_block/__init__.py b/openedx/features/_mobile_extensions/html_block/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py new file mode 100644 index 000000000000..d59775c53a92 --- /dev/null +++ b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py @@ -0,0 +1,125 @@ +import re +import zipfile + +from bs4 import BeautifulSoup +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 + + +class HtmlBlockMobileApiMixin: + FILE_NAME = 'content_html.zip' + + def update_info_api(self): + if not self.is_modified(): + return + + base_path = self._base_storage_path() + self.remove_old_files(base_path) + + def replace_static_links(match): + link = match.group() + filename = link.split('/static/')[-1] + self.save_asset_file(link, filename) + return f'assets/{filename}' + + def replace_iframe(data): + soup = BeautifulSoup(data, 'html.parser') + 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) + return str(soup) + + pattern = re.compile(r'/static/[\w\+@\-_\.]+') + data = pattern.sub(replace_static_links, self.data) + data = replace_iframe(data) + + default_storage.save(f'{base_path}index.html', ContentFile(data)) + self.create_zip_file(base_path) + + def remove_old_files(self, 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(self): + return '{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}/'.format(loc=self.location) + + def save_asset_file(self, path, filename): + asset_key = StaticContent.get_asset_key_from_path(self.location.course_key, path) + try: + content = AssetManager.find(asset_key) + except (ItemNotFoundError, NotFoundError): + pass + else: + base_path = self._base_storage_path() + default_storage.save(f'{base_path}assets/{filename}', ContentFile(content.data)) + + def create_zip_file(self, base_path): + zf = zipfile.ZipFile(default_storage.path(base_path + self.FILE_NAME), "w") + zf.write(default_storage.path(base_path + "index.html"), "index.html") + + try: + directories, files = default_storage.listdir(base_path + 'assets/') + except OSError: + pass + else: + for file_name in files: + zf.write(default_storage.path(base_path + 'assets/' + file_name), 'assets/' + file_name) + + zf.close() + + def is_modified(self): + file_path = f'{self._base_storage_path()}{self.FILE_NAME}' + + try: + last_modified = default_storage.get_created_time(file_path) + except OSError: + return True + + return self.published_on > last_modified + + def student_view_data(self): + file_path = f'{self._base_storage_path()}{self.FILE_NAME}' + + try: + default_storage.get_created_time(file_path) + except OSError: + self.update_info_api() + + html_data = default_storage.url(file_path) + + if not html_data.startswith('http'): + html_data = f'{settings.LMS_ROOT_URL}{html_data}' + + last_modified = default_storage.get_created_time(file_path) + size = default_storage.size(file_path) + + return { + 'last_modified': last_modified, + 'html_data': html_data, + 'size': size, + 'index_page': 'index.html', + 'icon_class': self.icon_class, + } diff --git a/openedx/features/_mobile_extensions/signals.py b/openedx/features/_mobile_extensions/signals.py new file mode 100644 index 000000000000..f4d1d68f8a50 --- /dev/null +++ b/openedx/features/_mobile_extensions/signals.py @@ -0,0 +1,11 @@ +import six +from django.dispatch import receiver + +from xmodule.modulestore.django import SignalHandler + +from .tasks import update_html_block_mobile_api + + +@receiver(SignalHandler.course_published) +def listen_for_course_publish(sender, course_key, **kwargs): + update_html_block_mobile_api.delay(six.text_type(course_key)) diff --git a/openedx/features/_mobile_extensions/tasks.py b/openedx/features/_mobile_extensions/tasks.py new file mode 100644 index 000000000000..04c6d1d48647 --- /dev/null +++ b/openedx/features/_mobile_extensions/tasks.py @@ -0,0 +1,12 @@ +from celery.task import task +from opaque_keys.edx.keys import CourseKey + +from xmodule.modulestore.django import modulestore + + +@task +def update_html_block_mobile_api(course_id): + course_key = CourseKey.from_string(course_id) + + for xblock_html in modulestore().get_items(course_key, qualifiers={'category': 'html'}): + xblock_html.update_info_api() diff --git a/setup.py b/setup.py index 188072354fd2..830a2362af87 100644 --- a/setup.py +++ b/setup.py @@ -154,6 +154,9 @@ "program_enrollments = lms.djangoapps.program_enrollments.apps:ProgramEnrollmentsConfig", "courseware_api = openedx.core.djangoapps.courseware_api.apps:CoursewareAPIConfig", "course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig", + # [RG]: + "_mobile_extensions = openedx.features._mobile_extensions.apps:MobileExtensionsConfig", + # :[RG] ], "cms.djangoapp": [ "announcements = openedx.features.announcements.apps:AnnouncementsConfig", @@ -177,6 +180,9 @@ "user_authn = openedx.core.djangoapps.user_authn.apps:UserAuthnConfig", "instructor = lms.djangoapps.instructor.apps:InstructorConfig", "course_apps = openedx.core.djangoapps.course_apps.apps:CourseAppsConfig", + # [RG]: + "_mobile_extensions = openedx.features._mobile_extensions.apps:MobileExtensionsConfig", + # :[RG] ], 'openedx.learning_context': [ 'lib = openedx.core.djangoapps.content_libraries.library_context:LibraryContextImpl', diff --git a/xmodule/html_block.py b/xmodule/html_block.py index 2db198360107..fd886dfd7b67 100644 --- a/xmodule/html_block.py +++ b/xmodule/html_block.py @@ -11,6 +11,7 @@ from django.conf import settings from fs.errors import ResourceNotFound from lxml import etree +from openedx.features._mobile_extensions.html_block.mobile_api_module import HtmlBlockMobileApiMixin from path import Path as path from web_fragments.fragment import Fragment from xblock.core import XBlock @@ -353,7 +354,7 @@ def index_dictionary(self): @edxnotes -class HtmlBlock(HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method +class HtmlBlock(HtmlBlockMobileApiMixin, HtmlBlockMixin): # lint-amnesty, pylint: disable=abstract-method """ This is the actual HTML XBlock. Nothing extra is required; this is just a wrapper to include edxnotes support. From 8b8faf1c2a3405b3015d024a387d50ed91d64480 Mon Sep 17 00:00:00 2001 From: Vladyslav Zherebkin Date: Wed, 15 Sep 2021 13:23:04 +0300 Subject: [PATCH 20/26] feat: [ICNC-597] Implemented video downloading (cherry picked from commit 4a388b574c8a44e999514950aae02cbd96987686) --- xmodule/video_block/video_block.py | 6 +++--- xmodule/video_block/video_xfields.py | 14 +++++++++++++- 2 files changed, 16 insertions(+), 4 deletions(-) diff --git a/xmodule/video_block/video_block.py b/xmodule/video_block/video_block.py index b4fddb63fa7a..110c733736ae 100644 --- a/xmodule/video_block/video_block.py +++ b/xmodule/video_block/video_block.py @@ -1217,9 +1217,9 @@ def student_view_data(self, context=None): # Fall back to other video URLs in the video block if not found in VAL if not encoded_videos: if all_sources: - encoded_videos["fallback"] = { + encoded_videos["mobile_high"] = { "url": all_sources[0], - "file_size": 0, # File size is unknown for fallback URLs + "file_size": self.file_size, } # Include youtube link if there is no encoding for mobile- ie only a fallback URL or no encodings at all @@ -1238,7 +1238,7 @@ def student_view_data(self, context=None): return { "only_on_web": self.only_on_web, - "duration": val_video_data.get('duration', None), + "duration": int(val_video_data.get('duration', self.file_length.total_seconds())), "transcripts": transcripts, "encoded_videos": encoded_videos, "all_sources": all_sources, diff --git a/xmodule/video_block/video_xfields.py b/xmodule/video_block/video_xfields.py index 873ed01f285c..907eb4bea4b8 100644 --- a/xmodule/video_block/video_xfields.py +++ b/xmodule/video_block/video_xfields.py @@ -5,7 +5,7 @@ import datetime -from xblock.fields import Boolean, DateTime, Dict, Float, List, Scope, String +from xblock.fields import Boolean, DateTime, Dict, Float, List, Scope, String, Integer from xmodule.fields import RelativeTime @@ -93,6 +93,18 @@ class VideoFields: display_name=_("Video File URLs"), scope=Scope.settings, ) + file_size = Integer( + help=_("Add video size. This size appears in mobile app via file downloading"), + display_name=_("Video File Size"), + scope=Scope.settings, + default=0 + ) + file_length = RelativeTime( + help=_("Add video length. This length appears in mobile app via file downloading. Formatted as HH:MM:SS"), + display_name=_("Video File Length"), + scope=Scope.settings, + default=datetime.timedelta(seconds=0) + ) track = String( help=_("By default, students can download an .srt or .txt transcript when you set Download Transcript " "Allowed to True. If you want to provide a downloadable transcript in a different format, we recommend " From aa38b2ecf4b0dcab162f22c5311a788c953e40ca Mon Sep 17 00:00:00 2001 From: monteri Date: Wed, 15 May 2024 11:27:37 +0200 Subject: [PATCH 21/26] feat: playing with problem v2 --- lms/djangoapps/course_home_api/dates/views.py | 152 +++++++++++++++++ .../mobile_api/offline_mode/__init__.py | 3 + .../html_block/mobile_api_module.py | 158 ++++++++++++++---- openedx/features/_mobile_extensions/tasks.py | 11 +- xmodule/capa_block.py | 2 + 5 files changed, 290 insertions(+), 36 deletions(-) create mode 100644 lms/djangoapps/mobile_api/offline_mode/__init__.py diff --git a/lms/djangoapps/course_home_api/dates/views.py b/lms/djangoapps/course_home_api/dates/views.py index 6c95f82349d4..46b47a615f12 100644 --- a/lms/djangoapps/course_home_api/dates/views.py +++ b/lms/djangoapps/course_home_api/dates/views.py @@ -2,6 +2,7 @@ Dates Tab Views """ +from django.conf import settings from edx_django_utils import monitoring as monitoring_utils from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser @@ -21,8 +22,152 @@ from lms.djangoapps.courseware.masquerade import setup_masquerade from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser from openedx.features.content_type_gating.models import ContentTypeGatingConfig +from xmodule.modulestore.django import modulestore +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 + + 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) + + ancestor_sequence_block = enclosing_sequence_for_gating_checks(block) + if False: + context = {'specific_masquerade': is_masquerading_as_specific_student(request.user, course_key)} + if ancestor_sequence_block.descendants_are_gated(context): + return redirect( + reverse( + 'render_xblock', + kwargs={'usage_key_string': str(ancestor_sequence_block.location)} + ) + ) + + if False: + seq_block = ancestor_sequence_block if ancestor_sequence_block else block + if getattr(seq_block, 'is_time_limited', None): + if not _check_sequence_exam_access(request, seq_block.location): + return HttpResponseForbidden("Access to exam content is restricted") + + 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) + class DatesTabView(RetrieveAPIView): """ **Use Cases** @@ -75,6 +220,13 @@ class DatesTabView(RetrieveAPIView): def get(self, request, *args, **kwargs): course_key_string = kwargs.get('course_key_string') course_key = CourseKey.from_string(course_key_string) + # import pdb; pdb.set_trace() + for xblock in modulestore().get_items(course_key, qualifiers={'category': 'problem'}): + try: + response_xblock = xblock_view_handler(request, xblock) + xblock.update_info_api(response_xblock) + except: + pass # Enable NR tracing for this view based on course monitoring_utils.set_custom_attribute('course_id', course_key_string) diff --git a/lms/djangoapps/mobile_api/offline_mode/__init__.py b/lms/djangoapps/mobile_api/offline_mode/__init__.py new file mode 100644 index 000000000000..de13de8df799 --- /dev/null +++ b/lms/djangoapps/mobile_api/offline_mode/__init__.py @@ -0,0 +1,3 @@ +""" +Offline mode +""" diff --git a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py index d59775c53a92..189268508fef 100644 --- a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py +++ b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py @@ -1,4 +1,5 @@ import re +import os import zipfile from bs4 import BeautifulSoup @@ -12,40 +13,118 @@ from xmodule.modulestore.exceptions import ItemNotFoundError +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() + + class HtmlBlockMobileApiMixin: FILE_NAME = 'content_html.zip' - def update_info_api(self): + def update_info_api(self, html_data=None): + html_data = self.data if not html_data else html_data if not self.is_modified(): return - base_path = self._base_storage_path() self.remove_old_files(base_path) - def replace_static_links(match): - link = match.group() - filename = link.split('/static/')[-1] - self.save_asset_file(link, filename) - return f'assets/{filename}' - - def replace_iframe(data): - soup = BeautifulSoup(data, 'html.parser') - 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) - return str(soup) - - pattern = re.compile(r'/static/[\w\+@\-_\.]+') - data = pattern.sub(replace_static_links, self.data) - data = replace_iframe(data) + pattern = re.compile(r'/static/[\w./-]+') + data = pattern.sub(self._replace_static_links, html_data) + + # Parse the HTML with BeautifulSoup + soup = BeautifulSoup(data, 'html.parser') + # Replace iframes + self._replace_iframe(soup) + # Add JS bridge script to the HTML data + self._add_js_bridge(soup) + # Convert the modified soup back to a string + data = str(soup) default_storage.save(f'{base_path}index.html', ContentFile(data)) self.create_zip_file(base_path) + def _replace_static_links(self, match): + link = match.group() + filename = link.split('/static/')[-1] + self.save_asset_file(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): + import pdb; pdb.set_trace() + script_tag = soup.new_tag('script') + script_tag.string = """ + // Function to send messages to iOS + function sendMessageToiOS(message) { + window?.webkit?.messageHandlers?.iOSBridge?.postMessage(message); + } + + // Function to send messages to Android + function sendMessageToAndroid(message) { + window?.AndroidBridge?.postMessage(message); + } + + // Function to handle messages from iOS + function receiveMessageFromiOS(message) { + console.log("Message received from iOS:", message); + // Handle the message from iOS + } + + // Function to handle messages from Android + function receiveMessageFromAndroid(message) { + console.log("Message received from Android:", message); + // Handle the message from Android + } + + // Check if iOS bridge is available + if (window.webkit && window.webkit.messageHandlers && window.webkit.messageHandlers.iOSBridge) { + // iOS bridge is available + window.addEventListener("messageFromiOS", function(event) { + receiveMessageFromiOS(event.data); + }); + } + + // Check if Android bridge is available + if (window.AndroidBridge) { + // Android bridge is available + 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); + }; + """ + + # Insert the script tag before the closing tag + if soup.body: + soup.body.append(script_tag) + else: + # If there's no body tag, add it to the end of the document + soup.append(script_tag) + def remove_old_files(self, base_path): try: directories, files = default_storage.listdir(base_path) @@ -67,26 +146,40 @@ def _base_storage_path(self): return '{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}/'.format(loc=self.location) def save_asset_file(self, path, filename): - asset_key = StaticContent.get_asset_key_from_path(self.location.course_key, path) try: - content = AssetManager.find(asset_key) + 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(self.location.course_key, path) + content = AssetManager.find(asset_key).data except (ItemNotFoundError, NotFoundError): pass else: base_path = self._base_storage_path() - default_storage.save(f'{base_path}assets/{filename}', ContentFile(content.data)) + default_storage.save(f'{base_path}assets/{filename}', ContentFile(content)) def create_zip_file(self, base_path): zf = zipfile.ZipFile(default_storage.path(base_path + self.FILE_NAME), "w") zf.write(default_storage.path(base_path + "index.html"), "index.html") - try: - directories, files = default_storage.listdir(base_path + 'assets/') - except OSError: - pass - else: + 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 + + # Add files for file_name in files: - zf.write(default_storage.path(base_path + 'assets/' + file_name), 'assets/' + file_name) + full_path = os.path.join(current_base_path, file_name) + zip_file.write(full_path, os.path.join(current_path_in_zip, file_name)) + + # Recursively add directories + 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() @@ -106,7 +199,8 @@ def student_view_data(self): try: default_storage.get_created_time(file_path) except OSError: - self.update_info_api() + # self.update_info_api() + pass html_data = default_storage.url(file_path) diff --git a/openedx/features/_mobile_extensions/tasks.py b/openedx/features/_mobile_extensions/tasks.py index 04c6d1d48647..407b82c7da87 100644 --- a/openedx/features/_mobile_extensions/tasks.py +++ b/openedx/features/_mobile_extensions/tasks.py @@ -1,12 +1,15 @@ -from celery.task import task +# from celery.task import task from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.django import modulestore -@task +# @task def update_html_block_mobile_api(course_id): course_key = CourseKey.from_string(course_id) - for xblock_html in modulestore().get_items(course_key, qualifiers={'category': 'html'}): - xblock_html.update_info_api() + for xblock_html in modulestore().get_items(course_key, qualifiers={'category': 'problem'}): + try: + xblock_html.update_info_api() + except: + pass diff --git a/xmodule/capa_block.py b/xmodule/capa_block.py index e7d917fee601..151d664c7de2 100644 --- a/xmodule/capa_block.py +++ b/xmodule/capa_block.py @@ -51,6 +51,7 @@ ATTR_KEY_USER_ID, ) from openedx.core.djangolib.markup import HTML, Text +from openedx.features._mobile_extensions.html_block.mobile_api_module import HtmlBlockMobileApiMixin from .capa.xqueue_interface import XQueueService from .fields import Date, ListScoreField, ScoreField, Timedelta @@ -135,6 +136,7 @@ def from_json(self, value): @XBlock.needs('replace_urls') @XBlock.wants('call_to_action') class ProblemBlock( + HtmlBlockMobileApiMixin, ScorableXBlockMixin, RawMixin, XmlMixin, From 585cf54dcea5a5fabd7678b04e0b93e3e543b88d Mon Sep 17 00:00:00 2001 From: monteri Date: Thu, 16 May 2024 18:49:25 +0200 Subject: [PATCH 22/26] feat: move logic into mobile_api --- lms/djangoapps/mobile_api/apps.py | 7 + .../mobile_api/offline_mode/signals.py | 12 ++ .../mobile_api/offline_mode/tasks.py | 12 ++ .../mobile_api/offline_mode/tests/__init__.py | 0 .../tests/test_assets_management.py | 0 .../tests/test_html_manipulation.py | 0 .../offline_mode/tests/test_signals.py | 0 .../offline_mode/tests/test_tasks.py | 0 .../offline_mode/tests/test_xblock_helpers.py | 0 .../offline_mode/tests/test_zip_management.py | 0 .../mobile_api/offline_mode/utils/__init__.py | 0 .../offline_mode/utils/assets_management.py | 47 ++++++ .../offline_mode/utils/file_management.py | 12 ++ .../offline_mode/utils/html_manipulator.py | 91 ++++++++++ .../offline_mode/utils/xblock_helpers.py | 159 ++++++++++++++++++ .../offline_mode/utils/zip_management.py | 25 +++ .../html_block/mobile_api_module.py | 10 +- 17 files changed, 373 insertions(+), 2 deletions(-) create mode 100644 lms/djangoapps/mobile_api/offline_mode/signals.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/tasks.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/__init__.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_assets_management.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_html_manipulation.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_signals.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_tasks.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_xblock_helpers.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_zip_management.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/utils/__init__.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/utils/assets_management.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/utils/file_management.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/utils/html_manipulator.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py create mode 100644 lms/djangoapps/mobile_api/offline_mode/utils/zip_management.py diff --git a/lms/djangoapps/mobile_api/apps.py b/lms/djangoapps/mobile_api/apps.py index 2e7cb30990d3..c7416966c086 100644 --- a/lms/djangoapps/mobile_api/apps.py +++ b/lms/djangoapps/mobile_api/apps.py @@ -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 diff --git a/lms/djangoapps/mobile_api/offline_mode/signals.py b/lms/djangoapps/mobile_api/offline_mode/signals.py new file mode 100644 index 000000000000..573e5648132e --- /dev/null +++ b/lms/djangoapps/mobile_api/offline_mode/signals.py @@ -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)) diff --git a/lms/djangoapps/mobile_api/offline_mode/tasks.py b/lms/djangoapps/mobile_api/offline_mode/tasks.py new file mode 100644 index 000000000000..f4b08a5d2df4 --- /dev/null +++ b/lms/djangoapps/mobile_api/offline_mode/tasks.py @@ -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() diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/__init__.py b/lms/djangoapps/mobile_api/offline_mode/tests/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_assets_management.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_assets_management.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_html_manipulation.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_html_manipulation.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_signals.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_signals.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_tasks.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_tasks.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_xblock_helpers.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_xblock_helpers.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_zip_management.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_zip_management.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/__init__.py b/lms/djangoapps/mobile_api/offline_mode/utils/__init__.py new file mode 100644 index 000000000000..e69de29bb2d1 diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/assets_management.py b/lms/djangoapps/mobile_api/offline_mode/utils/assets_management.py new file mode 100644 index 000000000000..f8e28128a726 --- /dev/null +++ b/lms/djangoapps/mobile_api/offline_mode/utils/assets_management.py @@ -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) diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/file_management.py b/lms/djangoapps/mobile_api/offline_mode/utils/file_management.py new file mode 100644 index 000000000000..170889d96b9e --- /dev/null +++ b/lms/djangoapps/mobile_api/offline_mode/utils/file_management.py @@ -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() diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/html_manipulator.py b/lms/djangoapps/mobile_api/offline_mode/utils/html_manipulator.py new file mode 100644 index 000000000000..18d3abfb2c00 --- /dev/null +++ b/lms/djangoapps/mobile_api/offline_mode/utils/html_manipulator.py @@ -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/mathjax@2.7.5/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) diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py b/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py new file mode 100644 index 000000000000..d850944e584c --- /dev/null +++ b/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py @@ -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') diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/zip_management.py b/lms/djangoapps/mobile_api/offline_mode/utils/zip_management.py new file mode 100644 index 000000000000..14f9865b7fbb --- /dev/null +++ b/lms/djangoapps/mobile_api/offline_mode/utils/zip_management.py @@ -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() diff --git a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py index 189268508fef..4a73d7d5bca0 100644 --- a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py +++ b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py @@ -33,8 +33,12 @@ def update_info_api(self, html_data=None): base_path = self._base_storage_path() self.remove_old_files(base_path) + # Replace MathJax URL + mathjax_pattern = re.compile(r'src="https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js[^"]*"') + data = mathjax_pattern.sub(self._replace_mathjax_link, html_data) + pattern = re.compile(r'/static/[\w./-]+') - data = pattern.sub(self._replace_static_links, html_data) + data = pattern.sub(self._replace_static_links, data) # Parse the HTML with BeautifulSoup soup = BeautifulSoup(data, 'html.parser') @@ -54,6 +58,9 @@ def _replace_static_links(self, match): self.save_asset_file(link, filename) return f'assets/{filename}' + def _replace_mathjax_link(self, match): + return 'src="/static/mathjax/MathJax.js"' + def _replace_iframe(self, soup): for node in soup.find_all('iframe'): replacement = soup.new_tag('p') @@ -64,7 +71,6 @@ def _replace_iframe(self, soup): node.replace_with(replacement) def _add_js_bridge(self, soup): - import pdb; pdb.set_trace() script_tag = soup.new_tag('script') script_tag.string = """ // Function to send messages to iOS From e26949043abcff7de0d3f19ee2d826a307b48ea7 Mon Sep 17 00:00:00 2001 From: monteri Date: Mon, 20 May 2024 13:13:58 +0200 Subject: [PATCH 23/26] feat: last update --- lms/djangoapps/discussion/signals/handlers.py | 4 ++ .../mobile_api/offline_mode/signals.py | 14 +++++-- .../mobile_api/offline_mode/tasks.py | 8 ++-- .../offline_mode/utils/xblock_helpers.py | 41 +++++++++++-------- .../html_block/mobile_api_module.py | 2 - 5 files changed, 43 insertions(+), 26 deletions(-) diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index ead8128a0fe0..332517cc13b2 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -2,6 +2,7 @@ Signal handlers related to discussions. """ +import six import logging from django.conf import settings @@ -20,6 +21,7 @@ send_thread_created_notification, send_response_endorsed_notifications ) +from lms.djangoapps.mobile_api.offline_mode.tasks import generate_course_media from openedx.core.djangoapps.django_comment_common import signals from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.theming.helpers import get_current_site @@ -46,6 +48,8 @@ def update_discussions_on_course_publish(sender, course_key, **kwargs): # pylin args=[context], countdown=settings.DISCUSSION_SETTINGS['COURSE_PUBLISH_TASK_DELAY'], ) + # import pdb; pdb.set_trace() + generate_course_media(six.text_type(course_key)) @receiver(signals.comment_created) diff --git a/lms/djangoapps/mobile_api/offline_mode/signals.py b/lms/djangoapps/mobile_api/offline_mode/signals.py index 573e5648132e..53cf4679c5b8 100644 --- a/lms/djangoapps/mobile_api/offline_mode/signals.py +++ b/lms/djangoapps/mobile_api/offline_mode/signals.py @@ -1,12 +1,20 @@ import six from django.dispatch import receiver +from openedx_events.content_authoring.signals import ( + XBLOCK_CREATED, + XBLOCK_DELETED, + XBLOCK_DUPLICATED, + XBLOCK_UPDATED, + XBLOCK_PUBLISHED, +) from xmodule.modulestore.django import SignalHandler from .tasks import generate_course_media -@receiver(SignalHandler.course_published) -def hello_world(sender, course_key, **kwargs): +@receiver([XBLOCK_PUBLISHED]) +def hello_world(**kwargs): import pdb; pdb.set_trace() - generate_course_media.delay(six.text_type(course_key)) + pass + # generate_course_media.delay(six.text_type(course_key)) diff --git a/lms/djangoapps/mobile_api/offline_mode/tasks.py b/lms/djangoapps/mobile_api/offline_mode/tasks.py index f4b08a5d2df4..c7abdb62522b 100644 --- a/lms/djangoapps/mobile_api/offline_mode/tasks.py +++ b/lms/djangoapps/mobile_api/offline_mode/tasks.py @@ -2,11 +2,13 @@ from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.django import modulestore +from .utils.xblock_helpers import generate_offline_content, xblock_view_handler, generate_request_with_service_user @shared_task def generate_course_media(course_id): + request = generate_request_with_service_user() 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() + for xblock in modulestore().get_items(course_key, qualifiers={'category': 'problem'}): + html_data = xblock_view_handler(request, xblock) + generate_offline_content(xblock, html_data) diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py b/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py index d850944e584c..53f0008cbec8 100644 --- a/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py +++ b/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py @@ -1,13 +1,16 @@ from django.conf import settings +from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.core.files.storage import default_storage +from django.http import HttpRequest 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 +from .html_manipulator import HtmlManipulator +from .assets_management import save_asset_file, remove_old_files, base_storage_path +from .zip_management import create_zip_file +User = get_user_model() def is_modified(xblock): file_path = f'{base_storage_path(xblock)}content_html.zip' @@ -20,6 +23,12 @@ def is_modified(xblock): return xblock.published_on > last_modified +def generate_request_with_service_user(): + user = User.objects.get(email='edx@example.com') + request = HttpRequest() + request.user = user + return request + def enclosing_sequence_for_gating_checks(block): seq_tags = ['sequential'] if block.location.block_type in seq_tags: @@ -49,8 +58,9 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d ) 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.access import has_access 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.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 @@ -79,11 +89,6 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d 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, @@ -91,7 +96,7 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d str(usage_key), disable_staff_debug_info=disable_staff_debug_info, course=course, - will_recheck_access=recheck_access, + will_recheck_access=False, ) except: return None @@ -100,9 +105,9 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d 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) + # is_learning_mfe = is_request_from_learning_mfe(request) + student_view_context['hide_access_error_blocks'] = False + is_mobile_app = True student_view_context['is_mobile_app'] = is_mobile_app enable_completion_on_view_service = False @@ -116,7 +121,7 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d 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) + # optimization_flags = get_optimization_flags_for_content(block, fragment) context = { 'fragment': fragment, @@ -141,7 +146,7 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d 'is_mobile_app': is_mobile_app, 'render_course_wide_assets': True, - **optimization_flags, + # **optimization_flags, } return render_to_string('courseware/courseware-chromeless.html', context) @@ -152,8 +157,8 @@ def generate_offline_content(xblock, html_data): base_path = base_storage_path(xblock) remove_old_files(base_path) + html_manipulator = HtmlManipulator(xblock, html_data) + updated_html = html_manipulator.process_html() - 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)) + default_storage.save(f'{base_path}index.html', ContentFile(updated_html)) create_zip_file(base_path, 'content_html.zip') diff --git a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py index 4a73d7d5bca0..3adfd261ee00 100644 --- a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py +++ b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py @@ -114,9 +114,7 @@ def _add_js_bridge(self, soup): const originalAjax = $.ajax; $.ajax = function(options) { - sendMessageToiOS(options) sendMessageToiOS(JSON.stringify(options)) - sendMessageToAndroid(options) sendMessageToAndroid(JSON.stringify(options)) console.log(options, JSON.stringify(options)) From dbfe6a1ceff444cca40b3cf0fb489f134b41dbad Mon Sep 17 00:00:00 2001 From: monteri Date: Tue, 21 May 2024 15:39:07 +0200 Subject: [PATCH 24/26] feat: offline mode app --- .../mobile_api/offline_mode/__init__.py | 3 - .../mobile_api/offline_mode/signals.py | 20 --- .../tests/test_html_manipulation.py | 0 .../offline_mode/tests/test_signals.py | 0 .../offline_mode/tests/test_tasks.py | 0 .../tests => offline_mode}/__init__.py | 0 lms/djangoapps/offline_mode/handlers.py | 14 ++ .../{mobile_api => }/offline_mode/tasks.py | 9 +- .../utils => offline_mode/tests}/__init__.py | 0 .../tests/test_assets_management.py | 140 ++++++++++++++++++ .../tests/test_html_manipulation.py | 72 +++++++++ .../offline_mode/tests/test_signals.py | 33 +++++ .../offline_mode/tests/test_tasks.py | 71 +++++++++ .../offline_mode/tests/test_xblock_helpers.py | 0 .../offline_mode/tests/test_zip_management.py | 0 lms/djangoapps/offline_mode/toggles.py | 17 +++ .../utils/__init__.py} | 0 .../offline_mode/utils/assets_management.py | 29 ++++ .../offline_mode/utils/file_management.py | 6 + .../offline_mode/utils/html_manipulator.py | 0 .../offline_mode/utils/xblock_helpers.py | 27 ++-- .../offline_mode/utils/zip_management.py | 0 22 files changed, 400 insertions(+), 41 deletions(-) delete mode 100644 lms/djangoapps/mobile_api/offline_mode/__init__.py delete mode 100644 lms/djangoapps/mobile_api/offline_mode/signals.py delete mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_html_manipulation.py delete mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_signals.py delete mode 100644 lms/djangoapps/mobile_api/offline_mode/tests/test_tasks.py rename lms/djangoapps/{mobile_api/offline_mode/tests => offline_mode}/__init__.py (100%) create mode 100644 lms/djangoapps/offline_mode/handlers.py rename lms/djangoapps/{mobile_api => }/offline_mode/tasks.py (68%) rename lms/djangoapps/{mobile_api/offline_mode/utils => offline_mode/tests}/__init__.py (100%) create mode 100644 lms/djangoapps/offline_mode/tests/test_assets_management.py create mode 100644 lms/djangoapps/offline_mode/tests/test_html_manipulation.py create mode 100644 lms/djangoapps/offline_mode/tests/test_signals.py create mode 100644 lms/djangoapps/offline_mode/tests/test_tasks.py rename lms/djangoapps/{mobile_api => }/offline_mode/tests/test_xblock_helpers.py (100%) rename lms/djangoapps/{mobile_api => }/offline_mode/tests/test_zip_management.py (100%) create mode 100644 lms/djangoapps/offline_mode/toggles.py rename lms/djangoapps/{mobile_api/offline_mode/tests/test_assets_management.py => offline_mode/utils/__init__.py} (100%) rename lms/djangoapps/{mobile_api => }/offline_mode/utils/assets_management.py (61%) rename lms/djangoapps/{mobile_api => }/offline_mode/utils/file_management.py (61%) rename lms/djangoapps/{mobile_api => }/offline_mode/utils/html_manipulator.py (100%) rename lms/djangoapps/{mobile_api => }/offline_mode/utils/xblock_helpers.py (91%) rename lms/djangoapps/{mobile_api => }/offline_mode/utils/zip_management.py (100%) diff --git a/lms/djangoapps/mobile_api/offline_mode/__init__.py b/lms/djangoapps/mobile_api/offline_mode/__init__.py deleted file mode 100644 index de13de8df799..000000000000 --- a/lms/djangoapps/mobile_api/offline_mode/__init__.py +++ /dev/null @@ -1,3 +0,0 @@ -""" -Offline mode -""" diff --git a/lms/djangoapps/mobile_api/offline_mode/signals.py b/lms/djangoapps/mobile_api/offline_mode/signals.py deleted file mode 100644 index 53cf4679c5b8..000000000000 --- a/lms/djangoapps/mobile_api/offline_mode/signals.py +++ /dev/null @@ -1,20 +0,0 @@ -import six -from django.dispatch import receiver -from openedx_events.content_authoring.signals import ( - XBLOCK_CREATED, - XBLOCK_DELETED, - XBLOCK_DUPLICATED, - XBLOCK_UPDATED, - XBLOCK_PUBLISHED, -) - -from xmodule.modulestore.django import SignalHandler - -from .tasks import generate_course_media - - -@receiver([XBLOCK_PUBLISHED]) -def hello_world(**kwargs): - import pdb; pdb.set_trace() - pass - # generate_course_media.delay(six.text_type(course_key)) diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_html_manipulation.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_html_manipulation.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_signals.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_signals.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_tasks.py b/lms/djangoapps/mobile_api/offline_mode/tests/test_tasks.py deleted file mode 100644 index e69de29bb2d1..000000000000 diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/__init__.py b/lms/djangoapps/offline_mode/__init__.py similarity index 100% rename from lms/djangoapps/mobile_api/offline_mode/tests/__init__.py rename to lms/djangoapps/offline_mode/__init__.py diff --git a/lms/djangoapps/offline_mode/handlers.py b/lms/djangoapps/offline_mode/handlers.py new file mode 100644 index 000000000000..06eed7d28c75 --- /dev/null +++ b/lms/djangoapps/offline_mode/handlers.py @@ -0,0 +1,14 @@ +import six +from django.dispatch import receiver +from openedx_events.content_authoring.signals import XBLOCK_PUBLISHED + +from xmodule.modulestore.django import SignalHandler + +from .tasks import generate_course_media + + +@receiver([XBLOCK_PUBLISHED]) +def listen_course_publish(**kwargs): + if USER_TOURS_DISABLED.is_disabled(): + return + generate_course_media.delay(six.text_type(course_key)) diff --git a/lms/djangoapps/mobile_api/offline_mode/tasks.py b/lms/djangoapps/offline_mode/tasks.py similarity index 68% rename from lms/djangoapps/mobile_api/offline_mode/tasks.py rename to lms/djangoapps/offline_mode/tasks.py index c7abdb62522b..4fdf01d75660 100644 --- a/lms/djangoapps/mobile_api/offline_mode/tasks.py +++ b/lms/djangoapps/offline_mode/tasks.py @@ -2,7 +2,12 @@ from opaque_keys.edx.keys import CourseKey from xmodule.modulestore.django import modulestore -from .utils.xblock_helpers import generate_offline_content, xblock_view_handler, generate_request_with_service_user +from .utils.xblock_helpers import ( + generate_offline_content, + xblock_view_handler, + generate_request_with_service_user, + is_offline_supported, +) @shared_task @@ -10,5 +15,7 @@ def generate_course_media(course_id): request = generate_request_with_service_user() course_key = CourseKey.from_string(course_id) for xblock in modulestore().get_items(course_key, qualifiers={'category': 'problem'}): + if is_offline_supported(xblock): + continue html_data = xblock_view_handler(request, xblock) generate_offline_content(xblock, html_data) diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/__init__.py b/lms/djangoapps/offline_mode/tests/__init__.py similarity index 100% rename from lms/djangoapps/mobile_api/offline_mode/utils/__init__.py rename to lms/djangoapps/offline_mode/tests/__init__.py diff --git a/lms/djangoapps/offline_mode/tests/test_assets_management.py b/lms/djangoapps/offline_mode/tests/test_assets_management.py new file mode 100644 index 000000000000..b3450fbc1ada --- /dev/null +++ b/lms/djangoapps/offline_mode/tests/test_assets_management.py @@ -0,0 +1,140 @@ +import os + +from unittest import TestCase +from unittest.mock import patch, MagicMock +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 save_asset_file, remove_old_files, base_storage_path + +class TestFileManagement(TestCase): + + @patch('file_management.get_static_file_path') + @patch('file_management.read_static_file') + @patch('xmodule.contentstore.content.StaticContent.get_asset_key_from_path') + @patch('xmodule.assetstore.assetmgr.AssetManager.find') + @patch('django.core.files.storage.default_storage.save') + def test_save_asset_file_with_static_file( + self, + mock_save, + mock_find, + mock_get_asset_key, + mock_read_static_file, + mock_get_static_file_path + ): + xblock = MagicMock() + xblock.location.course_key = 'course-v1:edX+DemoX+Demo_Course' + path = 'path/to/asset' + filename = 'static/file/path.txt' + content = b'some content' + + mock_get_static_file_path.return_value = 'static/file/path.txt' + mock_read_static_file.return_value = content + + save_asset_file(xblock, path, filename) + + mock_get_static_file_path.assert_called_with(filename) + mock_read_static_file.assert_called_with('static/file/path.txt') + mock_save.assert_called_with( + f'{xblock.location.org}/{xblock.location.course}' + f'/{xblock.location.block_type}/{xblock.location.block_id}/assets/{filename}', + ContentFile(content) + ) + + @patch('xmodule.contentstore.content.StaticContent.get_asset_key_from_path') + @patch('xmodule.assetstore.assetmgr.AssetManager.find') + @patch('django.core.files.storage.default_storage.save') + def test_save_asset_file_with_asset_manager(self, mock_save, mock_find, mock_get_asset_key): + xblock = MagicMock() + xblock.location.course_key = 'course-v1:edX+DemoX+Demo_Course' + path = 'path/to/asset' + filename = 'asset.txt' + content = b'some content' + + mock_get_asset_key.return_value = 'asset_key' + mock_find.return_value.data = content + + save_asset_file(xblock, path, filename) + + mock_get_asset_key.assert_called_with(xblock.location.course_key, path) + mock_find.assert_called_with('asset_key') + mock_save.assert_called_with( + f'{xblock.location.org}/{xblock.location.course}' + f'/{xblock.location.block_type}/{xblock.location.block_id}/assets/{filename}', + ContentFile(content) + ) + + @patch('xmodule.contentstore.content.StaticContent.get_asset_key_from_path') + @patch('xmodule.assetstore.assetmgr.AssetManager.find') + @patch('file_management.get_static_file_path') + @patch('file_management.read_static_file') + @patch('django.core.files.storage.default_storage.save') + def test_save_asset_file_not_found_error( + self, + mock_save, + mock_read_static_file, + mock_get_static_file_path, + mock_find, + mock_get_asset_key + ): + xblock = MagicMock() + xblock.location.course_key = 'course-v1:edX+DemoX+Demo_Course' + path = 'path/to/asset' + filename = 'asset.txt' + + mock_get_asset_key.side_effect = ItemNotFoundError + mock_find.side_effect = NotFoundError + + save_asset_file(xblock, path, filename) + + mock_save.assert_not_called() + + @patch('django.core.files.storage.default_storage.listdir') + @patch('django.core.files.storage.default_storage.delete') + def test_remove_old_files(self, mock_delete, mock_listdir): + base_path = 'base/path/' + files = ['file1.txt', 'file2.txt'] + assets_files = ['asset1.txt', 'asset2.txt'] + + mock_listdir.side_effect = [ + ([], files), # for base_path + ([], assets_files), # for base_path + 'assets/' + ] + + remove_old_files(base_path) + + expected_delete_calls = [ + patch('django.core.files.storage.default_storage.delete').call(base_path + 'file1.txt'), + patch('django.core.files.storage.default_storage.delete').call(base_path + 'file2.txt'), + patch('django.core.files.storage.default_storage.delete').call(base_path + 'assets/' + 'asset1.txt'), + patch('django.core.files.storage.default_storage.delete').call(base_path + 'assets/' + 'asset2.txt'), + ] + + mock_delete.assert_has_calls(expected_delete_calls, any_order=True) + + @patch('django.core.files.storage.default_storage.listdir') + @patch('django.core.files.storage.default_storage.delete') + def test_remove_old_files_os_error(self, mock_delete, mock_listdir): + base_path = 'base/path/' + + mock_listdir.side_effect = OSError + + remove_old_files(base_path) + + mock_delete.assert_not_called() + + def test_base_storage_path(self): + xblock = MagicMock() + xblock.location.org = 'edX' + xblock.location.course = 'DemoX' + xblock.location.block_type = 'block' + xblock.location.block_id = 'block_id' + + expected_path = 'edX/DemoX/block/block_id/' + + self.assertEqual(base_storage_path(xblock), expected_path) diff --git a/lms/djangoapps/offline_mode/tests/test_html_manipulation.py b/lms/djangoapps/offline_mode/tests/test_html_manipulation.py new file mode 100644 index 000000000000..2ec8b692a26a --- /dev/null +++ b/lms/djangoapps/offline_mode/tests/test_html_manipulation.py @@ -0,0 +1,72 @@ +import unittest +from unittest.mock import Mock, patch +from bs4 import BeautifulSoup + +from .html_manipulator import HtmlManipulator + + +class HtmlManipulatorTest(unittest.TestCase): + + def setUp(self): + self.xblock = Mock() + self.html_data = ''' + + + + + + + + + + ''' + self.manipulator = HtmlManipulator(self.xblock, self.html_data) + + @patch('html_manipulator.save_asset_file') + def test_replace_mathjax_link(self, mock_save_asset_file): + updated_html = self.manipulator._replace_mathjax_link() + self.assertNotIn('https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js', updated_html) + self.assertIn('src="/static/mathjax/MathJax.js"', updated_html) + + @patch('html_manipulator.save_asset_file') + def test_replace_static_links(self, mock_save_asset_file): + updated_html = self.manipulator._replace_static_links() + self.assertIn('assets/img/sample.png', updated_html) + mock_save_asset_file.assert_called_with(self.xblock, '/static/img/sample.png', 'img/sample.png') + + def test_replace_iframe(self): + soup = BeautifulSoup(self.html_data, 'html.parser') + self.manipulator._replace_iframe(soup) + self.assertEqual(len(soup.find_all('iframe')), 0) + self.assertEqual(len(soup.find_all('a', href='https://example.com/video')), 1) + + def test_add_js_bridge(self): + soup = BeautifulSoup(self.html_data, 'html.parser') + self.manipulator._add_js_bridge(soup) + script_tag = soup.find('script', string=lambda text: 'sendMessageToiOS' in text if text else False) + self.assertIsNotNone(script_tag) + self.assertIn('sendMessageToAndroid', script_tag.string) + + @patch('html_manipulator.save_asset_file') + def test_process_html(self, mock_save_asset_file): + final_html = self.manipulator.process_html() + soup = BeautifulSoup(final_html, 'html.parser') + + # Check MathJax link replacement + mathjax_script = soup.find('script', src='/static/mathjax/MathJax.js') + self.assertIsNotNone(mathjax_script) + + # Check iframe replacement + iframes = soup.find_all('iframe') + self.assertEqual(len(iframes), 0) + anchors = soup.find_all('a', href='https://example.com/video') + self.assertEqual(len(anchors), 1) + + # Check static link replacement + img_tag = soup.find('img', src='assets/img/sample.png') + self.assertIsNotNone(img_tag) + mock_save_asset_file.assert_called_with(self.xblock, '/static/img/sample.png', 'img/sample.png') + + # Check JS bridge script + script_tag = soup.find('script', string=lambda text: 'sendMessageToiOS' in text if text else False) + self.assertIsNotNone(script_tag) diff --git a/lms/djangoapps/offline_mode/tests/test_signals.py b/lms/djangoapps/offline_mode/tests/test_signals.py new file mode 100644 index 000000000000..d2f39ebc9ef5 --- /dev/null +++ b/lms/djangoapps/offline_mode/tests/test_signals.py @@ -0,0 +1,33 @@ +import unittest +from unittest.mock import patch, Mock +from django.test import TestCase +from django.dispatch import Signal + +from lms.djangoapps.offline_mode.handlers import listen_course_publish +from lms.djangoapps.offline_mode.tasks import generate_course_media + +# Mocking the XBLOCK_PUBLISHED signal +XBLOCK_PUBLISHED = Signal(providing_args=["course_key"]) + +class ListenCoursePublishSignalTest(TestCase): + + def setUp(self): + self.course_key = 'course-v1:edX+DemoX+Demo_Course' + + @patch('myapp.signals.generate_course_media.delay') + @patch('myapp.signals.USER_TOURS_DISABLED.is_disabled', return_value=False) + def test_listen_course_publish_signal_handler(self, mock_is_disabled, mock_generate_course_media): + # Simulate sending the signal + XBLOCK_PUBLISHED.send(sender=None, course_key=self.course_key) + + # Check if the generate_course_media task was called with the correct arguments + mock_generate_course_media.assert_called_once_with(self.course_key) + + @patch('myapp.signals.generate_course_media.delay') + @patch('myapp.signals.USER_TOURS_DISABLED.is_disabled', return_value=True) + def test_listen_course_publish_signal_handler_disabled(self, mock_is_disabled, mock_generate_course_media): + # Simulate sending the signal + XBLOCK_PUBLISHED.send(sender=None, course_key=self.course_key) + + # Check that the generate_course_media task was not called since the feature is disabled + mock_generate_course_media.assert_not_called() diff --git a/lms/djangoapps/offline_mode/tests/test_tasks.py b/lms/djangoapps/offline_mode/tests/test_tasks.py new file mode 100644 index 000000000000..feb1c0ee5592 --- /dev/null +++ b/lms/djangoapps/offline_mode/tests/test_tasks.py @@ -0,0 +1,71 @@ +# lint-amnesty, pylint: disable=missing-module-docstring +from unittest import mock +from unittest.mock import patch + +import ddt +from django.test import TestCase +from celery import shared_task + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from opaque_keys.edx.keys import CourseKey + +from lms.djangoapps.offline_mode.tasks import generate_course_media +from lms.djangoapps.offline_mode.utils.xblock_helpers import ( + generate_offline_content, + xblock_view_handler, + generate_request_with_service_user, + is_offline_supported, +) + + +@ddt.ddt +class TestGenerateCourseMediaTask(ModuleStoreTestCase): # lint-amnesty, pylint: disable=missing-class-docstring + @patch('lms.djangoapps.offline_mode.tasks.generate_request_with_service_user') + @patch('lms.djangoapps.offline_mode.tasks.modulestore') + @patch('lms.djangoapps.offline_mode.tasks.xblock_view_handler') + @patch('lms.djangoapps.offline_mode.tasks.generate_offline_content') + @patch('lms.djangoapps.offline_mode.tasks.is_offline_supported') + def test_generate_course_media(self, mock_is_offline_supported, mock_generate_offline_content, mock_xblock_view_handler, mock_modulestore, mock_generate_request_with_service_user): + # Arrange + course_id = 'course-v1:edX+DemoX+Demo_Course' + course_key = CourseKey.from_string(course_id) + request = mock.Mock() + mock_generate_request_with_service_user.return_value = request + mock_xblock = mock.Mock(category='problem') + mock_modulestore().get_items.return_value = [mock_xblock] + mock_is_offline_supported.return_value = False + html_data = '
Sample HTML
' + mock_xblock_view_handler.return_value = html_data + + # Act + generate_course_media(course_id) + + # Assert + mock_generate_request_with_service_user.assert_called_once() + mock_modulestore().get_items.assert_called_once_with(course_key, qualifiers={'category': 'problem'}) + mock_is_offline_supported.assert_called_once_with(mock_xblock) + mock_xblock_view_handler.assert_called_once_with(request, mock_xblock) + mock_generate_offline_content.assert_called_once_with(mock_xblock, html_data) + + @patch('lms.djangoapps.offline_mode.tasks.generate_request_with_service_user') + @patch('lms.djangoapps.offline_mode.tasks.modulestore') + @patch('lms.djangoapps.offline_mode.tasks.is_offline_supported') + def test_generate_course_media_offline_supported(self, mock_is_offline_supported, mock_modulestore, mock_generate_request_with_service_user): + # Arrange + course_id = 'course-v1:edX+DemoX+Demo_Course' + course_key = CourseKey.from_string(course_id) + request = mock.Mock() + mock_generate_request_with_service_user.return_value = request + mock_xblock = mock.Mock(category='problem') + mock_modulestore().get_items.return_value = [mock_xblock] + mock_is_offline_supported.return_value = True + + # Act + generate_course_media(course_id) + + # Assert + mock_generate_request_with_service_user.assert_called_once() + mock_modulestore().get_items.assert_called_once_with(course_key, qualifiers={'category': 'problem'}) + mock_is_offline_supported.assert_called_once_with(mock_xblock) + self.assertFalse(mock_xblock_view_handler.called) + self.assertFalse(mock_generate_offline_content.called) diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_xblock_helpers.py b/lms/djangoapps/offline_mode/tests/test_xblock_helpers.py similarity index 100% rename from lms/djangoapps/mobile_api/offline_mode/tests/test_xblock_helpers.py rename to lms/djangoapps/offline_mode/tests/test_xblock_helpers.py diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_zip_management.py b/lms/djangoapps/offline_mode/tests/test_zip_management.py similarity index 100% rename from lms/djangoapps/mobile_api/offline_mode/tests/test_zip_management.py rename to lms/djangoapps/offline_mode/tests/test_zip_management.py diff --git a/lms/djangoapps/offline_mode/toggles.py b/lms/djangoapps/offline_mode/toggles.py new file mode 100644 index 000000000000..bbb09bead92c --- /dev/null +++ b/lms/djangoapps/offline_mode/toggles.py @@ -0,0 +1,17 @@ +""" +Toggles for the Offline Mode Experience. +""" + +from edx_toggles.toggles import WaffleFlag + +# .. toggle_name: offline_node.media_generation_enabled +# .. toggle_implementation: WaffleFlag +# .. toggle_default: False +# .. toggle_description: This flag enables media generation for offline mode. +# .. toggle_warnings: None +# .. toggle_use_cases: opt_out +# .. toggle_creation_date: 2024-05-20 +# .. toggle_target_removal_date: None +MEDIA_GENERATION_ENABLED = WaffleFlag( + 'offline_node.media_generation_enabled', module_name=__name__, log_prefix='offline_mode' +) diff --git a/lms/djangoapps/mobile_api/offline_mode/tests/test_assets_management.py b/lms/djangoapps/offline_mode/utils/__init__.py similarity index 100% rename from lms/djangoapps/mobile_api/offline_mode/tests/test_assets_management.py rename to lms/djangoapps/offline_mode/utils/__init__.py diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/assets_management.py b/lms/djangoapps/offline_mode/utils/assets_management.py similarity index 61% rename from lms/djangoapps/mobile_api/offline_mode/utils/assets_management.py rename to lms/djangoapps/offline_mode/utils/assets_management.py index f8e28128a726..7edb34ff4ff3 100644 --- a/lms/djangoapps/mobile_api/offline_mode/utils/assets_management.py +++ b/lms/djangoapps/offline_mode/utils/assets_management.py @@ -11,6 +11,17 @@ 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 that provides context for the file. + path (str): The path where the asset is located. + filename (str): The name of the file to be saved. + """ try: if '/' in filename: static_path = get_static_file_path(filename) @@ -26,6 +37,12 @@ def save_asset_file(xblock, path, filename): def remove_old_files(base_path): + """ + Removes old files from the specified base path and its 'assets/' subdirectory. + + Args: + base_path (str): The base path from which to delete files. + """ try: directories, files = default_storage.listdir(base_path) except OSError: @@ -44,4 +61,16 @@ def remove_old_files(base_path): def base_storage_path(xblock): + """ + 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. + + Returns: + str: The constructed base storage path. + """ return '{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}/'.format(loc=xblock.location) diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/file_management.py b/lms/djangoapps/offline_mode/utils/file_management.py similarity index 61% rename from lms/djangoapps/mobile_api/offline_mode/utils/file_management.py rename to lms/djangoapps/offline_mode/utils/file_management.py index 170889d96b9e..829fcf18211b 100644 --- a/lms/djangoapps/mobile_api/offline_mode/utils/file_management.py +++ b/lms/djangoapps/offline_mode/utils/file_management.py @@ -3,10 +3,16 @@ 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() diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/html_manipulator.py b/lms/djangoapps/offline_mode/utils/html_manipulator.py similarity index 100% rename from lms/djangoapps/mobile_api/offline_mode/utils/html_manipulator.py rename to lms/djangoapps/offline_mode/utils/html_manipulator.py diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py b/lms/djangoapps/offline_mode/utils/xblock_helpers.py similarity index 91% rename from lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py rename to lms/djangoapps/offline_mode/utils/xblock_helpers.py index 53f0008cbec8..e1c7ae26fe74 100644 --- a/lms/djangoapps/mobile_api/offline_mode/utils/xblock_helpers.py +++ b/lms/djangoapps/offline_mode/utils/xblock_helpers.py @@ -12,6 +12,13 @@ User = get_user_model() +OFFLINE_SUPPORTED_XBLOCKS = ['html', 'problem'] + + +def is_offline_supported(xblock): + return xblock.location.block_type in OFFLINE_SUPPORTED_XBLOCKS + + def is_modified(xblock): file_path = f'{base_storage_path(xblock)}content_html.zip' @@ -29,19 +36,6 @@ def generate_request_with_service_user(): request.user = user return request -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): """ @@ -60,7 +54,7 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d from openedx.features.course_experience.utils import dates_banner_should_display from lms.djangoapps.courseware.access import has_access 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.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 @@ -121,7 +115,7 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d 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) + optimization_flags = get_optimization_flags_for_content(block, fragment) context = { 'fragment': fragment, @@ -145,8 +139,7 @@ def xblock_view_handler(request, xblock, check_if_enrolled=True, disable_staff_d 'is_learning_mfe': is_learning_mfe, 'is_mobile_app': is_mobile_app, 'render_course_wide_assets': True, - - # **optimization_flags, + **optimization_flags, } return render_to_string('courseware/courseware-chromeless.html', context) diff --git a/lms/djangoapps/mobile_api/offline_mode/utils/zip_management.py b/lms/djangoapps/offline_mode/utils/zip_management.py similarity index 100% rename from lms/djangoapps/mobile_api/offline_mode/utils/zip_management.py rename to lms/djangoapps/offline_mode/utils/zip_management.py From 074c5bd9f0b6ac754eab1daa2a4cb303659ba331 Mon Sep 17 00:00:00 2001 From: monteri Date: Fri, 24 May 2024 16:45:59 +0200 Subject: [PATCH 25/26] feat: new offline mode state --- lms/djangoapps/discussion/signals/handlers.py | 10 +- lms/djangoapps/mobile_api/apps.py | 7 - lms/djangoapps/offline_mode/handlers.py | 11 +- .../offline_mode/tests/test_signals.py | 14 +- .../offline_mode/tests/test_zip_management.py | 45 +++++ lms/djangoapps/offline_mode/urls.py | 12 ++ .../offline_mode/utils/assets_management.py | 75 +++++-- .../offline_mode/utils/xblock_helpers.py | 186 +++++++++++++++++- lms/djangoapps/offline_mode/views.py | 36 ++++ lms/urls.py | 4 + 10 files changed, 360 insertions(+), 40 deletions(-) create mode 100644 lms/djangoapps/offline_mode/urls.py create mode 100644 lms/djangoapps/offline_mode/views.py diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 332517cc13b2..719e32b3ceaf 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -21,10 +21,10 @@ send_thread_created_notification, send_response_endorsed_notifications ) -from lms.djangoapps.mobile_api.offline_mode.tasks import generate_course_media from openedx.core.djangoapps.django_comment_common import signals from openedx.core.djangoapps.site_configuration.models import SiteConfiguration from openedx.core.djangoapps.theming.helpers import get_current_site +from lms.djangoapps.offline_mode.utils.xblock_helpers import get_xblock_view_response, generate_request_with_service_user log = logging.getLogger(__name__) @@ -48,8 +48,12 @@ def update_discussions_on_course_publish(sender, course_key, **kwargs): # pylin args=[context], countdown=settings.DISCUSSION_SETTINGS['COURSE_PUBLISH_TASK_DELAY'], ) - # import pdb; pdb.set_trace() - generate_course_media(six.text_type(course_key)) + + import pdb; + pdb.set_trace() + request = generate_request_with_service_user() + result = get_xblock_view_response(request, 'block-v1:new+123+new+type@problem+block@f7693d5dde094f65a28485582125936d', 'student_view') + print(result) @receiver(signals.comment_created) diff --git a/lms/djangoapps/mobile_api/apps.py b/lms/djangoapps/mobile_api/apps.py index c7416966c086..2e7cb30990d3 100644 --- a/lms/djangoapps/mobile_api/apps.py +++ b/lms/djangoapps/mobile_api/apps.py @@ -12,10 +12,3 @@ 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 diff --git a/lms/djangoapps/offline_mode/handlers.py b/lms/djangoapps/offline_mode/handlers.py index 06eed7d28c75..603cd88b0117 100644 --- a/lms/djangoapps/offline_mode/handlers.py +++ b/lms/djangoapps/offline_mode/handlers.py @@ -2,13 +2,18 @@ from django.dispatch import receiver from openedx_events.content_authoring.signals import XBLOCK_PUBLISHED + from xmodule.modulestore.django import SignalHandler from .tasks import generate_course_media +from .utils.assets_management import remove_old_files @receiver([XBLOCK_PUBLISHED]) -def listen_course_publish(**kwargs): - if USER_TOURS_DISABLED.is_disabled(): +def listen_xblock_publish(**kwargs): + if MEDIA_GENERATION_ENABLED.is_disabled(): return - generate_course_media.delay(six.text_type(course_key)) + usage_key = UsageKey.from_string(kwargs.get('usage_key_string')) + xblock = modulestore().get_item(usage_key) + remove_old_files(xblock) + diff --git a/lms/djangoapps/offline_mode/tests/test_signals.py b/lms/djangoapps/offline_mode/tests/test_signals.py index d2f39ebc9ef5..25ef1d5fcbad 100644 --- a/lms/djangoapps/offline_mode/tests/test_signals.py +++ b/lms/djangoapps/offline_mode/tests/test_signals.py @@ -9,25 +9,21 @@ # Mocking the XBLOCK_PUBLISHED signal XBLOCK_PUBLISHED = Signal(providing_args=["course_key"]) -class ListenCoursePublishSignalTest(TestCase): +class ListenXBlockPublishSignalTest(TestCase): def setUp(self): - self.course_key = 'course-v1:edX+DemoX+Demo_Course' + self.usage_key = '' @patch('myapp.signals.generate_course_media.delay') @patch('myapp.signals.USER_TOURS_DISABLED.is_disabled', return_value=False) def test_listen_course_publish_signal_handler(self, mock_is_disabled, mock_generate_course_media): - # Simulate sending the signal - XBLOCK_PUBLISHED.send(sender=None, course_key=self.course_key) + XBLOCK_PUBLISHED.send(sender=None, course_key=self.usage_key) - # Check if the generate_course_media task was called with the correct arguments - mock_generate_course_media.assert_called_once_with(self.course_key) + mock_generate_course_media.assert_called_once_with(self.usage_key) @patch('myapp.signals.generate_course_media.delay') @patch('myapp.signals.USER_TOURS_DISABLED.is_disabled', return_value=True) def test_listen_course_publish_signal_handler_disabled(self, mock_is_disabled, mock_generate_course_media): - # Simulate sending the signal - XBLOCK_PUBLISHED.send(sender=None, course_key=self.course_key) + XBLOCK_PUBLISHED.send(sender=None, course_key=self.usage_key) - # Check that the generate_course_media task was not called since the feature is disabled mock_generate_course_media.assert_not_called() diff --git a/lms/djangoapps/offline_mode/tests/test_zip_management.py b/lms/djangoapps/offline_mode/tests/test_zip_management.py index e69de29bb2d1..8768cda5dce9 100644 --- a/lms/djangoapps/offline_mode/tests/test_zip_management.py +++ b/lms/djangoapps/offline_mode/tests/test_zip_management.py @@ -0,0 +1,45 @@ +import unittest +from unittest.mock import Mock, patch, call +import zipfile + +from lms.djangoapps.offline_mode.utils.zip_management import create_zip_file + + +class CreateZipFileTest(unittest.TestCase): + + @patch('your_module.default_storage') + @patch('your_module.zipfile.ZipFile') + def test_create_zip_file(self, mock_zipfile, mock_default_storage): + # Setup mock paths + base_path = 'test_base_path/' + file_name = 'test_file.zip' + index_html_path = f'{base_path}index.html' + assets_path = f'{base_path}assets/' + asset_file_path = f'{assets_path}test_asset.txt' + + # Mock default_storage behavior + mock_default_storage.path.side_effect = lambda x: x + mock_default_storage.listdir.side_effect = [ + (['assets'], ['index.html']), # Root directory + ([], ['test_asset.txt']) # Assets directory + ] + + # Mock zipfile behavior + mock_zf_instance = Mock() + mock_zipfile.return_value = mock_zf_instance + + # Call the function to test + create_zip_file(base_path, file_name) + + # Assertions + mock_zipfile.assert_called_once_with(f'{base_path}{file_name}', 'w') + mock_zf_instance.write.assert_any_call(index_html_path, 'index.html') + mock_zf_instance.write.assert_any_call(asset_file_path, 'assets/test_asset.txt') + mock_zf_instance.close.assert_called_once() + + expected_calls = [ + call(path=f'{base_path}index.html'), + call(path=f'{assets_path}'), + ] + self.assertEqual(mock_default_storage.path.call_count, 2) + mock_default_storage.path.assert_has_calls(expected_calls, any_order=True) diff --git a/lms/djangoapps/offline_mode/urls.py b/lms/djangoapps/offline_mode/urls.py new file mode 100644 index 000000000000..bf1b513c8abb --- /dev/null +++ b/lms/djangoapps/offline_mode/urls.py @@ -0,0 +1,12 @@ +""" +URLs for mobile API +""" + + +from django.urls import include, path + +from .views import OfflineXBlockStatusInfoView + +urlpatterns = [ + path('xblocks_status_info/', OfflineXBlockStatusInfoView.as_view(), name='offline_xblocks_info'), +] diff --git a/lms/djangoapps/offline_mode/utils/assets_management.py b/lms/djangoapps/offline_mode/utils/assets_management.py index 7edb34ff4ff3..2950764bb6f2 100644 --- a/lms/djangoapps/offline_mode/utils/assets_management.py +++ b/lms/djangoapps/offline_mode/utils/assets_management.py @@ -1,4 +1,6 @@ +import shutil import os +import logging from django.core.files.base import ContentFile from django.core.files.storage import default_storage @@ -10,6 +12,9 @@ from .file_management import get_static_file_path, read_static_file +log = logging.getLogger(__name__) + + def save_asset_file(xblock, path, filename): """ Saves an asset file to the default storage. @@ -18,7 +23,7 @@ def save_asset_file(xblock, path, filename): Otherwise, it fetches the asset from the AssetManager. Args: - xblock (XBlock): The XBlock instance that provides context for the file. + xblock (XBlock): The XBlock instance path (str): The path where the asset is located. filename (str): The name of the file to be saved. """ @@ -36,28 +41,66 @@ def save_asset_file(xblock, path, filename): default_storage.save(f'{base_path}assets/{filename}', ContentFile(content)) -def remove_old_files(base_path): +def remove_old_files(xblock): """ - Removes old files from the specified base path and its 'assets/' subdirectory. + Removes the 'asset' directory, 'index.html', and 'offline_content.zip' files + in the specified base path directory. Args: - base_path (str): The base path from which to delete files. + (XBlock): The XBlock instance """ try: - directories, files = default_storage.listdir(base_path) - except OSError: - pass - else: - for file_name in files: - default_storage.delete(base_path + file_name) + base_path = base_storage_path(xblock) + + # Define the paths to the specific items to delete + asset_path = os.path.join(base_path, 'asset') + index_file_path = os.path.join(base_path, 'index.html') + offline_zip_path = os.path.join(base_path, 'offline_content.zip') + + # Delete the 'asset' directory if it exists + if os.path.isdir(asset_path): + shutil.rmtree(asset_path) + log.info(f"Successfully deleted the directory: {asset_path}") + + # Delete the 'index.html' file if it exists + if os.path.isfile(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 os.path.isfile(offline_zip_path): + os.remove(offline_zip_path) + log.info(f"Successfully deleted the file: {offline_zip_path}") + + except Exception as e: + log.error(f"Error occurred while deleting the files or directory: {e}") + + +def is_offline_content_present(xblock): + """ + Checks whether 'offline_content.zip' file is present in the specified base path directory. + + Args: + xblock (XBlock): The XBlock instance + Returns: + bool: True if the file is present, False otherwise + """ 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) + base_path = base_storage_path(xblock) + + # Define the path to the 'offline_content.zip' file + offline_zip_path = os.path.join(base_path, 'offline_content.zip') + + # Check if the file exists + if os.path.isfile(offline_zip_path): + return True + else: + return False + + except Exception as e: + log.error(f"Error occurred while checking the file: {e}") + return False def base_storage_path(xblock): diff --git a/lms/djangoapps/offline_mode/utils/xblock_helpers.py b/lms/djangoapps/offline_mode/utils/xblock_helpers.py index e1c7ae26fe74..5041a552c75e 100644 --- a/lms/djangoapps/offline_mode/utils/xblock_helpers.py +++ b/lms/djangoapps/offline_mode/utils/xblock_helpers.py @@ -1,7 +1,9 @@ +from django.urls import reverse, resolve from django.conf import settings from django.contrib.auth import get_user_model from django.core.files.base import ContentFile from django.core.files.storage import default_storage +from django.contrib.sessions.backends.db import SessionStore from django.http import HttpRequest from xmodule.modulestore.django import modulestore @@ -34,9 +36,189 @@ def generate_request_with_service_user(): user = User.objects.get(email='edx@example.com') request = HttpRequest() request.user = user + # Set up the session + session = SessionStore() + session.create() + request.session = session + return request +def cms_xblock_view_handler(usage_key_string, view_name): + # Generate the URL for the view + url = reverse('xblock_view_handler', kwargs={'usage_key_string': usage_key_string, 'view_name': view_name}) + + # Create a mock request object + request = generate_request_with_service_user() + request.method = 'GET' + request.META['HTTP_ACCEPT'] = 'application/json' + + # Resolve the URL to get the view function + view_func, args, kwargs = resolve(url) + + try: + # Call the view function with the request and resolved kwargs + response = view_func(request, *args, **kwargs) + except Exception as e: + return None + + return response + + +def get_xblock_view_response(request, usage_key_string, view_name): + from collections import OrderedDict + from functools import partial + + from django.utils.translation import gettext as _ + from web_fragments.fragment import Fragment + + from cms.lib.xblock.authoring_mixin import VISIBILITY_VIEW + from common.djangoapps.edxmako.shortcuts import render_to_string + from common.djangoapps.student.auth import ( + has_studio_read_access, + has_studio_write_access, + ) + from openedx.core.lib.xblock_utils import ( + hash_resource, + request_token, + wrap_xblock, + wrap_xblock_aside, + ) + from xmodule.modulestore.django import modulestore + from cms.djangoapps.contentstore.toggles import use_tagging_taxonomy_list_page + + from xmodule.x_module import ( + AUTHOR_VIEW, + PREVIEW_VIEWS, + STUDENT_VIEW, + STUDIO_VIEW, + ) + + from cms.djangoapps.contentstore.helpers import is_unit + from cms.djangoapps.contentstore.views.preview import get_preview_fragment + from cms.djangoapps.contentstore.xblock_storage_handlers.xblock_helpers import ( + usage_key_with_run, + get_children_tags_count, + ) + + usage_key = usage_key_with_run(usage_key_string) + if not has_studio_read_access(request.user, usage_key.course_key): + return None + + accept_header = request.META.get("HTTP_ACCEPT", "application/json") + + if "application/json" in accept_header: + store = modulestore() + xblock = store.get_item(usage_key) + container_views = [ + "container_preview", + "reorderable_container_child_preview", + "container_child_preview", + ] + + xblock.runtime.wrappers.append( + partial( + wrap_xblock, + "StudioRuntime", + usage_id_serializer=str, + request_token=request_token(request), + ) + ) + + xblock.runtime.wrappers_asides.append( + partial( + wrap_xblock_aside, + "StudioRuntime", + usage_id_serializer=str, + request_token=request_token(request), + extra_classes=["wrapper-comp-plugins"], + ) + ) + + if view_name in (STUDIO_VIEW, VISIBILITY_VIEW): + if view_name == STUDIO_VIEW: + load_services_for_studio(xblock.runtime, request.user) + + try: + fragment = xblock.render(view_name) + except Exception as exc: + log.debug( + "Unable to render %s for %r", view_name, xblock, exc_info=True + ) + fragment = Fragment( + render_to_string("html_error.html", {"message": str(exc)}) + ) + + elif view_name in PREVIEW_VIEWS + container_views: + is_pages_view = view_name == STUDENT_VIEW + can_edit = has_studio_write_access(request.user, usage_key.course_key) + + reorderable_items = set() + if view_name == "reorderable_container_child_preview": + reorderable_items.add(xblock.location) + + paging = None + try: + if request.GET.get("enable_paging", "false") == "true": + paging = { + "page_number": int(request.GET.get("page_number", 0)), + "page_size": int(request.GET.get("page_size", 0)), + } + except ValueError: + return None + + force_render = request.GET.get("force_render", None) + + tags_count_map = {} + if use_tagging_taxonomy_list_page(): + tags_count_map = get_children_tags_count(xblock) + + context = request.GET.dict() + context.update( + { + "is_pages_view": is_pages_view or view_name == AUTHOR_VIEW, + "is_unit_page": is_unit(xblock), + "can_edit": can_edit, + "root_xblock": xblock if (view_name == "container_preview") else None, + "reorderable_items": reorderable_items, + "paging": paging, + "force_render": force_render, + "item_url": "/container/{usage_key}", + "tags_count_map": tags_count_map, + } + ) + fragment = get_preview_fragment(request, xblock, context) + + display_label = xblock.display_name or xblock.scope_ids.block_type + if not xblock.display_name and xblock.scope_ids.block_type == "html": + display_label = _("Text") + if is_pages_view: + fragment.content = render_to_string( + "component.html", + { + "xblock_context": context, + "xblock": xblock, + "locator": usage_key, + "preview": fragment.content, + "label": display_label, + }, + ) + else: + return None + + hashed_resources = OrderedDict() + for resource in fragment.resources: + hashed_resources[hash_resource(resource)] = resource._asdict() + + fragment_content = fragment.content + if isinstance(fragment_content, bytes): + fragment_content = fragment.content.decode("utf-8") + + return {"html": fragment_content, "resources": list(hashed_resources.items())} + + 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. @@ -149,9 +331,9 @@ def generate_offline_content(xblock, html_data): return base_path = base_storage_path(xblock) - remove_old_files(base_path) + remove_old_files(xblock) html_manipulator = HtmlManipulator(xblock, html_data) updated_html = html_manipulator.process_html() default_storage.save(f'{base_path}index.html', ContentFile(updated_html)) - create_zip_file(base_path, 'content_html.zip') + create_zip_file(base_path, 'offline_content.zip') diff --git a/lms/djangoapps/offline_mode/views.py b/lms/djangoapps/offline_mode/views.py new file mode 100644 index 000000000000..9626fc4a332e --- /dev/null +++ b/lms/djangoapps/offline_mode/views.py @@ -0,0 +1,36 @@ +from rest_framework.views import APIView + +from .tasks import generate_course_media + + +class OfflineXBlockStatusInfoView(APIView): + + def get(self, request, course_id): + course_key = CourseKey.from_string(course_id) + response_data = [] + + for xblock in modulestore().get_items(course_key, qualifiers={'category': 'problem'}): + if not is_offline_supported(xblock): + continue + if not is_offline_content_present(xblock): + generate_course_media.delay(course_id) + return Response({'status': False, 'data': []}) + + base_path = base_storage_path(xblock) + offline_zip_path = os.path.join(base_path, 'offline_content.zip') + + html_data = default_storage.url(offline_zip_path) + if not html_data.startswith('http'): + html_data = f'{settings.LMS_ROOT_URL}{html_data}' + + last_modified = default_storage.get_created_time(offline_zip_path) + size = default_storage.size(offline_zip_path) + + response_data.append({ + 'link': html_data, + 'file_size': size, + 'last_modified': last_modified, + }) + + return Response({'status': True, 'data': response_data}) + diff --git a/lms/urls.py b/lms/urls.py index 15e374dd8551..43abf26f4a18 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -221,6 +221,10 @@ re_path(r'^api/mobile/(?Pv(4|3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), ] +urlpatterns += [ + re_path(r'^api/offline_mode/', include('lms.djangoapps.offline_mode.urls')), +] + urlpatterns += [ path('openassessment/fileupload/', include('openassessment.fileupload.urls')), ] From 81c0ec3f682ebc3311d28fda18660b21d126fa8f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=86=D0=B2=D0=B0=D0=BD=20=D0=9D=D1=94=D0=B4=D1=94=D0=BB?= =?UTF-8?q?=D1=8C=D0=BD=D1=96=D1=86=D0=B5=D0=B2?= Date: Thu, 30 May 2024 13:02:41 +0300 Subject: [PATCH 26/26] chore: add fixme's --- lms/djangoapps/discussion/signals/handlers.py | 1 + lms/djangoapps/discussion/tasks.py | 1 + .../offline_mode/utils/assets_management.py | 6 ++++-- .../offline_mode/utils/html_manipulator.py | 2 ++ .../offline_mode/utils/xblock_helpers.py | 6 +++--- lms/djangoapps/offline_mode/views.py | 15 ++++++++++++++- .../html_block/mobile_api_module.py | 4 +++- 7 files changed, 28 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/discussion/signals/handlers.py b/lms/djangoapps/discussion/signals/handlers.py index 719e32b3ceaf..4927bf7fe0a0 100644 --- a/lms/djangoapps/discussion/signals/handlers.py +++ b/lms/djangoapps/discussion/signals/handlers.py @@ -52,6 +52,7 @@ def update_discussions_on_course_publish(sender, course_key, **kwargs): # pylin import pdb; pdb.set_trace() request = generate_request_with_service_user() + # FIXME: Change the block id to the actual block id result = get_xblock_view_response(request, 'block-v1:new+123+new+type@problem+block@f7693d5dde094f65a28485582125936d', 'student_view') print(result) diff --git a/lms/djangoapps/discussion/tasks.py b/lms/djangoapps/discussion/tasks.py index 23af89888134..f3f14aaa117f 100644 --- a/lms/djangoapps/discussion/tasks.py +++ b/lms/djangoapps/discussion/tasks.py @@ -262,6 +262,7 @@ def _build_message_context(context, notification_type='forum_comment'): # lint- 'comment_username': comment_author.username, 'post_link': post_link, 'push_notification_extra_context': { + 'course_id': str(context['course_id']), 'notification_type': notification_type, 'topic_id': context.get('thread_commentable_id', ''), 'thread_id': context['thread_id'], diff --git a/lms/djangoapps/offline_mode/utils/assets_management.py b/lms/djangoapps/offline_mode/utils/assets_management.py index 2950764bb6f2..2d8bc57f904e 100644 --- a/lms/djangoapps/offline_mode/utils/assets_management.py +++ b/lms/djangoapps/offline_mode/utils/assets_management.py @@ -38,7 +38,7 @@ def save_asset_file(xblock, path, filename): pass else: base_path = base_storage_path(xblock) - default_storage.save(f'{base_path}assets/{filename}', ContentFile(content)) + default_storage.save(f'{base_path}assets/{filename}', ContentFile(content)) # FIXME: change to os.path.join def remove_old_files(xblock): @@ -55,6 +55,7 @@ def remove_old_files(xblock): # Define the paths to the specific items to delete asset_path = os.path.join(base_path, 'asset') index_file_path = os.path.join(base_path, 'index.html') + # FIXME: change filename to block_id or move to constants offline_zip_path = os.path.join(base_path, 'offline_content.zip') # Delete the 'asset' directory if it exists @@ -88,7 +89,7 @@ def is_offline_content_present(xblock): """ try: base_path = base_storage_path(xblock) - + # FIXME: change filename to block_id or move to constants # Define the path to the 'offline_content.zip' file offline_zip_path = os.path.join(base_path, 'offline_content.zip') @@ -116,4 +117,5 @@ def base_storage_path(xblock): Returns: str: The constructed base storage path. """ + # FIXME: change to os.path.join? return '{loc.org}/{loc.course}/{loc.block_type}/{loc.block_id}/'.format(loc=xblock.location) diff --git a/lms/djangoapps/offline_mode/utils/html_manipulator.py b/lms/djangoapps/offline_mode/utils/html_manipulator.py index 18d3abfb2c00..cac6c31b1083 100644 --- a/lms/djangoapps/offline_mode/utils/html_manipulator.py +++ b/lms/djangoapps/offline_mode/utils/html_manipulator.py @@ -10,6 +10,7 @@ def __init__(self, xblock, html_data): self.xblock = xblock def _replace_mathjax_link(self): + # FIXME: version shouldn't be hardcoded mathjax_pattern = re.compile(r'src="https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js[^"]*"') return mathjax_pattern.sub('src="/static/mathjax/MathJax.js"', self.html_data) @@ -34,6 +35,7 @@ def _replace_iframe(self, soup): def _add_js_bridge(self, soup): script_tag = soup.new_tag('script') + # FIXME: this script should be loaded from a file script_tag.string = """ // JS bridge script function sendMessageToiOS(message) { diff --git a/lms/djangoapps/offline_mode/utils/xblock_helpers.py b/lms/djangoapps/offline_mode/utils/xblock_helpers.py index 5041a552c75e..a4a69073a546 100644 --- a/lms/djangoapps/offline_mode/utils/xblock_helpers.py +++ b/lms/djangoapps/offline_mode/utils/xblock_helpers.py @@ -14,7 +14,7 @@ User = get_user_model() -OFFLINE_SUPPORTED_XBLOCKS = ['html', 'problem'] +OFFLINE_SUPPORTED_XBLOCKS = ['html', 'problem'] # FIXME: move this to settings def is_offline_supported(xblock): @@ -22,7 +22,7 @@ def is_offline_supported(xblock): def is_modified(xblock): - file_path = f'{base_storage_path(xblock)}content_html.zip' + file_path = f'{base_storage_path(xblock)}content_html.zip' # FIXME: change filename, and change to os.path.join try: last_modified = default_storage.get_created_time(file_path) @@ -33,7 +33,7 @@ def is_modified(xblock): def generate_request_with_service_user(): - user = User.objects.get(email='edx@example.com') + user = User.objects.get(email='edx@example.com') # FIXME: Change this to a valid user request = HttpRequest() request.user = user # Set up the session diff --git a/lms/djangoapps/offline_mode/views.py b/lms/djangoapps/offline_mode/views.py index 9626fc4a332e..068baad47e34 100644 --- a/lms/djangoapps/offline_mode/views.py +++ b/lms/djangoapps/offline_mode/views.py @@ -1,9 +1,22 @@ +import os +from django.conf import settings +from django.core.files.storage import default_storage + +from opaque_keys.edx.keys import CourseKey +from rest_framework.response import Response from rest_framework.views import APIView +from lms.djangoapps.offline_mode.utils.assets_management import is_offline_content_present, save_asset_file +from lms.djangoapps.offline_mode.utils.xblock_helpers import is_offline_supported +from xmodule.modulestore.django import modulestore + + +from .file_management import save_asset_file, remove_old_files, base_storage_path from .tasks import generate_course_media class OfflineXBlockStatusInfoView(APIView): + # FIXME: Add docstring def get(self, request, course_id): course_key = CourseKey.from_string(course_id) @@ -21,7 +34,7 @@ def get(self, request, course_id): html_data = default_storage.url(offline_zip_path) if not html_data.startswith('http'): - html_data = f'{settings.LMS_ROOT_URL}{html_data}' + html_data = f'{settings.LMS_ROOT_URL}{html_data}' # FIXME: use os.path.join last_modified = default_storage.get_created_time(offline_zip_path) size = default_storage.size(offline_zip_path) diff --git a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py index 3adfd261ee00..491948de532a 100644 --- a/openedx/features/_mobile_extensions/html_block/mobile_api_module.py +++ b/openedx/features/_mobile_extensions/html_block/mobile_api_module.py @@ -34,6 +34,7 @@ def update_info_api(self, html_data=None): self.remove_old_files(base_path) # Replace MathJax URL + # FIXME: version shouldn't be hardcoded mathjax_pattern = re.compile(r'src="https://cdn.jsdelivr.net/npm/mathjax@2.7.5/MathJax.js[^"]*"') data = mathjax_pattern.sub(self._replace_mathjax_link, html_data) @@ -72,6 +73,7 @@ def _replace_iframe(self, soup): def _add_js_bridge(self, soup): script_tag = soup.new_tag('script') + # FIXME: this script should be loaded from a file script_tag.string = """ // Function to send messages to iOS function sendMessageToiOS(message) { @@ -209,7 +211,7 @@ def student_view_data(self): html_data = default_storage.url(file_path) if not html_data.startswith('http'): - html_data = f'{settings.LMS_ROOT_URL}{html_data}' + html_data = f'{settings.LMS_ROOT_URL}{html_data}' # FIXME: use os.path.join last_modified = default_storage.get_created_time(file_path) size = default_storage.size(file_path)