feat(mobile_api): Add course access object to mobile course info API (#34273)

* feat: include access serializer into mobile info api view

* test: add tests for serializer and view methods

* test: move tests to common directory and update test case

* fix: cr fixes and use snake case for functions

* test: fix additional get call assertion

* feat: add required course access messages to mobile endpoint

* test: [AXM-229] Improve test coverage

* style: [AXM-229] Try to fix linters

* fix: remove redundant comment

* refactor: change names for the test files

---------

Co-authored-by: KyryloKireiev <kirillkireev888@gmail.com>
This commit is contained in:
Glib Glugovskiy
2024-04-25 11:13:43 +03:00
committed by GitHub
parent e76fee1521
commit c37e9765ef
4 changed files with 470 additions and 20 deletions

View File

@@ -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 CourseInfoOverviewSerializer(serializers.ModelSerializer):
'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()

View File

@@ -3,20 +3,28 @@ Views for course info API
"""
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 @@ from ..decorators import mobile_course_access, mobile_view
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]:
"""
Returns the information about the user's certificate in the course.
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):
"""
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 @@ class BlocksInfoInCourseView(BlocksInCourseView):
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

View File

@@ -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)

View File

@@ -5,26 +5,33 @@ Tests for course_info
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 @@ class TestCourseGoalsUserActivityAPI(MobileAPITestCase, SharedModuleStoreTestCas
@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 @@ class TestBlocksInfoInCourseView(TestBlocksInCourseView): # lint-amnesty, pylin
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 @@ class TestBlocksInfoInCourseView(TestBlocksInCourseView): # lint-amnesty, pylin
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)