feat: Add new entitlement expiration endpoint (#32677)
* feat: add new entitlements expiration endpoint
This commit is contained in:
committed by
GitHub
parent
5379daf83e
commit
7fe5229bbb
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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'
|
||||
)
|
||||
]
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user