feat: Add new entitlement expiration endpoint (#32677)

* feat: add new entitlements expiration endpoint
This commit is contained in:
Mohammad Ahtasham ul Hassan
2023-07-13 13:38:20 +05:00
committed by GitHub
parent 5379daf83e
commit 7fe5229bbb
7 changed files with 326 additions and 10 deletions

View File

@@ -4,6 +4,7 @@ requiring Superuser access for all other Request types on an API endpoint.
"""
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

View File

@@ -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.content.course_overviews.tests.factories import Cou
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 @@ class EntitlementEnrollmentViewSetTest(ModuleStoreTestCase):
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))

View File

@@ -3,10 +3,10 @@ URLs for the V1 of the Entitlements API.
"""
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 @@ urlpatterns = [
fr'entitlements/(?P<uuid>{EntitlementViewSet.ENTITLEMENT_UUID4_REGEX})/enrollments$',
ENROLLMENTS_VIEW,
name='enrollments'
),
path(
'subscriptions/entitlements/revoke',
SubscriptionsRevokeVerifiedAccessView.as_view(),
name='revoke_subscriptions_verified_access'
)
]

View File

@@ -15,6 +15,7 @@ from opaque_keys.edx.keys import CourseKey
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 @@ from common.djangoapps.entitlements.models import ( # lint-amnesty, pylint: dis
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 @@ class EntitlementEnrollmentViewSet(viewsets.GenericViewSet):
})
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)

View File

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

View File

@@ -10,6 +10,7 @@ from django.utils import timezone
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)

View File

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