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..3691988eebf8 --- /dev/null +++ b/lms/djangoapps/mobile_api/course_info/serializers.py @@ -0,0 +1,22 @@ +""" +Serializer for course_info API +""" + + +from common.djangoapps.student.models import CourseEnrollment +from lms.djangoapps.course_api.serializers import CourseDetailSerializer + + +class CourseInfoDetailSerializer(CourseDetailSerializer): + """ + Serializer for Course objects providing additional details about the + course. + + This serializer returns more data - 'is_enrolled' user's status. + """ + def to_representation(self, instance): + response = super().to_representation(instance) + + if self.context['request'].user.is_authenticated: + response['is_enrolled'] = CourseEnrollment.is_enrolled(self.context['request'].user, instance.id) + return response diff --git a/lms/djangoapps/mobile_api/course_info/tests.py b/lms/djangoapps/mobile_api/course_info/tests.py index 086359cafb8f..b1fff28164b3 100644 --- a/lms/djangoapps/mobile_api/course_info/tests.py +++ b/lms/djangoapps/mobile_api/course_info/tests.py @@ -9,13 +9,18 @@ from edx_toggles.toggles.testutils import override_waffle_flag from milestones.tests.utils import MilestonesTestCaseMixin from mock import patch +from rest_framework.request import Request from rest_framework.test import APIClient # pylint: disable=unused-import from common.djangoapps.student.models import CourseEnrollment # pylint: disable=unused-import 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.tests.test_views import CourseDetailViewTestCase +from lms.djangoapps.course_api.tests.test_serializers import TestCourseDetailSerializer from openedx.features.course_experience import ENABLE_COURSE_GOALS +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from .serializers import CourseInfoDetailSerializer from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore import ModuleStoreEnum # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order @@ -255,3 +260,59 @@ 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)) ) + + +class CourseInfoDetailViewTestCase(CourseDetailViewTestCase): # lint-amnesty, pylint: disable=test-inherits-tests + """ + Test responses returned from CourseInfoDetailView. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.url = reverse('course-info-detail', kwargs={ + 'course_key_string': cls.course.id, + 'api_version': 'v3' + }) + cls.hidden_url = reverse('course-info-detail', kwargs={ + 'course_key_string': cls.hidden_course.id, + 'api_version': 'v3' + }) + cls.nonexistent_url = reverse('course-info-detail', kwargs={ + 'course_key_string': 'edX/nope/Fall_2014', + 'api_version': 'v3' + }) + + +class TestCourseInfoDetailSerializer(TestCourseDetailSerializer): # lint-amnesty, pylint: disable=test-inherits-tests + """ + Test CourseInfoDetailSerializer by rerunning all the tests + in TestCourseDetailSerializer, but with the + CourseInfoDetailSerializer serializer class. + """ + + serializer_class = CourseInfoDetailSerializer + + def setUp(self): + super().setUp() + # by default, we do not have enrolled users + self.expected_data['is_enrolled'] = False + + @patch('lms.djangoapps.mobile_api.course_info.serializers.CourseEnrollment.is_enrolled', return_value=True) + def test_is_enrolled_field_true(self, mock_is_enrolled): + course = self.create_course() + result = self._get_result(course) + assert result['is_enrolled'] is True + mock_is_enrolled.assert_called_once() + + def test_is_enrolled_field_anonymous_user(self): + course = self.create_course() + result = self._get_anonymous_result(course) + self.assertNotIn('is_enrolled', result) + + def _get_anonymous_request(self): + return Request(self.request_factory.get('/')) + + def _get_anonymous_result(self, course): + course_overview = CourseOverview.get_from_id(course.id) + return self.serializer_class(course_overview, context={'request': self._get_anonymous_request()}).data diff --git a/lms/djangoapps/mobile_api/course_info/urls.py b/lms/djangoapps/mobile_api/course_info/urls.py index 5314b43bc5be..d0c79c9971c5 100644 --- a/lms/djangoapps/mobile_api/course_info/urls.py +++ b/lms/djangoapps/mobile_api/course_info/urls.py @@ -5,8 +5,13 @@ from django.conf import settings from django.urls import path, re_path +from .views import ( + CourseHandoutsList, + CourseUpdatesList, + CourseGoalsRecordUserActivity, + CourseInfoDetailView, +) -from .views import CourseHandoutsList, CourseUpdatesList, CourseGoalsRecordUserActivity urlpatterns = [ re_path( @@ -19,5 +24,10 @@ CourseUpdatesList.as_view(), name='course-updates-list' ), + re_path( + fr'^{settings.COURSE_KEY_PATTERN}/info$', + CourseInfoDetailView.as_view(), + name="course-info-detail" + ), path('record_user_activity', CourseGoalsRecordUserActivity.as_view(), name='record_user_activity'), ] diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index 5c29dc1b5d58..06ddb099f3c7 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -14,8 +14,11 @@ from common.djangoapps.static_replace import make_static_urls_absolute from lms.djangoapps.courseware.courses import get_course_info_section_block from lms.djangoapps.course_goals.models import UserActivity +from lms.djangoapps.course_api.views import CourseDetailView +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 .serializers import CourseInfoDetailSerializer from ..decorators import mobile_course_access, mobile_view User = get_user_model() @@ -166,3 +169,102 @@ 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 CourseInfoDetailView(CourseDetailView): + """ + **Use Cases** + + Request details for a course + + **Example Requests** + + GET /api/mobile/v3/course_info/{course_key}/info + + **Response Values** + + Body consists of the following fields: + + * effort: A textual description of the weekly hours of effort expected + in the course. + * end: Date the course ends, in ISO 8601 notation + * enrollment_end: Date enrollment ends, in ISO 8601 notation + * enrollment_start: Date enrollment begins, in ISO 8601 notation + * id: A unique identifier of the course; a serialized representation + of the opaque key identifying the course. + * media: 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 + * name: Name of the course + * number: Catalog number of the course + * org: Name of the organization that owns the course + * overview: A possibly verbose HTML textual description of the course. + Note: this field is only included in the Course Detail view, not + the Course List view. + * short_description: A textual description of the course + * start: Date the course begins, in ISO 8601 notation + * start_display: Readably formatted start of the course + * start_type: 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 + * pacing: Course pacing. Possible values: instructor, self + * certificate_available_date (optional): Date the certificate will be available, + in ISO 8601 notation if the `certificates.auto_certificate_generation` + waffle switch is enabled + * is_enrolled: (bool) Optional field. This field is not available for an anonymous user. + Indicates if the user is enrolled in the course + + Deprecated fields: + + * blocks_url: Used to fetch the course blocks + * course_id: Course key (use 'id' instead) + + **Parameters:** + + username (optional): + The username of the specified user for whom the course data + is being accessed. The username is not only required if the API is + requested by an Anonymous user. + + **Returns** + + * 200 on success with above fields. + * 400 if an invalid parameter was sent or the username was not provided + for an authenticated request. + * 401 unauthorized + * 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. + + Example response: + + { + "blocks_url": "/api/courses/v1/blocks/?course_id=edX%2Fexample%2F2012_Fall", + "media": { + "course_image": { + "uri": "/c4x/edX/example/asset/just_a_test.jpg", + "name": "Course Image" + } + }, + "description": "An example course.", + "end": "2015-09-19T18:00:00Z", + "enrollment_end": "2015-07-15T00:00:00Z", + "enrollment_start": "2015-06-15T00:00:00Z", + "course_id": "edX/example/2012_Fall", + "name": "Example Course", + "number": "example", + "org": "edX", + "overview: "

A verbose description of the course.

" + "start": "2015-07-17T12:00:00Z", + "start_display": "July 17, 2015", + "start_type": "timestamp", + "pacing": "instructor", + "certificate_available_date": "2015-08-14T00:00:00Z", + "is_enrolled": true + } + """ + + serializer_class = CourseInfoDetailSerializer diff --git a/lms/djangoapps/mobile_api/utils.py b/lms/djangoapps/mobile_api/utils.py index 5dcdf9e04c62..73a0cfea0827 100644 --- a/lms/djangoapps/mobile_api/utils.py +++ b/lms/djangoapps/mobile_api/utils.py @@ -5,6 +5,7 @@ API_V05 = 'v0.5' API_V1 = 'v1' API_V2 = 'v2' +API_V3 = 'v3' def parsed_version(version): diff --git a/lms/urls.py b/lms/urls.py index dc673054df9b..dfc10d1a479b 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -224,7 +224,7 @@ if settings.FEATURES.get('ENABLE_MOBILE_REST_API'): urlpatterns += [ - re_path(r'^api/mobile/(?Pv(2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), + re_path(r'^api/mobile/(?Pv(3|2|1|0.5))/', include('lms.djangoapps.mobile_api.urls')), ] if settings.FEATURES.get('ENABLE_OPENBADGES'):