From 7fe5229bbb11286bcaa50e1858d803d514c7cd13 Mon Sep 17 00:00:00 2001 From: Mohammad Ahtasham ul Hassan <60315450+aht007@users.noreply.github.com> Date: Thu, 13 Jul 2023 13:38:20 +0500 Subject: [PATCH] feat: Add new entitlement expiration endpoint (#32677) * feat: add new entitlements expiration endpoint --- .../entitlements/rest_api/v1/permissions.py | 11 ++ .../rest_api/v1/tests/test_views.py | 170 +++++++++++++++++- .../entitlements/rest_api/v1/urls.py | 9 +- .../entitlements/rest_api/v1/views.py | 73 +++++++- common/djangoapps/entitlements/tasks.py | 34 ++++ common/djangoapps/entitlements/utils.py | 35 ++++ openedx/core/djangoapps/credentials/utils.py | 4 +- 7 files changed, 326 insertions(+), 10 deletions(-) diff --git a/common/djangoapps/entitlements/rest_api/v1/permissions.py b/common/djangoapps/entitlements/rest_api/v1/permissions.py index 67ca04aae16..6a705d9feed 100644 --- a/common/djangoapps/entitlements/rest_api/v1/permissions.py +++ b/common/djangoapps/entitlements/rest_api/v1/permissions.py @@ -4,6 +4,7 @@ """ +from django.conf import settings from rest_framework.permissions import SAFE_METHODS, BasePermission from lms.djangoapps.courseware.access import has_access @@ -15,8 +16,18 @@ class IsAdminOrSupportOrAuthenticatedReadOnly(BasePermission): in the SAFE_METHODS list. For example GET requests will not require an Admin or Support user. """ + def has_permission(self, request, view): if request.method in SAFE_METHODS: return request.user.is_authenticated else: return request.user.is_staff or has_access(request.user, "support", "global") + + +class IsSubscriptionWorkerUser(BasePermission): + """ + Method that will require the request to be coming from the subscriptions service worker user. + """ + + def has_permission(self, request, view): + return request.user.username == settings.SUBSCRIPTIONS_SERVICE_WORKER_USERNAME diff --git a/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py b/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py index 980b5a7a091..8c3bf10c1a5 100644 --- a/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py +++ b/common/djangoapps/entitlements/rest_api/v1/tests/test_views.py @@ -1,12 +1,12 @@ """ Test file to test the Entitlement API Views. """ - import json import logging import uuid from datetime import datetime, timedelta from unittest.mock import patch +from uuid import uuid4 from django.conf import settings from django.urls import reverse @@ -24,17 +24,22 @@ from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from openedx.core.djangoapps.user_api.models import UserOrgTag from openedx.core.djangolib.testing.utils import skip_unless_lms -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order +from xmodule.modulestore.tests.django_utils import \ + ModuleStoreTestCase # lint-amnesty, pylint: disable=wrong-import-order from xmodule.modulestore.tests.factories import CourseFactory # lint-amnesty, pylint: disable=wrong-import-order log = logging.getLogger(__name__) # Entitlements is not in CMS' INSTALLED_APPS so these imports will error during test collection if settings.ROOT_URLCONF == 'lms.urls': - from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory - from common.djangoapps.entitlements.models import CourseEntitlement, CourseEntitlementPolicy, CourseEntitlementSupportDetail # lint-amnesty, pylint: disable=line-too-long + from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: disable=line-too-long + CourseEntitlement, + CourseEntitlementPolicy, + CourseEntitlementSupportDetail + ) from common.djangoapps.entitlements.rest_api.v1.serializers import CourseEntitlementSerializer from common.djangoapps.entitlements.rest_api.v1.views import set_entitlement_policy + from common.djangoapps.entitlements.tests.factories import CourseEntitlementFactory @skip_unless_lms @@ -1231,3 +1236,160 @@ def test_user_is_not_unenrolled_on_failed_refund( assert CourseEnrollment.is_enrolled(self.user, self.course.id) assert course_entitlement.enrollment_course_run is not None assert course_entitlement.expired_at is None + + +@skip_unless_lms +class RevokeSubscriptionsVerifiedAccessViewTest(ModuleStoreTestCase): + """ + Tests for the RevokeVerifiedAccessView + """ + REVOKE_VERIFIED_ACCESS_PATH = 'entitlements_api:v1:revoke_subscriptions_verified_access' + + def setUp(self): + super().setUp() + self.user = UserFactory(username="subscriptions_worker", is_staff=True) + self.client.login(username=self.user.username, password=TEST_PASSWORD) + self.course = CourseFactory() + self.course_mode1 = CourseModeFactory( + course_id=self.course.id, # pylint: disable=no-member + mode_slug=CourseMode.VERIFIED, + expiration_datetime=now() + timedelta(days=1) + ) + self.course_mode2 = CourseModeFactory( + course_id=self.course.id, # pylint: disable=no-member + mode_slug=CourseMode.AUDIT, + expiration_datetime=now() + timedelta(days=1) + ) + + @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') + def test_revoke_access_success(self, mock_get_courses_completion_status): + mock_get_courses_completion_status.return_value = ([], False) + enrollment = CourseEnrollmentFactory.create( + user=self.user, + course_id=self.course.id, # pylint: disable=no-member + is_active=True, + mode=CourseMode.VERIFIED + ) + course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) + url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) + + assert course_entitlement.enrollment_course_run is not None + + response = self.client.post( + url, + data={ + "entitlement_uuids": [str(course_entitlement.uuid)], + "lms_user_id": self.user.id + }, + content_type='application/json', + ) + assert response.status_code == 204 + + course_entitlement.refresh_from_db() + enrollment.refresh_from_db() + assert course_entitlement.expired_at is not None + assert course_entitlement.enrollment_course_run is None + assert enrollment.mode == CourseMode.AUDIT + + @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') + def test_already_completed_course(self, mock_get_courses_completion_status): + enrollment = CourseEnrollmentFactory.create( + user=self.user, + course_id=self.course.id, # pylint: disable=no-member + is_active=True, + mode=CourseMode.VERIFIED + ) + mock_get_courses_completion_status.return_value = ([enrollment.course_id], False) + course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) + url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) + + assert course_entitlement.enrollment_course_run is not None + + response = self.client.post( + url, + data={ + "entitlement_uuids": [str(course_entitlement.uuid)], + "lms_user_id": self.user.id + }, + content_type='application/json', + ) + assert response.status_code == 204 + + course_entitlement.refresh_from_db() + assert course_entitlement.expired_at is None + assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED + + @patch('common.djangoapps.entitlements.rest_api.v1.views.log.info') + def test_revoke_access_invalid_uuid(self, mock_log): + url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) + entitlement_uuids = [str(uuid4())] + response = self.client.post( + url, + data={ + "entitlement_uuids": entitlement_uuids, + "lms_user_id": self.user.id + }, + content_type='application/json', + ) + + mock_log.assert_called_once_with("B2C_SUBSCRIPTIONS: Entitlements not found for the provided" + " entitlements data: %s and user: %s", + entitlement_uuids, + self.user.id) + assert response.status_code == 204 + + def test_revoke_access_unauthorized_user(self): + user = UserFactory(is_staff=True, username='not_subscriptions_worker') + self.client.login(username=user.username, password=TEST_PASSWORD) + + enrollment = CourseEnrollmentFactory.create( + user=self.user, + course_id=self.course.id, # pylint: disable=no-member + is_active=True, + mode=CourseMode.VERIFIED + ) + course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) + url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) + + assert course_entitlement.enrollment_course_run is not None + + response = self.client.post( + url, + data={ + "entitlement_uuids": [], + "lms_user_id": self.user.id + }, + content_type='application/json', + ) + assert response.status_code == 403 + + course_entitlement.refresh_from_db() + assert course_entitlement.expired_at is None + assert course_entitlement.enrollment_course_run.mode == CourseMode.VERIFIED + + @patch('common.djangoapps.entitlements.tasks.retry_revoke_subscriptions_verified_access.apply_async') + @patch('common.djangoapps.entitlements.rest_api.v1.views.get_courses_completion_status') + def test_course_completion_exception_triggers_task(self, mock_get_courses_completion_status, mock_task): + mock_get_courses_completion_status.return_value = ([], True) + enrollment = CourseEnrollmentFactory.create( + user=self.user, + course_id=self.course.id, # pylint: disable=no-member + is_active=True, + mode=CourseMode.VERIFIED + ) + course_entitlement = CourseEntitlementFactory.create(user=self.user, enrollment_course_run=enrollment) + + url = reverse(self.REVOKE_VERIFIED_ACCESS_PATH) + + response = self.client.post( + url, + data={ + "entitlement_uuids": [str(course_entitlement.uuid)], + "lms_user_id": self.user.id + }, + content_type='application/json', + ) + assert response.status_code == 204 + mock_task.assert_called_once_with(args=([str(course_entitlement.uuid)], + [str(enrollment.course_id)], + self.user.id)) diff --git a/common/djangoapps/entitlements/rest_api/v1/urls.py b/common/djangoapps/entitlements/rest_api/v1/urls.py index a4c2c953670..ae314660a81 100644 --- a/common/djangoapps/entitlements/rest_api/v1/urls.py +++ b/common/djangoapps/entitlements/rest_api/v1/urls.py @@ -3,10 +3,10 @@ """ from django.conf.urls import include +from django.urls import path, re_path from rest_framework.routers import DefaultRouter -from django.urls import path, re_path -from .views import EntitlementEnrollmentViewSet, EntitlementViewSet +from .views import EntitlementEnrollmentViewSet, EntitlementViewSet, SubscriptionsRevokeVerifiedAccessView router = DefaultRouter() router.register(r'entitlements', EntitlementViewSet, basename='entitlements') @@ -23,5 +23,10 @@ fr'entitlements/(?P{EntitlementViewSet.ENTITLEMENT_UUID4_REGEX})/enrollments$', ENROLLMENTS_VIEW, name='enrollments' + ), + path( + 'subscriptions/entitlements/revoke', + SubscriptionsRevokeVerifiedAccessView.as_view(), + name='revoke_subscriptions_verified_access' ) ] diff --git a/common/djangoapps/entitlements/rest_api/v1/views.py b/common/djangoapps/entitlements/rest_api/v1/views.py index 2516687d150..d8dbd29b249 100644 --- a/common/djangoapps/entitlements/rest_api/v1/views.py +++ b/common/djangoapps/entitlements/rest_api/v1/views.py @@ -15,6 +15,7 @@ from rest_framework import permissions, status, viewsets from rest_framework.authentication import SessionAuthentication from rest_framework.response import Response +from rest_framework.views import APIView from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: disable=line-too-long @@ -23,14 +24,22 @@ CourseEntitlementSupportDetail ) from common.djangoapps.entitlements.rest_api.v1.filters import CourseEntitlementFilter -from common.djangoapps.entitlements.rest_api.v1.permissions import IsAdminOrSupportOrAuthenticatedReadOnly +from common.djangoapps.entitlements.rest_api.v1.permissions import ( + IsAdminOrSupportOrAuthenticatedReadOnly, + IsSubscriptionWorkerUser +) from common.djangoapps.entitlements.rest_api.v1.serializers import CourseEntitlementSerializer from common.djangoapps.entitlements.rest_api.v1.throttles import ServiceUserThrottle -from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable +from common.djangoapps.entitlements.tasks import retry_revoke_subscriptions_verified_access +from common.djangoapps.entitlements.utils import ( + is_course_run_entitlement_fulfillable, + revoke_entitlements_and_downgrade_courses_to_audit +) from common.djangoapps.student.models import AlreadyEnrolledError, CourseEnrollment, CourseEnrollmentException from openedx.core.djangoapps.catalog.utils import get_course_runs_for_course, get_owners_for_course from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.cors_csrf.authentication import SessionAuthenticationCrossDomainCsrf +from openedx.core.djangoapps.credentials.utils import get_courses_completion_status from openedx.core.djangoapps.user_api.preferences.api import update_email_opt_in log = logging.getLogger(__name__) @@ -521,3 +530,63 @@ def destroy(self, request, uuid): }) return Response(status=status.HTTP_204_NO_CONTENT) + + +class SubscriptionsRevokeVerifiedAccessView(APIView): + """ + Endpoint for expiring entitlements for a user and downgrading the enrollments + to Audit mode. This endpoint accepts a list of entitlement UUIDs and will expire + the entitlements along with downgrading the related enrollments to Audit mode. + Only those enrollments are downgraded to Audit for which user has not been awarded + a completion certificate yet. + """ + authentication_classes = (JwtAuthentication, SessionAuthenticationCrossDomainCsrf,) + permission_classes = (permissions.IsAuthenticated, IsSubscriptionWorkerUser,) + throttle_classes = (ServiceUserThrottle,) + + def _process_revoke_and_downgrade_to_audit(self, course_entitlements, user_id, revocable_entitlement_uuids): + """ + Gets course completion status for the provided course entitlements and triggers the + revoke and downgrade to audit process for the course entitlements which are not completed. + Triggers the retry task asynchronously if there is an exception while getting the + course completion status. + """ + entitled_course_ids = [] + for course_entitlement in course_entitlements: + if course_entitlement.enrollment_course_run is not None: + entitled_course_ids.append(str(course_entitlement.enrollment_course_run.course_id)) + + awarded_cert_course_ids, is_exception = get_courses_completion_status(user_id, entitled_course_ids) + + if is_exception: + # Trigger the retry task asynchronously + log.exception('B2C_SUBSCRIPTIONS: Exception occurred while getting course completion status for user %s ' + 'and entitled_course_ids %s', + user_id, + entitled_course_ids) + retry_revoke_subscriptions_verified_access.apply_async(args=(revocable_entitlement_uuids, + entitled_course_ids, + user_id)) + return + revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, user_id, awarded_cert_course_ids, + revocable_entitlement_uuids) + + def post(self, request): + """ + Invokes the entitlements expiration process for the provided uuids and downgrades the + enrollments to Audit mode. + """ + revocable_entitlement_uuids = request.data.get('entitlement_uuids', []) + user_id = request.data.get('lms_user_id', None) + course_entitlements = (CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids). + select_related('user'). + select_related('enrollment_course_run')) + + if course_entitlements.exists(): + self._process_revoke_and_downgrade_to_audit(course_entitlements, user_id, revocable_entitlement_uuids) + return Response(status=status.HTTP_204_NO_CONTENT) + else: + log.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements data: %s and user: %s', + revocable_entitlement_uuids, + user_id) + return Response(status=status.HTTP_204_NO_CONTENT) diff --git a/common/djangoapps/entitlements/tasks.py b/common/djangoapps/entitlements/tasks.py index a91e504fccb..b6ba21b3611 100644 --- a/common/djangoapps/entitlements/tasks.py +++ b/common/djangoapps/entitlements/tasks.py @@ -2,13 +2,17 @@ This file contains celery tasks for entitlements-related functionality. """ import logging + from celery import shared_task +from celery.exceptions import MaxRetriesExceededError from celery.utils.log import get_task_logger from django.conf import settings # lint-amnesty, pylint: disable=unused-import from django.contrib.auth import get_user_model from edx_django_utils.monitoring import set_code_owner_attribute from common.djangoapps.entitlements.models import CourseEntitlement, CourseEntitlementSupportDetail +from common.djangoapps.entitlements.utils import revoke_entitlements_and_downgrade_courses_to_audit +from openedx.core.djangoapps.credentials.utils import get_courses_completion_status LOGGER = get_task_logger(__name__) log = logging.getLogger(__name__) @@ -150,3 +154,33 @@ def expire_and_create_entitlements(self, entitlement_ids, support_username): '%d entries, task id :%s', len(entitlement_ids), self.request.id) + + +@shared_task(bind=True) +def retry_revoke_subscriptions_verified_access(self, revocable_entitlement_uuids, entitled_course_ids, user_id): + """ + Task to process course access revoke and move to audit. + This is called only if call to get_courses_completion_status fails due to any exception. + """ + course_entitlements = CourseEntitlement.objects.filter(uuid__in=revocable_entitlement_uuids) + course_entitlements = course_entitlements.select_related('user').select_related('enrollment_course_run') + if course_entitlements.exists(): + awarded_cert_course_ids, is_exception = get_courses_completion_status(user_id, entitled_course_ids) + if is_exception: + try: + countdown = 2 ** self.request.retries + self.retry(countdown=countdown, max_retries=3) + except MaxRetriesExceededError: + log.exception( + 'B2C_SUBSCRIPTIONS: Failed to process retry_revoke_subscriptions_verified_access ' + 'for user_id %s and entitlement_uuids %s', + user_id, + revocable_entitlement_uuids + ) + revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, user_id, awarded_cert_course_ids, + revocable_entitlement_uuids) + else: + log.info('B2C_SUBSCRIPTIONS: Entitlements not found for the provided entitlements uuids %s ' + 'for user_id %s duing the retry_revoke_subscriptions_verified_access task', + revocable_entitlement_uuids, + user_id) diff --git a/common/djangoapps/entitlements/utils.py b/common/djangoapps/entitlements/utils.py index a356072b581..f098c405415 100644 --- a/common/djangoapps/entitlements/utils.py +++ b/common/djangoapps/entitlements/utils.py @@ -10,6 +10,7 @@ from common.djangoapps.course_modes.models import CourseMode from common.djangoapps.student.models import CourseEnrollment from openedx.core.djangoapps.content.course_overviews.models import CourseOverview +from openedx.core.djangoapps.enrollments.api import update_enrollment log = logging.getLogger("common.entitlements.utils") @@ -58,3 +59,37 @@ def is_course_run_entitlement_fulfillable( can_upgrade = unexpired_paid_modes and entitlement.mode in unexpired_paid_modes return course_overview.start and can_upgrade and (is_enrolled or can_enroll) + + +def revoke_entitlements_and_downgrade_courses_to_audit(course_entitlements, user_id, awarded_cert_course_ids, + revocable_entitlement_uuids): + """ + This method expires the entitlements for provided course_entitlements and also moves the enrollments + to audit for the course entitlements which have not been completed yet(not a part of the provided exclusion_list). + """ + + log.info('B2C_SUBSCRIPTIONS: Starting revoke_entitlements_and_downgrade_courses_to_audit for ' + 'user: %s and course_entitlements_uuids: %s', + user_id, + revocable_entitlement_uuids) + for course_entitlement in course_entitlements: + if course_entitlement.enrollment_course_run is None: + if course_entitlement.expired_at is None: + course_entitlement.expire_entitlement() + elif course_entitlement.enrollment_course_run.course_id not in awarded_cert_course_ids: + course_id = course_entitlement.enrollment_course_run.course_id + enrollment_mode = course_entitlement.enrollment_course_run.mode + username = course_entitlement.enrollment_course_run.user.username + if enrollment_mode == CourseMode.VERIFIED: + course_entitlement.set_enrollment(None) + if course_entitlement.expired_at is None: + course_entitlement.expire_entitlement() + update_enrollment(username, str(course_id), CourseMode.AUDIT, include_expired=True) + else: + log.warning('B2C_SUBSCRIPTIONS: Enrollment mode mismatch for user_id: %s and course_id: %s', + user_id, + course_id) + log.info('B2C_SUBSCRIPTIONS: Completed revoke_entitlements_and_downgrade_courses_to_audit for ' + 'user: %s and course_entitlements_uuids %s', + user_id, + revocable_entitlement_uuids) diff --git a/openedx/core/djangoapps/credentials/utils.py b/openedx/core/djangoapps/credentials/utils.py index bdc4a0fc3f2..e060cf8fe1a 100644 --- a/openedx/core/djangoapps/credentials/utils.py +++ b/openedx/core/djangoapps/credentials/utils.py @@ -125,8 +125,8 @@ def get_courses_completion_status(lms_user_id, course_run_ids): log.warning('%s configuration is disabled.', credential_configuration.API_NAME) return [], False - base_api_url = get_credentials_api_base_url() - completion_status_url = f'{base_api_url}/api/credentials/learner_cert_status' + completion_status_url = (f'{settings.CREDENTIALS_INTERNAL_SERVICE_URL}/api' + '/credentials/v1/learner_cert_status/') try: api_client = get_credentials_api_client( User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME)