From c4fe8d30e3c3131ae9803998bc8bf9778178a4ed Mon Sep 17 00:00:00 2001 From: KyryloKireiev Date: Fri, 15 Sep 2023 15:20:18 +0300 Subject: [PATCH 1/4] feat: [AXIM-26] Extended BlocksInCourseView API --- .../mobile_api/course_info/tests.py | 47 +++++++ lms/djangoapps/mobile_api/course_info/urls.py | 3 +- .../mobile_api/course_info/views.py | 120 ++++++++++++++++++ 3 files changed, 169 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/mobile_api/course_info/tests.py b/lms/djangoapps/mobile_api/course_info/tests.py index 086359cafb8f..823588624289 100644 --- a/lms/djangoapps/mobile_api/course_info/tests.py +++ b/lms/djangoapps/mobile_api/course_info/tests.py @@ -15,6 +15,7 @@ from common.djangoapps.student.tests.factories import UserFactory # pylint: disable=unused-import from lms.djangoapps.mobile_api.testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin from lms.djangoapps.mobile_api.utils import API_V1, API_V05 +from lms.djangoapps.course_api.blocks.tests.test_views import TestBlocksInCourseView from openedx.features.course_experience import ENABLE_COURSE_GOALS from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order @@ -255,3 +256,49 @@ def test_flag_disabled(self, mock_logger): 'For this mobile request, user activity is not enabled for this user {} and course {}'.format( str(self.user.id), str(self.course.id)) ) + + +@ddt.ddt +class TestBlocksInfoInCourseView(TestBlocksInCourseView): # lint-amnesty, pylint: disable=test-inherits-tests + """ + Test class for BlocksInfoInCourseView + """ + + def setUp(self): + super().setUp() + self.url = reverse('blocks_info_in_course', kwargs={ + 'api_version': 'v3', + }) + + @patch('lms.djangoapps.mobile_api.course_info.views.certificate_downloadable_status') + def test_additional_info_response(self, mock_certificate_downloadable_status): + certificate_url = 'https://test_certificate_url' + mock_certificate_downloadable_status.return_value = { + 'is_downloadable': True, + 'download_url': certificate_url, + } + + expected_image_urls = { + 'image': + { + 'large': '/asset-v1:edX+toy+2012_Fall+type@asset+block@just_a_test.jpg', + 'raw': '/asset-v1:edX+toy+2012_Fall+type@asset+block@just_a_test.jpg', + 'small': '/asset-v1:edX+toy+2012_Fall+type@asset+block@just_a_test.jpg' + } + } + + response = self.verify_response(url=self.url) + + assert response.status_code == 200 + assert response.data['id'] == str(self.course.id) + assert response.data['name'] == self.course.display_name + assert response.data['number'] == self.course.display_number_with_default + assert response.data['org'] == self.course.display_org_with_default + assert response.data['start'] == self.course.start + assert response.data['start_display'] == 'July 17, 2015' + assert response.data['start_type'] == 'timestamp' + assert response.data['end'] == self.course.end + assert response.data['media'] == expected_image_urls + assert response.data['certificate'] == {'url': certificate_url} + assert response.data['is_self_paced'] is False + mock_certificate_downloadable_status.assert_called_once() diff --git a/lms/djangoapps/mobile_api/course_info/urls.py b/lms/djangoapps/mobile_api/course_info/urls.py index 5314b43bc5be..e369930e2550 100644 --- a/lms/djangoapps/mobile_api/course_info/urls.py +++ b/lms/djangoapps/mobile_api/course_info/urls.py @@ -6,7 +6,7 @@ from django.conf import settings from django.urls import path, re_path -from .views import CourseHandoutsList, CourseUpdatesList, CourseGoalsRecordUserActivity +from .views import CourseHandoutsList, CourseUpdatesList, CourseGoalsRecordUserActivity, BlocksInfoInCourseView urlpatterns = [ re_path( @@ -20,4 +20,5 @@ name='course-updates-list' ), path('record_user_activity', CourseGoalsRecordUserActivity.as_view(), name='record_user_activity'), + path('blocks/', BlocksInfoInCourseView.as_view(), name="blocks_info_in_course"), ] diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 5c29dc1b5d58..33aa7ab98c2c 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -12,8 +12,12 @@ from rest_framework.views import APIView from common.djangoapps.static_replace import make_static_urls_absolute +from lms.djangoapps.certificates.api import certificate_downloadable_status 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 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 from openedx.features.course_experience import ENABLE_COURSE_GOALS from ..decorators import mobile_course_access, mobile_view @@ -166,3 +170,119 @@ def post(self, request, *args, **kwargs): # Populate user activity for tracking progress towards a user's course goals UserActivity.record_user_activity(user, course_key) return Response(status=(200)) + + +@view_auth_classes(is_authenticated=False) +class BlocksInfoInCourseView(BlocksInCourseView): + """ + **Use Case** + + Returns the blocks in the course according to the requesting user's access level. + Add to response info fields with information about course + + **Example requests**: + + This api works with all versions {api_version}, you can use: v0.5, v1, v2 or v3 + + GET /api/mobile/{api_version}/course_info/blocks/?course_id= + GET /api/mobile/{api_version}/course_info/blocks/?course_id= + &username=anjali + &depth=all + &requested_fields=graded,format,student_view_multi_device,lti_url + &block_counts=video + &student_view_data=video + &block_types_filter=problem,html + + **Response example** + + Body consists of the following fields: + + root: (str) The ID of the root node of the requested course block structure.\ + blocks: (dict) A dictionary or list, based on the value of the + "return_type" parameter. Maps block usage IDs to a collection of + information about each block. Each block contains the following + fields. + + id: (str) The Course's id (Course Run key) + name: (str) The course's name + number: (str) The course's number + org: (str) The course's organisation + start: (str) Date the course begins, in ISO 8601 notation + start_display: (str) Readably formatted start of the course + start_type: (str) Hint describing how `start_display` is set. One of: + * `"string"`: manually set by the course author + * `"timestamp"`: generated from the `start` timestamp + * `"empty"`: no start date is specified + end: (str) Date the course ends, in ISO 8601 notation + media: (dict) An object that contains named media items. Included here: + * course_image: An image to show for the course. Represented + as an object with the following fields: + * uri: The location of the image + certificate: (dict) Information about the user's earned certificate in the course. + Included here: + * uri: The location of the user's certificate + is_self_paced: (bool) Indicates if the course is self paced + + **Returns** + + * 200 on success with above fields. + * 400 if an invalid parameter was sent or the username was not provided + * 401 unauthorized, the provided access token has expired and is no longer valid + for an authenticated request. + * 403 if a user who does not have permission to masquerade as + another user specifies a username other than their own. + * 404 if the course is not available or cannot be seen. + """ + + def get_certificate(self, request, course_id): + """Returns the information about the user's certificate in the course.""" + if request.user.is_authenticated: + certificate_info = certificate_downloadable_status(request.user, course_id) + if certificate_info['is_downloadable']: + return { + 'url': request.build_absolute_uri( + certificate_info['download_url'] + ), + } + return {} + + # pylint: disable=arguments-differ + def list(self, request, **kwargs): + """ + REST API endpoint for listing all the blocks information in the course and + information about the course while regarding user access and roles. + + Arguments: + request - Django request object + """ + + response = super().list(request, kwargs) + + if request.GET.get('return_type', 'dict') == 'dict': + course_id = request.query_params.get('course_id', None) + course_key = CourseKey.from_string(course_id) + course_overview = CourseOverview.get_from_id(course_key) + + course_data = { + # identifiers + 'id': course_id, + 'name': course_overview.display_name, + 'number': course_overview.display_number_with_default, + 'org': course_overview.display_org_with_default, + + # dates + 'start': course_overview.start, + 'start_display': course_overview.start_display, + 'start_type': course_overview.start_type, + 'end': course_overview.end, + + # various URLs + 'media': { + 'image': course_overview.image_urls, + }, + 'certificate': self.get_certificate(request, course_key), + 'is_self_paced': course_overview.self_paced + } + + response.data.update(course_data) + return response From 81c992990ad007963e66dc433a3918d1a005d761 Mon Sep 17 00:00:00 2001 From: KyryloKireiev Date: Mon, 30 Oct 2023 16:50:25 +0200 Subject: [PATCH 2/4] fix: [FC-0031] Add parameters description, refactor list method --- .../mobile_api/course_info/views.py | 67 +++++++++++++------ 1 file changed, 48 insertions(+), 19 deletions(-) diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 33aa7ab98c2c..9cc27e6e38e1 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -193,6 +193,23 @@ class BlocksInfoInCourseView(BlocksInCourseView): &student_view_data=video &block_types_filter=problem,html + **Parameters:** + + username (str): The username of the specified user for whom the course data + is being accessed. + depth (integer, str, None): Optional number of blocks you receive in response + course nesting depth, you can get only sections, sections and subsections, + or provide string 'all' to receive all blocks of the course. + requested_field (list): Optional list of names of additional fields to return for each block. + Supported fields can be found in transformers.SUPPORTED_FIELDS. + block_counts (list): Optional list of names of block types for which an aggregated count + of blocks is returned. + student_view_data (list): Optional list of names of block types for + which student_view_data is returned. + block_types_filter (list): Filter by block types: + 'video', 'discussion', 'html', 'chapter', 'sequential', 'vertical'. + return_type (list, dict): Optional list or dictionary of block's fields based on 'return_type'. + **Response example** Body consists of the following fields: @@ -246,8 +263,36 @@ def get_certificate(self, request, course_id): } return {} - # pylint: disable=arguments-differ - def list(self, request, **kwargs): + @staticmethod + def compose_course_info(course_overview): + """ + Method for obtaining additional information about the course. + + Arguments: + request - Django request object + """ + + course_data = { + # identifiers + 'name': course_overview.display_name, + 'number': course_overview.display_number_with_default, + 'org': course_overview.display_org_with_default, + + # dates + 'start': course_overview.start, + 'start_display': course_overview.start_display, + 'start_type': course_overview.start_type, + 'end': course_overview.end, + + # various URLs + 'media': { + 'image': course_overview.image_urls, + }, + 'is_self_paced': course_overview.self_paced + } + return course_data + + def list(self, request, **kwargs): # pylint: disable=W0221 """ REST API endpoint for listing all the blocks information in the course and information about the course while regarding user access and roles. @@ -262,27 +307,11 @@ def list(self, request, **kwargs): course_id = request.query_params.get('course_id', None) course_key = CourseKey.from_string(course_id) course_overview = CourseOverview.get_from_id(course_key) - course_data = { - # identifiers 'id': course_id, - 'name': course_overview.display_name, - 'number': course_overview.display_number_with_default, - 'org': course_overview.display_org_with_default, - - # dates - 'start': course_overview.start, - 'start_display': course_overview.start_display, - 'start_type': course_overview.start_type, - 'end': course_overview.end, - - # various URLs - 'media': { - 'image': course_overview.image_urls, - }, 'certificate': self.get_certificate(request, course_key), - 'is_self_paced': course_overview.self_paced } + course_data.update(BlocksInfoInCourseView.compose_course_info(course_overview)) response.data.update(course_data) return response From 003f6d9bb4d4867571473349cbc7d2af9a533b11 Mon Sep 17 00:00:00 2001 From: KyryloKireiev Date: Fri, 15 Dec 2023 14:10:58 +0200 Subject: [PATCH 3/4] refactor: [FC-0031] Use serializer instead of custom function --- .../mobile_api/course_info/serializers.py | 36 +++++++++++ .../mobile_api/course_info/tests.py | 2 +- .../mobile_api/course_info/views.py | 63 +++++++++---------- 3 files changed, 67 insertions(+), 34 deletions(-) create mode 100644 lms/djangoapps/mobile_api/course_info/serializers.py diff --git a/lms/djangoapps/mobile_api/course_info/serializers.py b/lms/djangoapps/mobile_api/course_info/serializers.py new file mode 100644 index 000000000000..c996d24945b2 --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/serializers.py @@ -0,0 +1,36 @@ +""" +Course Info serializers +""" +from rest_framework import serializers + +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview + + +class CourseInfoOverviewSerializer(serializers.ModelSerializer): + """ + Serializer for serialize additional fields in BlocksInfoInCourseView. + """ + + name = serializers.CharField(source='display_name') + number = serializers.CharField(source='display_number_with_default') + org = serializers.CharField(source='display_org_with_default') + is_self_paced = serializers.BooleanField(source='self_paced') + media = serializers.SerializerMethodField() + + class Meta: + model = CourseOverview + fields = ( + 'name', + 'number', + 'org', + 'start', + 'start_display', + 'start_type', + 'end', + 'is_self_paced', + 'media', + ) + + @staticmethod + def get_media(obj): + return {'image': obj.image_urls} diff --git a/lms/djangoapps/mobile_api/course_info/tests.py b/lms/djangoapps/mobile_api/course_info/tests.py index 823588624289..e7552382cb3a 100644 --- a/lms/djangoapps/mobile_api/course_info/tests.py +++ b/lms/djangoapps/mobile_api/course_info/tests.py @@ -294,7 +294,7 @@ def test_additional_info_response(self, mock_certificate_downloadable_status): assert response.data['name'] == self.course.display_name assert response.data['number'] == self.course.display_number_with_default assert response.data['org'] == self.course.display_org_with_default - assert response.data['start'] == self.course.start + assert response.data['start'] == self.course.start.strftime('%Y-%m-%dT%H:%M:%SZ') assert response.data['start_display'] == 'July 17, 2015' assert response.data['start_type'] == 'timestamp' assert response.data['end'] == self.course.end diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 9cc27e6e38e1..6c5a325c0de6 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -16,6 +16,7 @@ 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.serializers import CourseInfoOverviewSerializer 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 @@ -212,7 +213,8 @@ class BlocksInfoInCourseView(BlocksInCourseView): **Response example** - Body consists of the following fields: + Body consists of the following fields, you received this response if you use + 'return_type=dict' in query params: root: (str) The ID of the root node of the requested course block structure.\ blocks: (dict) A dictionary or list, based on the value of the @@ -240,6 +242,22 @@ class BlocksInfoInCourseView(BlocksInCourseView): * uri: The location of the user's certificate is_self_paced: (bool) Indicates if the course is self paced + Body consists of the following fields, you received this response if you use + 'return_type=list' in query params: + + id: (str) The Course's id (Course Run key) + block_id: (str) The unique identifier for the block_id + lms_web_url: (str) The URL to the navigational container of the xBlock on the web. + legacy_web_url: (str) Like `lms_web_url`, but always directs to + the "Legacy" frontend experience. + student_view_url: (str) The URL to retrieve the HTML rendering + of this block's student view + type: (str): The type of block. Possible values the names of any + XBlock type in the system, including custom blocks. Examples are + course, chapter, sequential, vertical, html, problem, video, and + discussion. + display_name: (str) The display name of the block. + **Returns** * 200 on success with above fields. @@ -252,7 +270,16 @@ class BlocksInfoInCourseView(BlocksInCourseView): """ def get_certificate(self, request, course_id): - """Returns the information about the user's certificate in the course.""" + """ + Returns the information about the user's certificate in the course. + + Arguments: + request (Request): The request object. + course_id (str): The identifier of the course. + Returns: + (dict): A dict containing information about location of the user's certificate + or an empty dictionary, if there is no certificate. + """ if request.user.is_authenticated: certificate_info = certificate_downloadable_status(request.user, course_id) if certificate_info['is_downloadable']: @@ -263,35 +290,6 @@ def get_certificate(self, request, course_id): } return {} - @staticmethod - def compose_course_info(course_overview): - """ - Method for obtaining additional information about the course. - - Arguments: - request - Django request object - """ - - course_data = { - # identifiers - 'name': course_overview.display_name, - 'number': course_overview.display_number_with_default, - 'org': course_overview.display_org_with_default, - - # dates - 'start': course_overview.start, - 'start_display': course_overview.start_display, - 'start_type': course_overview.start_type, - 'end': course_overview.end, - - # various URLs - 'media': { - 'image': course_overview.image_urls, - }, - 'is_self_paced': course_overview.self_paced - } - return course_data - def list(self, request, **kwargs): # pylint: disable=W0221 """ REST API endpoint for listing all the blocks information in the course and @@ -311,7 +309,6 @@ def list(self, request, **kwargs): # pylint: disable=W0221 'id': course_id, 'certificate': self.get_certificate(request, course_key), } - - course_data.update(BlocksInfoInCourseView.compose_course_info(course_overview)) + course_data.update(CourseInfoOverviewSerializer(course_overview).data) response.data.update(course_data) return response From cae409157957eae3943e1a161c9b736e67c65427 Mon Sep 17 00:00:00 2001 From: Glib Glugovskiy Date: Tue, 19 Dec 2023 19:14:29 +0200 Subject: [PATCH 4/4] docs: [FC-0031] Update docstring --- lms/djangoapps/mobile_api/course_info/views.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 6c5a325c0de6..d97d1e8691dc 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -178,8 +178,10 @@ class BlocksInfoInCourseView(BlocksInCourseView): """ **Use Case** - Returns the blocks in the course according to the requesting user's access level. - Add to response info fields with information about course + This API endpoint is specifically optimized for the course homepage on Mobile Apps. + The endpoint returns the blocks in the course according to the requesting user's access level. + Additionally, response encompasses info fields with information about the course, + including certificate URL, media dictionary with course image URLs, start and end dates for the course. **Example requests**: