diff --git a/lms/djangoapps/support/tests/test_views.py b/lms/djangoapps/support/tests/test_views.py index bb337af309..04dae1a384 100644 --- a/lms/djangoapps/support/tests/test_views.py +++ b/lms/djangoapps/support/tests/test_views.py @@ -53,6 +53,16 @@ from lms.djangoapps.verify_student.tests.factories import SSOVerificationFactory from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.features.content_type_gating.models import ContentTypeGatingConfig from openedx.features.course_duration_limits.models import CourseDurationLimitConfig +from openedx.features.enterprise_support.api import enterprise_is_enabled +from openedx.features.enterprise_support.tests.factories import ( + EnterpriseCourseEnrollmentFactory, + EnterpriseCustomerUserFactory +) + +try: + from consent.models import DataSharingConsent +except ImportError: # pragma: no cover + pass class SupportViewTestCase(ModuleStoreTestCase): @@ -315,6 +325,54 @@ class SupportViewEnrollmentsTests(SharedModuleStoreTestCase, SupportViewTestCase }, data[0]) assert {CourseMode.VERIFIED, CourseMode.AUDIT, CourseMode.HONOR, CourseMode.NO_ID_PROFESSIONAL_MODE, CourseMode.PROFESSIONAL, CourseMode.CREDIT_MODE} == {mode['slug'] for mode in data[0]['course_modes']} + assert 'enterprise_course_enrollments' not in data[0] + + @override_settings(FEATURES=dict(ENABLE_ENTERPRISE_INTEGRATION=True)) + @enterprise_is_enabled() + def test_get_enrollments_enterprise_enabled(self): + url = reverse( + 'support:enrollment_list', + kwargs={'username_or_email': self.student.username} + ) + + enterprise_customer_user = EnterpriseCustomerUserFactory( + user_id=self.student.id + ) + enterprise_course_enrollment = EnterpriseCourseEnrollmentFactory( + course_id=self.course.id, + enterprise_customer_user=enterprise_customer_user + ) + data_sharing_consent = DataSharingConsent( + course_id=self.course.id, + enterprise_customer=enterprise_customer_user.enterprise_customer, + username=self.student.username, + granted=True + ) + data_sharing_consent.save() + + response = self.client.get(url) + assert response.status_code == 200 + data = json.loads(response.content.decode('utf-8')) + assert len(data) == 1 + + enterprise_course_enrollments_data = data[0]['enterprise_course_enrollments'] + assert len(enterprise_course_enrollments_data) == 1 + expected = { + 'course_id': str(enterprise_course_enrollment.course_id), + 'enterprise_customer_name': enterprise_customer_user.enterprise_customer.name, + 'enterprise_customer_user_id': enterprise_customer_user.id, + 'license': None, + 'saved_for_later': enterprise_course_enrollment.saved_for_later, + 'data_sharing_consent': { + 'username': self.student.username, + 'enterprise_customer_uuid': str(enterprise_customer_user.enterprise_customer_id), + 'exists': data_sharing_consent.exists, + 'consent_provided': data_sharing_consent.granted, + 'consent_required': data_sharing_consent.consent_required(), + 'course_id': str(enterprise_course_enrollment.course_id), + } + } + assert enterprise_course_enrollments_data[0] == expected @ddt.data( (True, 'Self Paced'), diff --git a/lms/djangoapps/support/views/enrollments.py b/lms/djangoapps/support/views/enrollments.py index e52858e223..4b1d68dab9 100644 --- a/lms/djangoapps/support/views/enrollments.py +++ b/lms/djangoapps/support/views/enrollments.py @@ -2,6 +2,8 @@ Support tool for changing course enrollments. """ +from collections import defaultdict + from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user from django.db import transaction from django.db.models import Q @@ -15,6 +17,7 @@ from rest_framework.generics import GenericAPIView from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.edxmako.shortcuts import render_to_response +from common.djangoapps.entitlements.models import CourseEntitlement from common.djangoapps.student.models import ( ENROLLED_TO_ENROLLED, UNENROLLED_TO_ENROLLED, @@ -23,7 +26,6 @@ from common.djangoapps.student.models import ( ManualEnrollmentAudit ) from common.djangoapps.util.json_request import JsonResponse -from common.djangoapps.entitlements.models import CourseEntitlement from lms.djangoapps.support.decorators import require_support_permission from lms.djangoapps.support.serializers import ManualEnrollmentSerializer from lms.djangoapps.verify_student.models import VerificationDeadline @@ -31,6 +33,12 @@ from openedx.core.djangoapps.credit.email_utils import get_credit_provider_attri from openedx.core.djangoapps.enrollments.api import get_enrollments, update_enrollment from openedx.core.djangoapps.enrollments.errors import CourseModeNotFoundError from openedx.core.djangoapps.enrollments.serializers import ModeSerializer +from openedx.features.enterprise_support.api import ( + enterprise_enabled, + get_data_sharing_consents, + get_enterprise_course_enrollments +) +from openedx.features.enterprise_support.serializers import EnterpriseCourseEnrollmentSerializer class EnrollmentSupportView(View): @@ -59,6 +67,35 @@ class EnrollmentSupportListView(GenericAPIView): # does not specify a serializer class. exclude_from_schema = True + def _enterprise_course_enrollments_by_course_id(self, user): + """ + Returns a dict containing enterprise course enrollments data with + course ids as keys. + """ + enterprise_course_enrollments = get_enterprise_course_enrollments(user) + data_sharing_consents_for_user = get_data_sharing_consents(user) + + enterprise_enrollments_by_course_id = defaultdict(list) + consent_by_course_and_enterprise_customer_id = {} + + # Get data sharing consent for each enterprise enrollment + for consent in data_sharing_consents_for_user: + key = f'{consent.course_id}-{consent.enterprise_customer_id}' + consent_by_course_and_enterprise_customer_id[key] = consent.serialize() + + for enterprise_course_enrollment in enterprise_course_enrollments: + serialized_enterprise_course_enrollment = EnterpriseCourseEnrollmentSerializer( + enterprise_course_enrollment + ).data + course_id = enterprise_course_enrollment.course_id + enterprise_customer_id = enterprise_course_enrollment.enterprise_customer_user.enterprise_customer_id + key = f'{course_id}-{enterprise_customer_id}' + consent = consent_by_course_and_enterprise_customer_id.get(key) + serialized_enterprise_course_enrollment['data_sharing_consent'] = consent + enterprise_enrollments_by_course_id[course_id].append(serialized_enterprise_course_enrollment) + + return enterprise_enrollments_by_course_id + @method_decorator(require_support_permission) def get(self, request, username_or_email): """ @@ -71,16 +108,24 @@ class EnrollmentSupportListView(GenericAPIView): return JsonResponse([]) enrollments = get_enrollments(user.username, include_inactive=True) + for enrollment in enrollments: # Folds the course_details field up into the main JSON object. enrollment.update(**enrollment.pop('course_details')) course_key = CourseKey.from_string(enrollment['course_id']) - # get the all courses modes and replace with existing modes. + # Get the all courses modes and replace with existing modes. enrollment['course_modes'] = self.get_course_modes(course_key) # Add the price of the course's verified mode. self.include_verified_mode_info(enrollment, course_key) # Add manual enrollment history, if it exists enrollment['manual_enrollment'] = self.manual_enrollment_data(enrollment, course_key) + + if enterprise_enabled(): + enterprise_enrollments_by_course_id = self._enterprise_course_enrollments_by_course_id(user) + for enrollment in enrollments: + enterprise_course_enrollments = enterprise_enrollments_by_course_id.get(enrollment['course_id'], []) + enrollment['enterprise_course_enrollments'] = enterprise_course_enrollments + return JsonResponse(enrollments) @method_decorator(require_support_permission) diff --git a/openedx/core/djangoapps/enrollments/views.py b/openedx/core/djangoapps/enrollments/views.py index e1616e23e2..01d4b58d62 100644 --- a/openedx/core/djangoapps/enrollments/views.py +++ b/openedx/core/djangoapps/enrollments/views.py @@ -7,20 +7,37 @@ consist primarily of authentication, request validation, and serialization. import logging -from common.djangoapps.course_modes.models import CourseMode -from django.core.exceptions import ObjectDoesNotExist, ValidationError # lint-amnesty, pylint: disable=wrong-import-order +from django.core.exceptions import ( # lint-amnesty, pylint: disable=wrong-import-order + ObjectDoesNotExist, + ValidationError +) from django.utils.decorators import method_decorator # lint-amnesty, pylint: disable=wrong-import-order -from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication # lint-amnesty, pylint: disable=wrong-import-order -from edx_rest_framework_extensions.auth.session.authentication import SessionAuthenticationAllowInactiveUser # lint-amnesty, pylint: disable=wrong-import-order +from edx_rest_framework_extensions.auth.jwt.authentication import \ + JwtAuthentication # lint-amnesty, pylint: disable=wrong-import-order +from edx_rest_framework_extensions.auth.session.authentication import \ + SessionAuthenticationAllowInactiveUser # lint-amnesty, pylint: disable=wrong-import-order from opaque_keys import InvalidKeyError # lint-amnesty, pylint: disable=wrong-import-order from opaque_keys.edx.keys import CourseKey # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework import permissions, status # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.generics import ListAPIView # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.throttling import UserRateThrottle # lint-amnesty, pylint: disable=wrong-import-order +from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order + +from common.djangoapps.course_modes.models import CourseMode +from common.djangoapps.student.auth import user_has_role +from common.djangoapps.student.models import CourseEnrollment, User +from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff +from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf from openedx.core.djangoapps.cors_csrf.decorators import ensure_csrf_cookie_cross_domain from openedx.core.djangoapps.course_groups.cohorts import CourseUserGroup, add_user_to_cohort, get_cohort_by_name from openedx.core.djangoapps.embargo import api as embargo_api from openedx.core.djangoapps.enrollments import api from openedx.core.djangoapps.enrollments.errors import ( - CourseEnrollmentError, CourseEnrollmentExistsError, CourseModeNotFoundError, + CourseEnrollmentError, + CourseEnrollmentExistsError, + CourseModeNotFoundError ) from openedx.core.djangoapps.enrollments.forms import CourseEnrollmentsApiListForm from openedx.core.djangoapps.enrollments.paginators import CourseEnrollmentsApiListPagination @@ -39,15 +56,6 @@ from openedx.features.enterprise_support.api import ( EnterpriseApiServiceClient, enterprise_enabled ) -from rest_framework import permissions, status # lint-amnesty, pylint: disable=wrong-import-order -from rest_framework.generics import ListAPIView # lint-amnesty, pylint: disable=wrong-import-order -from rest_framework.response import Response # lint-amnesty, pylint: disable=wrong-import-order -from rest_framework.throttling import UserRateThrottle # lint-amnesty, pylint: disable=wrong-import-order -from rest_framework.views import APIView # lint-amnesty, pylint: disable=wrong-import-order -from common.djangoapps.student.auth import user_has_role -from common.djangoapps.student.models import CourseEnrollment, User -from common.djangoapps.student.roles import CourseStaffRole, GlobalStaff -from common.djangoapps.util.disable_rate_limit import can_disable_rate_limit log = logging.getLogger(__name__) REQUIRED_ATTRIBUTES = { @@ -743,7 +751,7 @@ class EnrollmentListView(APIView, ApiKeyPermissionMixIn): enterprise_api_client = EnterpriseApiServiceClient() consent_client = ConsentApiServiceClient() try: - enterprise_api_client.post_enterprise_course_enrollment(username, str(course_id), None) + enterprise_api_client.post_enterprise_course_enrollment(username, str(course_id)) except EnterpriseApiException as error: log.exception("An unexpected error occurred while creating the new EnterpriseCourseEnrollment " "for user [%s] in course run [%s]", username, course_id) diff --git a/openedx/features/enterprise_support/api.py b/openedx/features/enterprise_support/api.py index b8dd301bb0..1ec3445b3a 100644 --- a/openedx/features/enterprise_support/api.py +++ b/openedx/features/enterprise_support/api.py @@ -21,25 +21,26 @@ from edx_django_utils.cache import TieredCache, get_cache_key from edx_rest_api_client.client import EdxRestApiClient from slumber.exceptions import HttpClientError, HttpNotFoundError, HttpServerError +from common.djangoapps.third_party_auth.pipeline import get as get_partial_pipeline +from common.djangoapps.third_party_auth.provider import Registry from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangolib.markup import HTML, Text from openedx.features.enterprise_support.utils import get_data_consent_share_cache_key -from common.djangoapps.third_party_auth.pipeline import get as get_partial_pipeline -from common.djangoapps.third_party_auth.provider import Registry - try: + from consent.models import DataSharingConsent, DataSharingConsentTextOverrides + from enterprise.api.v1.serializers import ( + EnterpriseCustomerUserReadOnlySerializer, + EnterpriseCustomerUserWriteSerializer + ) from enterprise.models import ( + EnterpriseCourseEnrollment, EnterpriseCustomer, EnterpriseCustomerIdentityProvider, EnterpriseCustomerUser, PendingEnterpriseCustomerUser ) - from enterprise.api.v1.serializers import ( - EnterpriseCustomerUserReadOnlySerializer, EnterpriseCustomerUserWriteSerializer - ) - from consent.models import DataSharingConsent, DataSharingConsentTextOverrides except ImportError: # pragma: no cover pass @@ -162,14 +163,13 @@ class EnterpriseApiClient: endpoint = getattr(self.client, 'enterprise-customer') return endpoint(uuid).get() - def post_enterprise_course_enrollment(self, username, course_id, consent_granted): + def post_enterprise_course_enrollment(self, username, course_id): """ Create an EnterpriseCourseEnrollment by using the corresponding serializer (for validation). """ data = { 'username': username, 'course_id': course_id, - 'consent_granted': consent_granted, } endpoint = getattr(self.client, 'enterprise-course-enrollment') try: @@ -177,11 +177,10 @@ class EnterpriseApiClient: except (HttpClientError, HttpServerError): message = ( "An error occured while posting EnterpriseCourseEnrollment for user {username} and " - "course run {course_id} (consent_granted value: {consent_granted})" + "course run {course_id}." ).format( username=username, course_id=course_id, - consent_granted=consent_granted, ) LOGGER.exception(message) raise EnterpriseApiException(message) # lint-amnesty, pylint: disable=raise-missing-from @@ -786,6 +785,33 @@ def get_enterprise_learner_data_from_db(user): return serializer.data +@enterprise_is_enabled(otherwise=[]) +def get_data_sharing_consents(user): + """ + Returns a list of data sharing consent records for the given user. + """ + + return DataSharingConsent.objects.filter( + username=user.username + ) + + +@enterprise_is_enabled(otherwise=[]) +def get_enterprise_course_enrollments(user): + """ + Returns a list of enterprise course enrollments for the given user. + """ + + return EnterpriseCourseEnrollment.objects.select_related( + 'licensed_with', + 'enterprise_customer_user' + ).prefetch_related( + 'enterprise_customer_user__enterprise_customer' + ).filter( + enterprise_customer_user__user_id=user.id + ) + + @enterprise_is_enabled() def enterprise_customer_from_session_or_learner_data(request): """ diff --git a/openedx/features/enterprise_support/serializers.py b/openedx/features/enterprise_support/serializers.py new file mode 100644 index 0000000000..54171de513 --- /dev/null +++ b/openedx/features/enterprise_support/serializers.py @@ -0,0 +1,44 @@ +""" +Defines serializers for enterprise_support. +""" + + +from rest_framework import serializers + +try: + from enterprise.api.v1.serializers import \ + EnterpriseCourseEnrollmentReadOnlySerializer as BaseEnterpriseCourseEnrollmentSerializer + from enterprise.models import EnterpriseCourseEnrollment +except ImportError: # pragma: no cover + pass + + +class EnterpriseCourseEnrollmentSerializer(BaseEnterpriseCourseEnrollmentSerializer): + """ + Serializer for EnterpriseCourseEnrollment model. + """ + + enterprise_customer_name = serializers.SerializerMethodField() + license = serializers.SerializerMethodField() + + class Meta: + model = EnterpriseCourseEnrollment + fields = ( + 'course_id', + 'enterprise_customer_name', + 'enterprise_customer_user_id', + 'license', + 'saved_for_later' + ) + + def get_enterprise_customer_name(self, obj): + return obj.enterprise_customer_user.enterprise_customer.name + + def get_license(self, obj): + licensed_ece = obj.license + + if licensed_ece: + return { + 'uuid': str(licensed_ece.license_uuid), + 'is_revoked': licensed_ece.is_revoked + } diff --git a/openedx/features/enterprise_support/tests/test_api.py b/openedx/features/enterprise_support/tests/test_api.py index f31e647d50..5ff059fcd1 100644 --- a/openedx/features/enterprise_support/tests/test_api.py +++ b/openedx/features/enterprise_support/tests/test_api.py @@ -41,7 +41,9 @@ from openedx.features.enterprise_support.api import ( get_consent_notification_data, get_consent_required_courses, get_dashboard_consent_notification, + get_data_sharing_consents, get_enterprise_consent_url, + get_enterprise_course_enrollments, get_enterprise_learner_data_from_api, get_enterprise_learner_data_from_db, get_enterprise_learner_portal_enabled_message, @@ -50,6 +52,7 @@ from openedx.features.enterprise_support.api import ( ) from openedx.features.enterprise_support.tests import FEATURES_WITH_ENTERPRISE_ENABLED from openedx.features.enterprise_support.tests.factories import ( + EnterpriseCourseEnrollmentFactory, EnterpriseCustomerIdentityProviderFactory, EnterpriseCustomerUserFactory ) @@ -185,18 +188,16 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase): username = 'spongebob' course_id = 'burger-flipping-101' - consent_granted = True if should_raise_http_error: with pytest.raises(EnterpriseApiException): - api_client.post_enterprise_course_enrollment(username, course_id, consent_granted) + api_client.post_enterprise_course_enrollment(username, course_id) else: - api_client.post_enterprise_course_enrollment(username, course_id, consent_granted) + api_client.post_enterprise_course_enrollment(username, course_id) mock_endpoint.post.assert_called_once_with(data={ 'username': username, 'course_id': course_id, - 'consent_granted': consent_granted, }) @mock.patch('openedx.features.enterprise_support.api.enterprise_customer_uuid_for_request') @@ -398,10 +399,45 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase): assert not get_enterprise_learner_data_from_db(self.user) def test_get_enterprise_learner_data_from_db(self): - enterprise_customer_user = EnterpriseCustomerUserFactory(user_id=self.user.id) # lint-amnesty, pylint: disable=unused-variable + EnterpriseCustomerUserFactory(user_id=self.user.id) user_data = get_enterprise_learner_data_from_db(self.user)[0]['user'] assert user_data['username'] == self.user.username + @ddt.data(True, False) + @mock.patch('openedx.features.enterprise_support.api.enterprise_enabled') + def test_get_data_sharing_consents(self, is_enterprise_enabled, mock_enterprise_enabled): + mock_enterprise_enabled.return_value = is_enterprise_enabled + enterprise_customer_user = EnterpriseCustomerUserFactory(user_id=self.user.id) + + if not is_enterprise_enabled: + assert get_data_sharing_consents(self.user) == [] + else: + course_id = 'fake-course' + data_sharing_consent = DataSharingConsent( + course_id=course_id, + enterprise_customer=enterprise_customer_user.enterprise_customer, + username=self.user.username, + granted=False + ) + data_sharing_consent.save() + data_sharing_consents = get_data_sharing_consents(self.user) + assert len(data_sharing_consents) == 1 + assert data_sharing_consents[0].id == data_sharing_consent.id + + @ddt.data(True, False) + @mock.patch('openedx.features.enterprise_support.api.enterprise_enabled') + def test_get_enterprise_course_enrollments(self, is_enterprise_enabled, mock_enterprise_enabled): + mock_enterprise_enabled.return_value = is_enterprise_enabled + enterprise_customer_user = EnterpriseCustomerUserFactory(user_id=self.user.id) + + if not is_enterprise_enabled: + assert get_enterprise_course_enrollments(self.user) == [] + else: + ece = EnterpriseCourseEnrollmentFactory(enterprise_customer_user=enterprise_customer_user) + enterprise_course_enrollments = get_enterprise_course_enrollments(self.user) + assert len(enterprise_course_enrollments) == 1 + assert enterprise_course_enrollments[0].id == ece.id + @httpretty.activate @mock.patch('openedx.features.enterprise_support.api.get_enterprise_learner_data_from_db') @mock.patch('openedx.features.enterprise_support.api.EnterpriseCustomer') diff --git a/openedx/features/enterprise_support/tests/test_serializers.py b/openedx/features/enterprise_support/tests/test_serializers.py new file mode 100644 index 0000000000..aa82b8dbb0 --- /dev/null +++ b/openedx/features/enterprise_support/tests/test_serializers.py @@ -0,0 +1,67 @@ +""" +Tests for custom enterprise_support Serializers. +""" + +from uuid import uuid4 + +from django.test import TestCase +from enterprise.models import LicensedEnterpriseCourseEnrollment + +from openedx.features.enterprise_support.serializers import EnterpriseCourseEnrollmentSerializer +from openedx.features.enterprise_support.tests.factories import ( + EnterpriseCourseEnrollmentFactory, + EnterpriseCustomerUserFactory +) + + +class EnterpriseCourseEnrollmentSerializerTests(TestCase): + """ + Tests for EnterpriseCourseEnrollmentSerializer. + """ + + @classmethod + def setUpTestData(cls): # lint-amnesty, pylint: disable=super-method-not-called + enterprise_customer_user = EnterpriseCustomerUserFactory() + enterprise_course_enrollment = EnterpriseCourseEnrollmentFactory( + enterprise_customer_user=enterprise_customer_user + ) + cls.enterprise_customer_user = enterprise_customer_user + cls.enterprise_course_enrollment = enterprise_course_enrollment + + def test_data_with_license(self): + """ Verify the correct fields are serialized when the enrollment is licensed. """ + + license_uuid = uuid4() + licensed_ece = LicensedEnterpriseCourseEnrollment( + license_uuid=license_uuid, + enterprise_course_enrollment=self.enterprise_course_enrollment + ) + licensed_ece.save() + + serializer = EnterpriseCourseEnrollmentSerializer(self.enterprise_course_enrollment) + + expected = { + 'enterprise_customer_name': self.enterprise_customer_user.enterprise_customer.name, + 'enterprise_customer_user_id': self.enterprise_customer_user.id, + 'course_id': self.enterprise_course_enrollment.course_id, + 'saved_for_later': self.enterprise_course_enrollment.saved_for_later, + 'license': { + 'uuid': str(license_uuid), + 'is_revoked': licensed_ece.is_revoked, + } + } + self.assertDictEqual(serializer.data, expected) + + def test_data_without_license(self): + """ Verify the correct fields are serialized when the enrollment is not licensed. """ + + serializer = EnterpriseCourseEnrollmentSerializer(self.enterprise_course_enrollment) + + expected = { + 'enterprise_customer_name': self.enterprise_customer_user.enterprise_customer.name, + 'enterprise_customer_user_id': self.enterprise_customer_user.id, + 'course_id': self.enterprise_course_enrollment.course_id, + 'saved_for_later': self.enterprise_course_enrollment.saved_for_later, + 'license': None + } + self.assertDictEqual(serializer.data, expected)