diff --git a/lms/djangoapps/mobile_api/course_info/serializers.py b/lms/djangoapps/mobile_api/course_info/serializers.py index c996d24945b2..b2bb0ce24701 100644 --- a/lms/djangoapps/mobile_api/course_info/serializers.py +++ b/lms/djangoapps/mobile_api/course_info/serializers.py @@ -2,13 +2,25 @@ Course Info serializers """ from rest_framework import serializers +from typing import Union +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.models import CourseEnrollment +from common.djangoapps.util.course import get_encoded_course_sharing_utm_params, get_link_for_about_page +from common.djangoapps.util.milestones_helpers import ( + get_pre_requisite_courses_not_completed, +) +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.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 class CourseInfoOverviewSerializer(serializers.ModelSerializer): """ - Serializer for serialize additional fields in BlocksInfoInCourseView. + Serializer for additional course fields that should be returned in BlocksInfoInCourseView. """ name = serializers.CharField(source='display_name') @@ -16,6 +28,9 @@ class CourseInfoOverviewSerializer(serializers.ModelSerializer): org = serializers.CharField(source='display_org_with_default') is_self_paced = serializers.BooleanField(source='self_paced') media = serializers.SerializerMethodField() + course_sharing_utm_parameters = serializers.SerializerMethodField() + course_about = serializers.SerializerMethodField('get_course_about_url') + course_modes = serializers.SerializerMethodField() class Meta: model = CourseOverview @@ -29,8 +44,86 @@ class Meta: 'end', 'is_self_paced', 'media', + 'course_sharing_utm_parameters', + 'course_about', + 'course_modes', ) @staticmethod def get_media(obj): + """ + Return course images in the correct format. + """ return {'image': obj.image_urls} + + def get_course_sharing_utm_parameters(self, obj): + return get_encoded_course_sharing_utm_params() + + def get_course_about_url(self, course_overview): + return get_link_for_about_page(course_overview) + + def get_course_modes(self, course_overview): + """ + Retrieve course modes associated with the course. + """ + course_modes = CourseMode.modes_for_course( + course_overview.id, + only_selectable=False + ) + return [ + ModeSerializer(mode).data + for mode in course_modes + ] + + +class MobileCourseEnrollmentSerializer(serializers.ModelSerializer): + """ + Serializer for the CourseEnrollment object used in the BlocksInfoInCourseView. + """ + + class Meta: + fields = ('created', 'mode', 'is_active') + model = CourseEnrollment + lookup_field = 'username' + + +class CourseAccessSerializer(serializers.Serializer): + """ + Get info whether a user should be able to view course material. + """ + + has_unmet_prerequisites = serializers.SerializerMethodField(method_name='get_has_unmet_prerequisites') + is_too_early = serializers.SerializerMethodField(method_name='get_is_too_early') + is_staff = serializers.SerializerMethodField(method_name='get_is_staff') + audit_access_expires = serializers.SerializerMethodField() + courseware_access = serializers.SerializerMethodField() + + def get_has_unmet_prerequisites(self, data: dict) -> bool: + """ + Check whether or not a course has unmet prerequisites. + """ + return any(get_pre_requisite_courses_not_completed(data.get('user'), [data.get('course_id')])) + + def get_is_too_early(self, data: dict) -> bool: + """ + Determine if the course is open to a learner (course has started or user has early beta access). + """ + return not check_course_open_for_learner(data.get('user'), data.get('course')) + + def get_is_staff(self, data: dict) -> bool: + """ + Determine whether a user has staff access to this course. + """ + return any(administrative_accesses_to_course_for_user(data.get('user'), data.get('course_id'))) + + def get_audit_access_expires(self, data: dict) -> Union[str, None]: + """ + Returns expiration date for a course audit expiration, if any or null + """ + return get_user_course_expiration_date(data.get('user'), data.get('course')) + + def get_courseware_access(self, data: dict) -> dict: + """ + Determine if the learner has access to the course, otherwise show error message. + """ + return has_access(data.get('user'), 'load_mobile', data.get('course')).to_json() diff --git a/lms/djangoapps/mobile_api/course_info/views.py b/lms/djangoapps/mobile_api/course_info/views.py index d97d1e8691dc..bd34336cc824 100644 --- a/lms/djangoapps/mobile_api/course_info/views.py +++ b/lms/djangoapps/mobile_api/course_info/views.py @@ -3,20 +3,28 @@ """ import logging +from typing import Optional, Union +import django from django.contrib.auth import get_user_model from opaque_keys import InvalidKeyError from opaque_keys.edx.keys import CourseKey from rest_framework import generics, status from rest_framework.response import Response +from rest_framework.reverse import reverse from rest_framework.views import APIView +from common.djangoapps.student.models import CourseEnrollment, User as StudentUser 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 lms.djangoapps.mobile_api.course_info.serializers import CourseInfoOverviewSerializer +from lms.djangoapps.mobile_api.course_info.serializers import ( + CourseInfoOverviewSerializer, + CourseAccessSerializer, + MobileCourseEnrollmentSerializer +) 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 @@ -26,6 +34,8 @@ User = get_user_model() log = logging.getLogger(__name__) +UserType = Union[django.contrib.auth.models.User, django.contrib.auth.models.AnonymousUser, StudentUser] + @mobile_view() class CourseUpdatesList(generics.ListAPIView): @@ -271,31 +281,52 @@ class BlocksInfoInCourseView(BlocksInCourseView): * 404 if the course is not available or cannot be seen. """ - def get_certificate(self, request, course_id): + def get_requested_user(self, user: UserType, username: Optional[str] = None) -> Union[UserType, None]: + """ + Return a user for whom the course blocks are fetched. + + Arguments: + user: current user from request. + username: string with username. + Returns: A user object or None. + """ + if user.is_anonymous: + return None + + if not username or (username and user.username == username): + return user + if username and (user.is_staff or user.is_superuser): + try: + return User.objects.get(username=username) + except User.DoesNotExist: + log.warning('Provided username does not correspond to an existing user %s', username) + return None + + def get_certificate(self, request, user, course_id): """ - Returns the information about the user's certificate in the course. + Return the information about the user's certificate in the course. Arguments: request (Request): The request object. + user (User): The user 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']: - return { - 'url': request.build_absolute_uri( - certificate_info['download_url'] - ), - } + certificate_info = certificate_downloadable_status(user, course_id) + if certificate_info['is_downloadable']: + return { + 'url': request.build_absolute_uri( + certificate_info['download_url'] + ), + } return {} 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. + information about the course considering user access and roles. Arguments: request - Django request object @@ -304,13 +335,48 @@ def list(self, request, **kwargs): # pylint: disable=W0221 response = super().list(request, kwargs) if request.GET.get('return_type', 'dict') == 'dict': + api_version = self.kwargs.get('api_version') course_id = request.query_params.get('course_id', None) course_key = CourseKey.from_string(course_id) course_overview = CourseOverview.get_from_id(course_key) + requested_username = request.query_params.get('username', None) + course_data = { 'id': course_id, - 'certificate': self.get_certificate(request, course_key), + 'course_updates': reverse( + 'course-updates-list', + kwargs={'api_version': api_version, 'course_id': course_id}, + request=request, + ), + 'course_handouts': reverse( + 'course-handouts-list', + kwargs={'api_version': api_version, 'course_id': course_id}, + request=request, + ), } - course_data.update(CourseInfoOverviewSerializer(course_overview).data) + + course_info_context = {} + if requested_user := self.get_requested_user(request.user, requested_username): + course_info_context = { + 'user': requested_user + } + user_enrollment = CourseEnrollment.get_enrollment(user=requested_user, course_key=course_key) + course_data.update({ + 'discussion_url': reverse( + 'discussion_course', + kwargs={'course_id': course_id}, + request=request, + ) if course_overview.is_discussion_tab_enabled(requested_user) else None, + 'course_access_details': CourseAccessSerializer({ + 'user': requested_user, + 'course': course_overview, + 'course_id': course_key + }).data, + 'certificate': self.get_certificate(request, requested_user, course_key), + 'enrollment_details': MobileCourseEnrollmentSerializer(user_enrollment).data, + }) + + course_data.update(CourseInfoOverviewSerializer(course_overview, context=course_info_context).data) + response.data.update(course_data) return response diff --git a/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py b/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py new file mode 100644 index 000000000000..6c50f68d6811 --- /dev/null +++ b/lms/djangoapps/mobile_api/tests/test_course_info_serializers.py @@ -0,0 +1,171 @@ +""" +Tests for serializers for the Mobile Course Info +""" + +import ddt +from django.test import TestCase +from mock import MagicMock, Mock, patch +from typing import Dict, List, Tuple, Union + +from common.djangoapps.student.tests.factories import UserFactory +from lms.djangoapps.mobile_api.course_info.serializers import ( + CourseAccessSerializer, + CourseInfoOverviewSerializer, +) +from openedx.core.djangoapps.content.course_overviews.tests.factories import CourseOverviewFactory + + +@ddt.ddt +class TestCourseAccessSerializer(TestCase): + """ + Tests for the CourseAccessSerializer. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.course = CourseOverviewFactory() + + @ddt.data( + ([{'course_id': {}}], True), + ([], False), + ) + @ddt.unpack + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_pre_requisite_courses_not_completed') + def test_has_unmet_prerequisites( + self, + mock_return_value: List[Dict], + has_unmet_prerequisites: bool, + mock_get_prerequisites: MagicMock, + ) -> None: + mock_get_prerequisites.return_value = mock_return_value + + output_data = CourseAccessSerializer({ + 'user': self.user, + 'course': self.course, + 'course_id': self.course.id, + }).data + + self.assertEqual(output_data['has_unmet_prerequisites'], has_unmet_prerequisites) + mock_get_prerequisites.assert_called_once_with(self.user, [self.course.id]) + + @ddt.data( + (True, False), + (False, True), + ) + @ddt.unpack + @patch('lms.djangoapps.mobile_api.course_info.serializers.check_course_open_for_learner') + def test_is_too_early( + self, + mock_return_value: bool, + is_too_early: bool, + mock_check_course_open: MagicMock, + ) -> None: + mock_check_course_open.return_value = mock_return_value + + output_data = CourseAccessSerializer({ + 'user': self.user, + 'course': self.course, + 'course_id': self.course.id + }).data + + self.assertEqual(output_data['is_too_early'], is_too_early) + mock_check_course_open.assert_called_once_with(self.user, self.course) + + @ddt.data( + ((False, False, False), False), + ((True, True, True), True), + ((True, False, False), True), + ) + @ddt.unpack + @patch('lms.djangoapps.mobile_api.course_info.serializers.administrative_accesses_to_course_for_user') + def test_is_staff( + self, + mock_return_value: Tuple[bool], + is_staff: bool, + mock_administrative_access: MagicMock, + ) -> None: + mock_administrative_access.return_value = mock_return_value + + output_data = CourseAccessSerializer({ + 'user': self.user, + 'course': self.course, + 'course_id': self.course.id + }).data + + self.assertEqual(output_data['is_staff'], is_staff) + mock_administrative_access.assert_called_once_with(self.user, self.course.id) + + @ddt.data(None, 'mocked_user_course_expiration_date') + @patch('lms.djangoapps.mobile_api.course_info.serializers.get_user_course_expiration_date') + def test_get_audit_access_expires( + self, + mock_return_value: Union[str, None], + mock_get_user_course_expiration_date: MagicMock, + ) -> None: + mock_get_user_course_expiration_date.return_value = mock_return_value + + output_data = CourseAccessSerializer({ + 'user': self.user, + 'course': self.course, + 'course_id': self.course.id + }).data + + self.assertEqual(output_data['audit_access_expires'], mock_return_value) + mock_get_user_course_expiration_date.assert_called_once_with(self.user, self.course) + + @patch('lms.djangoapps.mobile_api.course_info.serializers.has_access') + def test_get_courseware_access(self, mock_has_access: MagicMock) -> None: + mocked_access = { + 'has_access': True, + 'error_code': None, + 'developer_message': None, + 'user_message': None, + 'additional_context_user_message': None, + 'user_fragment': None + } + mock_has_access.return_value = Mock(to_json=Mock(return_value=mocked_access)) + + output_data = CourseAccessSerializer({ + 'user': self.user, + 'course': self.course, + 'course_id': self.course.id + }).data + + self.assertDictEqual(output_data['courseware_access'], mocked_access) + mock_has_access.assert_called_once_with(self.user, 'load_mobile', self.course) + mock_has_access.return_value.to_json.assert_called_once_with() + + +class TestCourseInfoOverviewSerializer(TestCase): + """ + Tests for the CourseInfoOverviewSerializer. + """ + + def setUp(self): + super().setUp() + self.user = UserFactory() + self.course_overview = CourseOverviewFactory() + + def test_get_media(self): + output_data = CourseInfoOverviewSerializer(self.course_overview, context={'user': self.user}).data + + self.assertIn('media', output_data) + self.assertIn('image', output_data['media']) + self.assertIn('raw', output_data['media']['image']) + self.assertIn('small', output_data['media']['image']) + self.assertIn('large', output_data['media']['image']) + + @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: + 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): + 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) diff --git a/lms/djangoapps/mobile_api/course_info/tests.py b/lms/djangoapps/mobile_api/tests/test_course_info_views.py similarity index 69% rename from lms/djangoapps/mobile_api/course_info/tests.py rename to lms/djangoapps/mobile_api/tests/test_course_info_views.py index e7552382cb3a..67d2c79f9017 100644 --- a/lms/djangoapps/mobile_api/course_info/tests.py +++ b/lms/djangoapps/mobile_api/tests/test_course_info_views.py @@ -5,26 +5,33 @@ import ddt from django.conf import settings +from django.contrib.auth import get_user_model +from django.contrib.auth.models import AnonymousUser +from django.test import RequestFactory from django.urls import reverse from edx_toggles.toggles.testutils import override_waffle_flag from milestones.tests.utils import MilestonesTestCaseMixin from mock import patch -from rest_framework.test import APIClient # pylint: disable=unused-import +from rest_framework import status -from common.djangoapps.student.models import CourseEnrollment # pylint: disable=unused-import from common.djangoapps.student.tests.factories import UserFactory # pylint: disable=unused-import +from common.djangoapps.util.course import get_link_for_about_page from lms.djangoapps.mobile_api.testutils import MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin from lms.djangoapps.mobile_api.utils import API_V1, API_V05 +from lms.djangoapps.mobile_api.course_info.views import BlocksInfoInCourseView from lms.djangoapps.course_api.blocks.tests.test_views import TestBlocksInCourseView +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.course_experience import ENABLE_COURSE_GOALS from xmodule.html_block import CourseInfoBlock # lint-amnesty, pylint: disable=wrong-import-order 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 from xmodule.modulestore.tests.django_utils import SharedModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order -from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=unused-import, wrong-import-order from xmodule.modulestore.xml_importer import import_course_from_xml # lint-amnesty, pylint: disable=wrong-import-order +User = get_user_model() + + @ddt.ddt class TestUpdates(MobileAPITestCase, MobileAuthTestMixin, MobileCourseAccessTestMixin, MilestonesTestCaseMixin): """ @@ -259,9 +266,9 @@ def test_flag_disabled(self, mock_logger): @ddt.ddt -class TestBlocksInfoInCourseView(TestBlocksInCourseView): # lint-amnesty, pylint: disable=test-inherits-tests +class TestBlocksInfoInCourseView(TestBlocksInCourseView, MilestonesTestCaseMixin): # lint-amnesty, pylint: disable=test-inherits-tests """ - Test class for BlocksInfoInCourseView + Test class for BlocksInfoInCourseView """ def setUp(self): @@ -269,6 +276,70 @@ def setUp(self): self.url = reverse('blocks_info_in_course', kwargs={ 'api_version': 'v3', }) + self.request = RequestFactory().get(self.url) + self.student_user = UserFactory.create(username="student_user") + + @ddt.data( + ('anonymous', None, None), + ('staff', 'student_user', 'student_user'), + ('student', 'student_user', 'student_user'), + ('student', None, 'student_user'), + ('student', 'other_student', None), + ) + @ddt.unpack + @patch('lms.djangoapps.mobile_api.course_info.views.User.objects.get') + def test_get_requested_user(self, user_role, username, expected_username, mock_get): + """ + Test get_requested_user utility from the BlocksInfoInCourseView. + + Parameters: + user_role: type of the user that making a request. + username: username query parameter from the request. + expected_username: username of the returned user. + """ + if user_role == 'anonymous': + request_user = AnonymousUser() + elif user_role == 'staff': + request_user = self.admin_user + elif user_role == 'student': + request_user = self.student_user + + self.request.user = request_user + + if expected_username == 'student_user': + mock_user = self.student_user + mock_get.return_value = mock_user + + result_user = BlocksInfoInCourseView().get_requested_user(self.request.user, username) + if expected_username: + self.assertEqual(result_user.username, expected_username) + if username and request_user.username != username: + mock_get.assert_called_with(username=username) + else: + self.assertIsNone(result_user) + + @ddt.data( + ({'is_downloadable': True, 'download_url': 'https://test_certificate_url'}, + {'url': 'https://test_certificate_url'}), + ({'is_downloadable': False}, {}), + ) + @ddt.unpack + @patch('lms.djangoapps.mobile_api.course_info.views.certificate_downloadable_status') + def test_get_certificate(self, certificate_status_return, expected_output, mock_certificate_status): + """ + Test get_certificate utility from the BlocksInfoInCourseView. + + Parameters: + certificate_status_return: returned value of the mocked certificate_downloadable_status function. + expected_output: return_value of the get_certificate function with specified mock return_value. + """ + mock_certificate_status.return_value = certificate_status_return + self.request.user = self.user + + certificate_info = BlocksInfoInCourseView().get_certificate( + self.request, self.user, 'course-v1:Test+T101+2021_T1' + ) + self.assertEqual(certificate_info, expected_output) @patch('lms.djangoapps.mobile_api.course_info.views.certificate_downloadable_status') def test_additional_info_response(self, mock_certificate_downloadable_status): @@ -302,3 +373,52 @@ def test_additional_info_response(self, mock_certificate_downloadable_status): assert response.data['certificate'] == {'url': certificate_url} assert response.data['is_self_paced'] is False mock_certificate_downloadable_status.assert_called_once() + + def test_course_access_details(self): + response = self.verify_response(url=self.url) + + expected_course_access_details = { + 'has_unmet_prerequisites': False, + 'is_too_early': False, + 'is_staff': False, + 'audit_access_expires': None, + 'courseware_access': { + 'has_access': True, + 'error_code': None, + 'developer_message': None, + 'user_message': None, + 'additional_context_user_message': None, + 'user_fragment': None + } + } + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.data['course_access_details'], expected_course_access_details) + + def test_course_sharing_utm_parameters(self): + response = self.verify_response(url=self.url) + + expected_course_sharing_utm_parameters = { + 'facebook': 'utm_medium=social&utm_campaign=social-sharing-db&utm_source=facebook', + 'twitter': 'utm_medium=social&utm_campaign=social-sharing-db&utm_source=twitter' + } + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertDictEqual(response.data['course_sharing_utm_parameters'], expected_course_sharing_utm_parameters) + + def test_course_about_url(self): + response = self.verify_response(url=self.url) + + course_overview = CourseOverview.objects.get(id=self.course.course_id) + expected_course_about_link = get_link_for_about_page(course_overview) + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertEqual(response.data['course_about'], expected_course_about_link) + + def test_course_modes(self): + response = self.verify_response(url=self.url) + + expected_course_modes = [{'slug': 'audit', 'sku': None, 'android_sku': None, 'ios_sku': None, 'min_price': 0}] + + self.assertEqual(response.status_code, status.HTTP_200_OK) + self.assertListEqual(response.data['course_modes'], expected_course_modes)