FC-0001: Remove old EdxRestAPIClient usage across the platform (#30301)

* refactor: remove EdxRestAPIClient

* test: update tests according to EdxRestAPIClient removal

* fix: remove unused import
This commit is contained in:
Eugene Dyudyunov
2022-05-09 19:48:26 +03:00
committed by GitHub
parent baf1cbc6fb
commit 289e682b8f
36 changed files with 869 additions and 698 deletions

View File

@@ -2,15 +2,14 @@
import logging
from urllib.parse import urljoin
from django.conf import settings
from django.utils.translation import gettext_lazy as _
from urllib.parse import urljoin # lint-amnesty, pylint: disable=wrong-import-order
from requests.exceptions import ConnectionError, Timeout # pylint: disable=redefined-builtin
from slumber.exceptions import SlumberBaseException
from requests.exceptions import RequestException
from common.djangoapps.course_modes.models import CourseMode
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
DISPLAY_VERIFIED = "verified"
DISPLAY_HONOR = "honor"
@@ -89,11 +88,17 @@ def get_course_final_price(user, sku, course_price):
"""
price_details = {}
try:
price_details = ecommerce_api_client(user).baskets.calculate.get(
sku=[sku],
username=user.username,
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", "baskets/calculate/")
response = get_ecommerce_api_client(user).get(
api_url,
params={
"sku": [sku],
"username": user.username,
}
)
except (SlumberBaseException, ConnectionError, Timeout) as exc:
response.raise_for_status()
price_details = response.json()
except RequestException as exc:
LOGGER.info(
'[e-commerce calculate endpoint] Exception raise for sku [%s] - user [%s] and exception: %s',
sku,

View File

@@ -11,8 +11,6 @@ file and check it in at the same time as your model changes. To do that,
3. Add the migration file created in edx-platform/common/djangoapps/student/migrations/
"""
import crum
import hashlib # lint-amnesty, pylint: disable=wrong-import-order
import json # lint-amnesty, pylint: disable=wrong-import-order
import logging # lint-amnesty, pylint: disable=wrong-import-order
@@ -21,8 +19,9 @@ from collections import defaultdict, namedtuple # lint-amnesty, pylint: disable
from datetime import date, datetime, timedelta # lint-amnesty, pylint: disable=wrong-import-order
from functools import total_ordering # lint-amnesty, pylint: disable=wrong-import-order
from importlib import import_module # lint-amnesty, pylint: disable=wrong-import-order
from urllib.parse import urlencode # lint-amnesty, pylint: disable=wrong-import-order
from urllib.parse import urlencode, urljoin
import crum
from config_models.models import ConfigurationModel
from django.apps import apps
from django.conf import settings
@@ -37,42 +36,35 @@ from django.db.models import Count, Index, Q
from django.db.models.signals import post_save, pre_save
from django.db.utils import ProgrammingError
from django.dispatch import receiver
from django.utils.functional import cached_property
from django.utils.translation import gettext_lazy as _
from django.utils.translation import gettext_noop
from django_countries.fields import CountryField
from edx_django_utils.cache import RequestCache, TieredCache, get_cache_key
from edx_django_utils import monitoring
from edx_rest_api_client.exceptions import SlumberBaseException
from edx_django_utils.cache import RequestCache, TieredCache, get_cache_key
from eventtracking import tracker
from model_utils.models import TimeStampedModel
from opaque_keys.edx.django.models import CourseKeyField, LearningContextKeyField
from opaque_keys.edx.keys import CourseKey
from pytz import UTC, timezone
from simple_history.models import HistoricalRecords
from slumber.exceptions import HttpClientError, HttpServerError
from user_util import user_util
from openedx_events.learning.data import (
CourseData,
CourseEnrollmentData,
UserData,
UserPersonalData,
)
from openedx_events.learning.data import CourseData, CourseEnrollmentData, UserData, UserPersonalData
from openedx_events.learning.signals import (
COURSE_ENROLLMENT_CHANGED,
COURSE_ENROLLMENT_CREATED,
COURSE_UNENROLLMENT_COMPLETED,
)
from openedx_filters.learning.filters import CourseEnrollmentStarted, CourseUnenrollmentStarted
from pytz import UTC, timezone
from requests.exceptions import HTTPError, RequestException
from simple_history.models import HistoricalRecords
from user_util import user_util
import openedx.core.djangoapps.django_comment_common.comment_client as cc
from common.djangoapps.course_modes.models import CourseMode, get_cosmetic_verified_display_price
from common.djangoapps.student.emails import send_proctoring_requirements_email
from common.djangoapps.student.email_helpers import (
generate_proctoring_requirements_email_context,
should_send_proctoring_requirements_email
should_send_proctoring_requirements_email,
)
from common.djangoapps.student.emails import send_proctoring_requirements_email
from common.djangoapps.student.signals import ENROLL_STATUS_CHANGE, ENROLLMENT_TRACK_UPDATED, UNENROLL_DONE
from common.djangoapps.track import contexts, segment
from common.djangoapps.util.model_utils import emit_field_changed_events, get_changed_fields_dict
@@ -1972,6 +1964,7 @@ class CourseEnrollment(models.Model):
# Due to circular import issues this import was placed close to usage. To move this to the
# top of the file would require a large scale refactor of the refund code.
import lms.djangoapps.certificates.api
# If the student has already been given a certificate in a non refundable status they should not be refunded
certificate = lms.djangoapps.certificates.api.get_certificate_for_user_id(
self.user,
@@ -2052,7 +2045,7 @@ class CourseEnrollment(models.Model):
"""
# NOTE: This is here to avoid circular references
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
order_number = self.get_order_attribute_value('order_number')
if not order_number:
return None
@@ -2065,23 +2058,21 @@ class CourseEnrollment(models.Model):
else:
try:
# response is not cached, so make a call to ecommerce to fetch order details
order = ecommerce_api_client(self.user).orders(order_number).get()
except HttpClientError:
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", f"orders/{order_number}/")
response = get_ecommerce_api_client(self.user).get(api_url)
response.raise_for_status()
order = response.json()
except HTTPError as err:
log.warning(
"Encountered HttpClientError while getting order details from ecommerce. "
"Order={number} and user {user}".format(number=order_number, user=self.user.id))
"Encountered HTTPError while getting order details from ecommerce. "
"Status code was %d, Order=%s and user %s", err.response.status_code, order_number, self.user.id
)
return None
except HttpServerError:
log.warning(
"Encountered HttpServerError while getting order details from ecommerce. "
"Order={number} and user {user}".format(number=order_number, user=self.user.id))
return None
except SlumberBaseException:
except RequestException:
log.warning(
"Encountered an error while getting order details from ecommerce. "
"Order={number} and user {user}".format(number=order_number, user=self.user.id))
"Order=%s and user %s", order_number, self.user.id
)
return None
cache_time_out = getattr(settings, 'ECOMMERCE_ORDERS_API_CACHE_TIMEOUT', 3600)

View File

@@ -235,7 +235,7 @@ class RefundableTest(SharedModuleStoreTestCase):
)
assert self.enrollment.is_order_voucher_refundable() is False
@patch('openedx.core.djangoapps.commerce.utils.ecommerce_api_client')
@patch('openedx.core.djangoapps.commerce.utils.get_ecommerce_api_client')
def test_get_order_attribute_from_ecommerce(self, mock_ecommerce_api_client):
"""
Assert that the get_order_attribute_from_ecommerce method returns order details if it's already cached,
@@ -254,7 +254,7 @@ class RefundableTest(SharedModuleStoreTestCase):
assert self.enrollment.get_order_attribute_from_ecommerce("vouchers") == order_details["vouchers"]
mock_ecommerce_api_client.assert_not_called()
@patch('openedx.core.djangoapps.commerce.utils.ecommerce_api_client')
@patch('openedx.core.djangoapps.commerce.utils.get_ecommerce_api_client')
def test_refund_cutoff_date_with_date_placed_attr(self, mock_ecommerce_api_client):
"""
Assert that the refund_cutoff_date returns order placement date if order:date_placed

View File

@@ -2,12 +2,13 @@
import logging
from urllib.parse import urljoin
from django.urls import reverse
from edx_rest_api_client import exceptions
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from opaque_keys import InvalidKeyError
from opaque_keys.edx.keys import CourseKey
from requests.exceptions import HTTPError
from rest_framework.authentication import SessionAuthentication
from rest_framework.permissions import IsAuthenticated
from rest_framework.status import HTTP_406_NOT_ACCEPTABLE, HTTP_409_CONFLICT
@@ -18,7 +19,7 @@ from common.djangoapps.entitlements.models import CourseEntitlement
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.util.json_request import JsonResponse
from lms.djangoapps.courseware import courses
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
from openedx.core.djangoapps.embargo import api as embargo_api
from openedx.core.djangoapps.enrollments.api import add_enrollment
from openedx.core.djangoapps.enrollments.views import EnrollmentCrossDomainSessionAuth
@@ -159,15 +160,23 @@ class BasketsView(APIView):
class BasketOrderView(APIView):
""" Retrieve the order associated with a basket. """
"""
Retrieve the order associated with a basket.
"""
authentication_classes = (SessionAuthentication,)
permission_classes = (IsAuthenticated,)
def get(self, request, *_args, **kwargs):
""" HTTP handler. """
"""
HTTP handler.
"""
try:
order = ecommerce_api_client(request.user).baskets(kwargs['basket_id']).order.get()
return JsonResponse(order)
except exceptions.HttpNotFoundError:
return JsonResponse(status=404)
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", f"baskets/{kwargs['basket_id']}/order/")
response = get_ecommerce_api_client(request.user).get(api_url)
response.raise_for_status()
return JsonResponse(response.json())
except HTTPError as err:
if err.response.status_code == 404:
return JsonResponse(status=404)
raise

View File

@@ -4,11 +4,12 @@ Commerce views
import logging
from urllib.parse import urljoin
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.http import Http404
from edx_rest_api_client import exceptions
from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication
from requests.exceptions import HTTPError
from rest_framework.authentication import SessionAuthentication
from rest_framework.generics import ListAPIView, RetrieveUpdateAPIView
from rest_framework.permissions import IsAuthenticated
@@ -16,7 +17,7 @@ from rest_framework.views import APIView
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.util.json_request import JsonResponse
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
from openedx.core.lib.api.authentication import BearerAuthentication
from openedx.core.lib.api.mixins import PutAsCreateMixin
@@ -84,7 +85,11 @@ class OrderView(APIView):
except User.DoesNotExist:
return JsonResponse(status=403)
try:
order = ecommerce_api_client(request.user).orders(number).get()
return JsonResponse(order)
except exceptions.HttpNotFoundError:
return JsonResponse(status=404)
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", f"orders/{number}/")
response = get_ecommerce_api_client(request.user).get(api_url)
response.raise_for_status()
return JsonResponse(response.json())
except HTTPError as err:
if err.response.status_code == 404:
return JsonResponse(status=404)
raise

View File

@@ -8,18 +8,18 @@ Management command to
import time
import traceback
from textwrap import dedent
from urllib.parse import urljoin
from django.conf import settings
from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError
from enterprise.models import EnterpriseCourseEnrollment
from opaque_keys.edx.keys import CourseKey
from requests import Timeout
from slumber.exceptions import HttpServerError, SlumberBaseException
from requests.exceptions import RequestException
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.util.query import use_read_replica_if_available
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
User = get_user_model()
@@ -33,7 +33,7 @@ class Command(BaseCommand):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
self.client = ecommerce_api_client(service_user)
self.client = get_ecommerce_api_client(service_user)
def _get_enrollments_queryset(self, start_index, end_index):
"""
@@ -60,15 +60,14 @@ class Command(BaseCommand):
Returns (success_count, fail_count)
"""
try:
order_response = self.client.manual_course_enrollment_order.post(
{
"enrollments": enrollments
}
)
except (SlumberBaseException, ConnectionError, Timeout, HttpServerError) as exc:
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", "manual_course_enrollment_order/")
response = self.client.post(api_url, data={"enrollments": enrollments})
response.raise_for_status()
order_response = response.json()
except RequestException as exc:
self.stderr.write(
"\t\t\tFailed to create order for manual enrollments for the following enrollments: {}. Reason: {}"
.format(enrollments, exc)
"\t\t\tFailed to create order for manual enrollments for the following "
f"enrollments: {enrollments}. Reason: {exc}"
)
return 0, 0, len(enrollments), []

View File

@@ -2,6 +2,7 @@
from unittest import mock
from urllib.parse import urljoin
import httpretty
from django.conf import settings
@@ -9,7 +10,7 @@ from django.test import TestCase
from freezegun import freeze_time
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
JSON = 'application/json'
@@ -25,8 +26,9 @@ TEST_PAYMENT_DATA = {
class EdxRestApiClientTest(TestCase):
""" Tests to ensure the client is initialized properly. """
"""
Tests to ensure the client is initialized properly.
"""
SCOPES = [
'user_id',
'email',
@@ -36,18 +38,18 @@ class EdxRestApiClientTest(TestCase):
def setUp(self):
super().setUp()
self.user = UserFactory()
self.base_url = get_ecommerce_api_base_url()
@httpretty.activate
def test_tracking_context(self):
"""
Ensure the tracking context is set up in the api client correctly and
automatically.
Ensure the tracking context is set up in the api client correctly and automatically.
"""
with freeze_time('2015-7-2'):
# fake an E-Commerce API request.
httpretty.register_uri(
httpretty.POST,
'{}/baskets/1/'.format(settings.ECOMMERCE_API_URL.strip('/')),
f"{settings.ECOMMERCE_API_URL.strip('/')}/baskets/1/",
status=200, body='{}',
adding_headers={'Content-Type': JSON}
)
@@ -55,7 +57,8 @@ class EdxRestApiClientTest(TestCase):
mock_tracker = mock.Mock()
mock_tracker.resolve_context = mock.Mock(return_value={'ip': '127.0.0.1'})
with mock.patch('openedx.core.djangoapps.commerce.utils.tracker.get_tracker', return_value=mock_tracker):
ecommerce_api_client(self.user).baskets(1).post()
api_url = urljoin(f"{self.base_url}/", "baskets/1/")
get_ecommerce_api_client(self.user).post(api_url)
# Verify the JWT includes the tracking context for the user
actual_header = httpretty.last_request().headers['Authorization']
@@ -73,17 +76,17 @@ class EdxRestApiClientTest(TestCase):
@httpretty.activate
def test_client_unicode(self):
"""
The client should handle json responses properly when they contain
unicode character data.
The client should handle json responses properly when they contain unicode character data.
Regression test for ECOM-1606.
"""
expected_content = '{"result": "Préparatoire"}'
httpretty.register_uri(
httpretty.GET,
'{}/baskets/1/order/'.format(settings.ECOMMERCE_API_URL.strip('/')),
f"{settings.ECOMMERCE_API_URL.strip('/')}/baskets/1/order/",
status=200, body=expected_content,
adding_headers={'Content-Type': JSON},
)
actual_object = ecommerce_api_client(self.user).baskets(1).order.get()
api_url = urljoin(f"{self.base_url}/", "baskets/1/order/")
actual_object = get_ecommerce_api_client(self.user).get(api_url).json()
assert actual_object == {'result': 'Préparatoire'}

View File

@@ -14,8 +14,11 @@ from django.utils.translation import gettext as _
from opaque_keys.edx.keys import CourseKey
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.student.models import CourseEnrollment # lint-amnesty, pylint: disable=unused-import
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client, is_commerce_service_configured
from openedx.core.djangoapps.commerce.utils import (
get_ecommerce_api_base_url,
get_ecommerce_api_client,
is_commerce_service_configured,
)
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers
@@ -164,7 +167,7 @@ def refund_entitlement(course_entitlement):
return False
service_user = user_model.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = ecommerce_api_client(service_user)
api_client = get_ecommerce_api_client(service_user)
log.info(
'Attempting to create a refund for user [%s], course entitlement [%s]...',
@@ -173,13 +176,17 @@ def refund_entitlement(course_entitlement):
)
try:
refund_ids = api_client.refunds.post(
{
refunds_url = urljoin(f"{get_ecommerce_api_base_url()}/", "refunds/")
refunds_response = api_client.post(
refunds_url,
data={
'order_number': course_entitlement.order_number,
'username': enrollee.username,
'entitlement_uuid': entitlement_uuid,
}
)
refunds_response.raise_for_status()
refund_ids = refunds_response.json()
except Exception as exc: # pylint: disable=broad-except
# Catch any possible exceptions from the Ecommerce service to ensure we fail gracefully
log.exception(
@@ -225,19 +232,26 @@ def refund_seat(course_enrollment, change_mode=False):
(may be empty).
Raises:
exceptions.SlumberBaseException: for any unhandled HTTP error during communication with the E-Commerce Service.
exceptions.Timeout: if the attempt to reach the commerce service timed out.
requests.exceptions.RequestException: for any unhandled HTTP error during communication with
the E-Commerce Service.
requests.exceptions.Timeout: if the attempt to reach the commerce service timed out.
"""
User = get_user_model() # pylint:disable=invalid-name
course_key_str = str(course_enrollment.course_id)
enrollee = course_enrollment.user
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
api_client = ecommerce_api_client(service_user)
api_client = get_ecommerce_api_client(service_user)
log.info('Attempting to create a refund for user [%s], course [%s]...', enrollee.id, course_key_str)
refund_ids = api_client.refunds.post({'course_id': course_key_str, 'username': enrollee.username})
refunds_url = urljoin(f"{get_ecommerce_api_base_url()}/", "refunds/")
refunds_response = api_client.post(
refunds_url,
data={'course_id': course_key_str, 'username': enrollee.username}
)
refunds_response.raise_for_status()
refund_ids = refunds_response.json() if refunds_response.content else None
if refund_ids:
log.info('Refund successfully opened for user [%s], course [%s]: %r', enrollee.id, course_key_str, refund_ids)
@@ -284,7 +298,10 @@ def _process_refund(refund_ids, api_client, mode, user, always_notify=False):
# NOTE: The following assumes that the user has already been unenrolled.
# We are then able to approve payment. Additionally, this ensures we don't tie up an
# additional web worker when the E-Commerce Service tries to unenroll the learner.
api_client.refunds(refund_id).process.put({'action': 'approve_payment_only'})
base_url = get_ecommerce_api_base_url()
api_url = urljoin(f"{base_url}/", f"refunds/{refund_id}/process/")
response = api_client.put(api_url, json={'action': 'approve_payment_only'})
response.raise_for_status()
log.info('Refund [%d] successfully approved.', refund_id)
except: # pylint: disable=bare-except
# Push the refund to Support to process

View File

@@ -6,6 +6,7 @@ import decimal
import json
import logging
import urllib
from urllib.parse import urljoin
from django.conf import settings
from django.contrib.auth.decorators import login_required
@@ -21,10 +22,11 @@ from django.utils.translation import gettext_lazy
from django.views.decorators.csrf import csrf_exempt
from django.views.decorators.http import require_POST
from django.views.generic.base import View
from edx_rest_api_client.exceptions import SlumberBaseException
from opaque_keys.edx.keys import CourseKey
from requests.exceptions import RequestException
from rest_framework.response import Response
from rest_framework.views import APIView
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.edxmako.shortcuts import render_to_response
@@ -39,11 +41,10 @@ from lms.djangoapps.verify_student.image import InvalidImageData, decode_image_d
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification, VerificationDeadline
from lms.djangoapps.verify_student.tasks import send_verification_status_email
from lms.djangoapps.verify_student.utils import can_verify_now
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
from openedx.core.djangoapps.embargo import api as embargo_api
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.lib.log_utils import audit_log
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
from .services import IDVerificationService
@@ -386,7 +387,10 @@ class PayAndVerifyView(View):
verification_good_until = self._verification_valid_until(request.user)
# get available payment processors
processors = ecommerce_api_client(request.user).payment.processors.get()
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", "payment/processors/")
response = get_ecommerce_api_client(request.user).get(api_url)
response.raise_for_status()
processors = response.json()
# Render the top-level page
context = {
@@ -706,20 +710,28 @@ class PayAndVerifyView(View):
def checkout_with_ecommerce_service(user, course_key, course_mode, processor):
""" Create a new basket and trigger immediate checkout, using the E-Commerce API. """
"""
Create a new basket and trigger immediate checkout, using the E-Commerce API.
"""
course_id = str(course_key)
try:
api = ecommerce_api_client(user)
api_client = get_ecommerce_api_client(user)
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", "baskets/")
# Make an API call to create the order and retrieve the results
result = api.baskets.post({
'products': [{'sku': course_mode.sku}],
'checkout': True,
'payment_processor_name': processor
})
response = api_client.post(
api_url,
json={
'products': [{'sku': course_mode.sku}],
'checkout': True,
'payment_processor_name': processor
}
)
response.raise_for_status()
result = response.json()
# Pass the payment parameters directly from the API response.
return result.get('payment_data')
except SlumberBaseException:
except RequestException:
params = {'username': user.username, 'mode': course_mode.slug, 'course_id': course_id}
log.exception('Failed to create order for %(username)s %(mode)s mode of %(course_id)s', params)
raise

View File

@@ -4507,7 +4507,7 @@ ENTERPRISE_INTEGRATIONS_EMAIL = "enterprise-integrations@edx.org"
INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT = {}
############## ENTERPRISE SERVICE API CLIENT CONFIGURATION ######################
# The LMS communicates with the Enterprise service via the EdxRestApiClient class
# The LMS communicates with the Enterprise service via the requests.Session() client
# These default settings are utilized by the LMS when interacting with the service,
# and are overridden by the configuration parameter accessors defined in production.py

View File

@@ -881,7 +881,7 @@ INTEGRATED_CHANNELS_API_CHUNK_TRANSMISSION_LIMIT = ENV_TOKENS.get(
)
############## ENTERPRISE SERVICE API CLIENT CONFIGURATION ######################
# The LMS communicates with the Enterprise service via the EdxRestApiClient class
# The LMS communicates with the Enterprise service via the requests.Session() client
# The below environmental settings are utilized by the LMS when interacting with
# the service, and override the default parameters which are defined in common.py

View File

@@ -1,7 +1,11 @@
"""Views for API management."""
"""
Views for API management.
"""
import logging
from functools import cached_property
from urllib.parse import urljoin
from django.conf import settings
from django.contrib.sites.shortcuts import get_current_site
@@ -14,13 +18,14 @@ from django.views.generic.edit import CreateView
from oauth2_provider.generators import generate_client_id, generate_client_secret
from oauth2_provider.models import get_application_model
from oauth2_provider.views import ApplicationRegistration
from slumber.exceptions import HttpNotFoundError
from requests.exceptions import HTTPError
from common.djangoapps.edxmako.shortcuts import render_to_response
from openedx.core.djangoapps.api_admin.decorators import require_api_access
from openedx.core.djangoapps.api_admin.forms import ApiAccessRequestForm, CatalogForm
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest, Catalog
from openedx.core.djangoapps.catalog.utils import create_catalog_api_client
from openedx.core.djangoapps.catalog.utils import get_catalog_api_base_url
from openedx.core.djangoapps.catalog.utils import get_catalog_api_client as create_catalog_api_client
log = logging.getLogger(__name__)
@@ -122,9 +127,29 @@ class ApiTosView(TemplateView):
class CatalogApiMixin:
"""
Helpers for work with Catalog API.
"""
def get_catalog_api_client(self, user):
"""
Returns catalog API client.
"""
return create_catalog_api_client(user)
@cached_property
def catalogs_api_url(self):
"""
Returns the catalogs URL for the catalog API.
"""
return urljoin(f"{get_catalog_api_base_url()}/", "catalogs/")
@cached_property
def courses_api_url(self):
"""
Returns the courses URL for the catalog API.
"""
return urljoin(f"{get_catalog_api_base_url()}/", "courses/")
class CatalogSearchView(View):
"""View to search for catalogs belonging to a user."""
@@ -143,89 +168,115 @@ class CatalogSearchView(View):
class CatalogListView(CatalogApiMixin, View):
"""View to list existing catalogs and create new ones."""
"""
View to list existing catalogs and create new ones.
"""
template = 'api_admin/catalogs/list.html'
def _get_catalogs(self, client, username):
"""Retrieve catalogs for a user. Returns the empty list if none are found."""
"""
Retrieve catalogs for a user. Returns the empty list if none are found.
"""
try:
response = client.catalogs.get(username=username)
return [Catalog(attributes=catalog) for catalog in response['results']]
except HttpNotFoundError:
return []
response = client.get(self.catalogs_api_url, params={"username": username})
response.raise_for_status()
return [Catalog(attributes=catalog) for catalog in response.json()['results']]
except HTTPError as err:
if err.response.status_code == 404:
return []
else:
raise
def get_context_data(self, client, username, form):
""" Retrieve context data for the template. """
"""
Retrieve context data for the template.
"""
return {
'username': username,
'catalogs': self._get_catalogs(client, username),
'form': form,
'preview_url': reverse('api_admin:catalog-preview'),
'catalog_api_catalog_endpoint': client.catalogs.url().rstrip('/'),
'catalog_api_url': client.courses.url(),
'catalog_api_catalog_endpoint': self.catalogs_api_url,
'catalog_api_url': self.courses_api_url,
}
def get(self, request, username):
"""Display a list of a user's catalogs."""
"""
Display a list of a user's catalogs.
"""
client = self.get_catalog_api_client(request.user)
form = CatalogForm(initial={'viewers': [username]})
return render_to_response(self.template, self.get_context_data(client, username, form))
def post(self, request, username):
"""Create a new catalog for a user."""
"""
Create a new catalog for a user.
"""
form = CatalogForm(request.POST)
client = self.get_catalog_api_client(request.user)
if not form.is_valid():
return render_to_response(self.template, self.get_context_data(client, username, form), status=400)
attrs = form.cleaned_data
catalog = client.catalogs.post(attrs)
response = client.post(self.catalogs_api_url, data=attrs)
response.raise_for_status()
catalog = response.json()
return redirect(reverse('api_admin:catalog-edit', kwargs={'catalog_id': catalog['id']}))
class CatalogEditView(CatalogApiMixin, View):
"""View to edit an individual catalog."""
"""
View to edit an individual catalog.
"""
template_name = 'api_admin/catalogs/edit.html'
def get_context_data(self, catalog, form, client):
""" Retrieve context data for the template. """
def get_context_data(self, catalog, form):
"""
Retrieve context data for the template.
"""
return {
'catalog': catalog,
'form': form,
'preview_url': reverse('api_admin:catalog-preview'),
'catalog_api_url': client.courses.url(),
'catalog_api_catalog_endpoint': client.catalogs.url().rstrip('/'),
'catalog_api_url': self.catalogs_api_url,
'catalog_api_catalog_endpoint': self.courses_api_url,
}
def get(self, request, catalog_id):
"""Display a form to edit this catalog."""
"""
Display a form to edit this catalog.
"""
client = self.get_catalog_api_client(request.user)
response = client.catalogs(catalog_id).get()
catalog = Catalog(attributes=response)
response = client.get(urljoin(f"{self.catalogs_api_url}/", f"{catalog_id}/"))
response.raise_for_status()
catalog = Catalog(attributes=response.json())
form = CatalogForm(instance=catalog)
return render_to_response(self.template_name, self.get_context_data(catalog, form, client))
return render_to_response(self.template_name, self.get_context_data(catalog, form))
def post(self, request, catalog_id):
"""Update or delete this catalog."""
"""
Update or delete this catalog.
"""
client = self.get_catalog_api_client(request.user)
if request.POST.get('delete-catalog') == 'on':
client.catalogs(catalog_id).delete()
response = client.delete(urljoin(f"{self.catalogs_api_url}/", f"{catalog_id}/"))
response.raise_for_status()
return redirect(reverse('api_admin:catalog-search'))
form = CatalogForm(request.POST)
if not form.is_valid():
response = client.catalogs(catalog_id).get()
catalog = Catalog(attributes=response)
return render_to_response(self.template_name, self.get_context_data(catalog, form, client), status=400)
catalog = client.catalogs(catalog_id).patch(form.cleaned_data)
return redirect(reverse('api_admin:catalog-edit', kwargs={'catalog_id': catalog['id']}))
response = client.get(urljoin(f"{self.catalogs_api_url}/", f"{catalog_id}/"))
response.raise_for_status()
catalog = Catalog(attributes=response.json())
return render_to_response(self.template_name, self.get_context_data(catalog, form), status=400)
catalog_response = client.patch(urljoin(f"{self.catalogs_api_url}/", f"{catalog_id}/"), data=form.cleaned_data)
catalog_response.raise_for_status()
return redirect(reverse('api_admin:catalog-edit', kwargs={'catalog_id': catalog_response.json()['id']}))
class CatalogPreviewView(CatalogApiMixin, View):
"""Endpoint to preview courses for a query."""
"""
Endpoint to preview courses for a query.
"""
def get(self, request):
"""
@@ -235,7 +286,9 @@ class CatalogPreviewView(CatalogApiMixin, View):
client = self.get_catalog_api_client(request.user)
# Just pass along the request params including limit/offset pagination
if 'q' in request.GET:
results = client.courses.get(**request.GET)
response = client.get(self.courses_api_url, params=request.GET)
response.raise_for_status()
results = response.json()
# Ensure that we don't just return all the courses if no query is given
else:
results = {'count': 0, 'results': [], 'next': None, 'prev': None}

View File

@@ -4,6 +4,7 @@
import logging
import sys
from collections import defaultdict
from urllib.parse import urljoin
from django.contrib.auth import get_user_model
from django.contrib.sites.models import Site
@@ -11,8 +12,8 @@ from django.core.cache import cache
from django.core.management import BaseCommand
from openedx.core.djangoapps.catalog.cache import (
COURSE_PROGRAMS_CACHE_KEY_TPL,
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL,
COURSE_PROGRAMS_CACHE_KEY_TPL,
PATHWAY_CACHE_KEY_TPL,
PROGRAM_CACHE_KEY_TPL,
PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL,
@@ -25,7 +26,8 @@ from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.catalog.utils import (
course_run_keys_for_program,
course_uuids_for_program,
create_catalog_api_client,
get_catalog_api_base_url,
get_catalog_api_client,
normalize_program_type
)
@@ -74,10 +76,11 @@ class Command(BaseCommand):
cache.set(SITE_PATHWAY_IDS_CACHE_KEY_TPL.format(domain=site.domain), [], None)
continue
client = create_catalog_api_client(user, site=site)
uuids, program_uuids_failed = self.get_site_program_uuids(client, site)
new_programs, program_details_failed = self.fetch_program_details(client, uuids)
new_pathways, pathways_failed = self.get_pathways(client, site)
client = get_catalog_api_client(user)
api_base_url = get_catalog_api_base_url(site=site)
uuids, program_uuids_failed = self.get_site_program_uuids(client, site, api_base_url)
new_programs, program_details_failed = self.fetch_program_details(client, uuids, api_base_url)
new_pathways, pathways_failed = self.get_pathways(client, site, api_base_url)
new_pathways, new_programs, pathway_processing_failed = self.process_pathways(
site, new_pathways, new_programs
)
@@ -134,7 +137,7 @@ class Command(BaseCommand):
if failure:
sys.exit(1)
def get_site_program_uuids(self, client, site): # lint-amnesty, pylint: disable=missing-function-docstring
def get_site_program_uuids(self, client, site, api_base_url): # lint-amnesty, pylint: disable=missing-function-docstring
failure = False
uuids = []
try:
@@ -143,9 +146,11 @@ class Command(BaseCommand):
'status': ('active', 'retired'),
'uuids_only': 1,
}
api_url = urljoin(f"{api_base_url}/", "programs/")
logger.info(f'Requesting program UUIDs for {site.domain}.')
uuids = client.programs.get(**querystring)
response = client.get(api_url, params=querystring)
response.raise_for_status()
uuids = response.json()
except: # pylint: disable=bare-except
logger.exception(f'Failed to retrieve program UUIDs for site: {site.domain}.')
failure = True
@@ -156,14 +161,17 @@ class Command(BaseCommand):
))
return uuids, failure
def fetch_program_details(self, client, uuids): # lint-amnesty, pylint: disable=missing-function-docstring
def fetch_program_details(self, client, uuids, api_base_url): # lint-amnesty, pylint: disable=missing-function-docstring
programs = {}
failure = False
for uuid in uuids:
try:
cache_key = PROGRAM_CACHE_KEY_TPL.format(uuid=uuid)
logger.info(f'Requesting details for program {uuid}.')
program = client.programs(uuid).get(exclude_utm=1)
api_url = urljoin(f"{api_base_url}/", f"programs/{uuid}/")
response = client.get(api_url, params={"exclude_utm": 1})
response.raise_for_status()
program = response.json()
# pathways get added in process_pathways
program['pathway_ids'] = []
programs[cache_key] = program
@@ -173,20 +181,22 @@ class Command(BaseCommand):
continue
return programs, failure
def get_pathways(self, client, site):
def get_pathways(self, client, site, api_base_url):
"""
Get all pathways for the current client
Get all pathways for the current client.
"""
pathways = []
failure = False
logger.info(f'Requesting pathways for {site.domain}.')
try:
api_url = urljoin(f"{api_base_url}/", "pathways/")
next_page = 1
while next_page:
new_pathways = client.pathways.get(exclude_utm=1, page=next_page)
response = client.get(api_url, params=dict(exclude_utm=1, page=next_page))
response.raise_for_status()
new_pathways = response.json()
pathways.extend(new_pathways['results'])
next_page = next_page + 1 if new_pathways['next'] else None
except: # pylint: disable=bare-except
logger.exception(
msg=f'Failed to retrieve pathways for site: {site.domain}.',

View File

@@ -383,7 +383,7 @@ class TestGetPathways(CacheIsolationTestCase):
assert not mock_warning.called
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
@mock.patch(UTILS_MODULE + '.get_api_data')
class TestGetProgramTypes(CatalogIntegrationMixin, TestCase):
"""Tests covering retrieval of program types from the catalog service."""
@override_settings(COURSE_CATALOG_API_URL='https://api.example.com/v1/')
@@ -406,7 +406,7 @@ class TestGetProgramTypes(CatalogIntegrationMixin, TestCase):
assert data == program
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
@mock.patch(UTILS_MODULE + '.get_api_data')
class TestGetCurrency(CatalogIntegrationMixin, TestCase):
"""Tests covering retrieval of currency data from the catalog service."""
@override_settings(COURSE_CATALOG_API_URL='https://api.example.com/v1/')
@@ -451,7 +451,7 @@ class TestGetLocalizedPriceText(TestCase):
@skip_unless_lms
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
@mock.patch(UTILS_MODULE + '.get_api_data')
class TestGetCourseRuns(CatalogIntegrationMixin, CacheIsolationTestCase):
"""
Tests covering retrieval of course runs from the catalog service.
@@ -471,7 +471,7 @@ class TestGetCourseRuns(CatalogIntegrationMixin, CacheIsolationTestCase):
for arg in (self.catalog_integration, 'course_runs'):
assert arg in args
assert kwargs['api']._store['base_url'] == self.catalog_integration.get_internal_api_url() # pylint: disable=protected-access, line-too-long
assert kwargs['base_api_url'] == self.catalog_integration.get_internal_api_url() # pylint: disable=protected-access, line-too-long
querystring = {
'page_size': 20,
@@ -534,7 +534,7 @@ class TestGetCourseRuns(CatalogIntegrationMixin, CacheIsolationTestCase):
@skip_unless_lms
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
@mock.patch(UTILS_MODULE + '.get_api_data')
class TestGetCourseOwners(CatalogIntegrationMixin, TestCase):
"""
Tests covering retrieval of course runs from the catalog service.
@@ -559,7 +559,7 @@ class TestGetCourseOwners(CatalogIntegrationMixin, TestCase):
@skip_unless_lms
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
@mock.patch(UTILS_MODULE + '.get_api_data')
class TestSessionEntitlement(CatalogIntegrationMixin, TestCase):
"""
Test Covering data related Entitlements.
@@ -667,7 +667,7 @@ class TestSessionEntitlement(CatalogIntegrationMixin, TestCase):
@skip_unless_lms
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
@mock.patch(UTILS_MODULE + '.get_api_data')
class TestGetCourseRunDetails(CatalogIntegrationMixin, TestCase):
"""
Tests covering retrieval of information about a specific course run from the catalog service.

View File

@@ -7,19 +7,21 @@ import logging
import uuid
import pycountry
import requests
from django.core.cache import cache
from django.core.exceptions import ObjectDoesNotExist
from edx_rest_api_client.client import EdxRestApiClient
from edx_rest_api_client.auth import SuppliedJwtAuth
from opaque_keys.edx.keys import CourseKey
from pytz import UTC
from common.djangoapps.entitlements.utils import is_course_run_entitlement_fulfillable
from common.djangoapps.student.models import CourseEnrollment
from openedx.core.djangoapps.catalog.cache import (
COURSE_PROGRAMS_CACHE_KEY_TPL,
CATALOG_COURSE_PROGRAMS_CACHE_KEY_TPL,
PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL,
COURSE_PROGRAMS_CACHE_KEY_TPL,
PATHWAY_CACHE_KEY_TPL,
PROGRAM_CACHE_KEY_TPL,
PROGRAMS_BY_ORGANIZATION_CACHE_KEY_TPL,
PROGRAMS_BY_TYPE_CACHE_KEY_TPL,
PROGRAMS_BY_TYPE_SLUG_CACHE_KEY_TPL,
SITE_PATHWAY_IDS_CACHE_KEY_TPL,
@@ -27,24 +29,32 @@ from openedx.core.djangoapps.catalog.cache import (
)
from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.lib.edx_api_utils import get_edx_api_data
from common.djangoapps.student.models import CourseEnrollment
from openedx.core.lib.edx_api_utils import get_api_data
logger = logging.getLogger(__name__)
missing_details_msg_tpl = 'Failed to get details for program {uuid} from the cache.'
def create_catalog_api_client(user, site=None):
"""Returns an API client which can be used to make Catalog API requests."""
jwt = create_jwt_for_user(user)
def get_catalog_api_base_url(site=None):
"""
Returns a base API url used to make Catalog API requests.
"""
if site:
url = site.configuration.get_value('COURSE_CATALOG_API_URL')
else:
url = CatalogIntegration.current().get_internal_api_url()
return site.configuration.get_value('COURSE_CATALOG_API_URL')
return EdxRestApiClient(url, jwt=jwt)
return CatalogIntegration.current().get_internal_api_url()
def get_catalog_api_client(user):
"""
Returns an API client which can be used to make Catalog API requests.
"""
jwt = create_jwt_for_user(user)
client = requests.Session()
client.auth = SuppliedJwtAuth(jwt)
return client
def check_catalog_integration_and_get_user(error_message_field):
@@ -231,11 +241,15 @@ def get_program_types(name=None):
"""
user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Program types')
if user:
api = create_catalog_api_client(user)
cache_key = f'{catalog_integration.CACHE_KEY}.program_types'
data = get_edx_api_data(catalog_integration, 'program_types', api=api,
cache_key=cache_key if catalog_integration.is_cache_enabled else None)
data = get_api_data(
catalog_integration,
"program_types",
api_client=get_catalog_api_client(user),
base_api_url=get_catalog_api_base_url(),
cache_key=cache_key if catalog_integration.is_cache_enabled else None
)
# Filter by name if a name was provided
if name:
@@ -311,11 +325,15 @@ def get_currency_data():
"""
user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Currency data')
if user:
api = create_catalog_api_client(user)
cache_key = f'{catalog_integration.CACHE_KEY}.currency'
return get_edx_api_data(catalog_integration, 'currency', api=api, traverse_pagination=False,
cache_key=cache_key if catalog_integration.is_cache_enabled else None)
return get_api_data(
catalog_integration,
"currency",
api_client=get_catalog_api_client(user),
base_api_url=get_catalog_api_base_url(),
traverse_pagination=False,
cache_key=cache_key if catalog_integration.is_cache_enabled else None)
else:
return []
@@ -410,14 +428,18 @@ def get_course_runs():
course_runs = []
user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course runs')
if user:
api = create_catalog_api_client(user)
querystring = {
'page_size': catalog_integration.page_size,
'exclude_utm': 1,
}
course_runs = get_edx_api_data(catalog_integration, 'course_runs', api=api, querystring=querystring)
course_runs = get_api_data(
catalog_integration,
'course_runs',
api_client=get_catalog_api_client(user),
base_api_url=get_catalog_api_base_url(),
querystring=querystring
)
return course_runs
@@ -425,16 +447,14 @@ def get_course_runs():
def get_course_runs_for_course(course_uuid): # lint-amnesty, pylint: disable=missing-function-docstring
user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course runs')
if user:
api = create_catalog_api_client(user)
cache_key = '{base}.course.{uuid}.course_runs'.format(
base=catalog_integration.CACHE_KEY,
uuid=course_uuid
)
data = get_edx_api_data(
cache_key = f"{catalog_integration.CACHE_KEY}.course.{course_uuid}.course_runs"
data = get_api_data(
catalog_integration,
'courses',
resource_id=course_uuid,
api=api,
api_client=get_catalog_api_client(user),
base_api_url=get_catalog_api_base_url(),
cache_key=cache_key if catalog_integration.is_cache_enabled else None,
long_term_cache=True,
many=False
@@ -447,23 +467,21 @@ def get_course_runs_for_course(course_uuid): # lint-amnesty, pylint: disable=mi
def get_owners_for_course(course_uuid): # lint-amnesty, pylint: disable=missing-function-docstring
user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Owners')
if user:
api = create_catalog_api_client(user)
cache_key = '{base}.course.{uuid}.course_runs'.format(
base=catalog_integration.CACHE_KEY,
uuid=course_uuid
)
data = get_edx_api_data(
cache_key = f"{catalog_integration.CACHE_KEY}.course.{course_uuid}.course_runs"
data = get_api_data(
catalog_integration,
'courses',
resource_id=course_uuid,
api=api,
api_client=get_catalog_api_client(user),
base_api_url=get_catalog_api_base_url(),
cache_key=cache_key if catalog_integration.is_cache_enabled else None,
long_term_cache=True,
many=False
)
return data.get('owners', [])
else:
return []
return []
def get_course_uuid_for_course(course_run_key):
@@ -479,18 +497,17 @@ def get_course_uuid_for_course(course_run_key):
"""
user, catalog_integration = check_catalog_integration_and_get_user(error_message_field='Course UUID')
if user:
api = create_catalog_api_client(user)
api_client = get_catalog_api_client(user)
base_api_url = get_catalog_api_base_url()
run_cache_key = '{base}.course_run.{course_run_key}'.format(
base=catalog_integration.CACHE_KEY,
course_run_key=course_run_key
)
run_cache_key = f"{catalog_integration.CACHE_KEY}.course_run.{course_run_key}"
course_run_data = get_edx_api_data(
course_run_data = get_api_data(
catalog_integration,
'course_runs',
resource_id=str(course_run_key),
api=api,
api_client=api_client,
base_api_url=base_api_url,
cache_key=run_cache_key if catalog_integration.is_cache_enabled else None,
long_term_cache=True,
many=False,
@@ -499,16 +516,14 @@ def get_course_uuid_for_course(course_run_key):
course_key_str = course_run_data.get('course', None)
if course_key_str:
run_cache_key = '{base}.course.{course_key}'.format(
base=catalog_integration.CACHE_KEY,
course_key=course_key_str
)
run_cache_key = f"{catalog_integration.CACHE_KEY}.course.{course_key_str}"
data = get_edx_api_data(
data = get_api_data(
catalog_integration,
'courses',
resource_id=course_key_str,
api=api,
api_client=api_client,
base_api_url=base_api_url,
cache_key=run_cache_key if catalog_integration.is_cache_enabled else None,
long_term_cache=True,
many=False,
@@ -598,12 +613,16 @@ def get_course_run_details(course_run_key, fields):
error_message_field=f'Data for course_run {course_run_key}'
)
if user:
api = create_catalog_api_client(user)
cache_key = f'{catalog_integration.CACHE_KEY}.course_runs'
course_run_details = get_edx_api_data(catalog_integration, 'course_runs', api, resource_id=course_run_key,
cache_key=cache_key, many=False, traverse_pagination=False, fields=fields)
course_run_details = get_api_data(
catalog_integration,
'course_runs',
api_client=get_catalog_api_client(user),
base_api_url=get_catalog_api_base_url(),
resource_id=course_run_key,
cache_key=cache_key, many=False, traverse_pagination=False, fields=fields
)
return course_run_details

View File

@@ -4,7 +4,6 @@
import requests
from django.conf import settings
from edx_rest_api_client.auth import SuppliedJwtAuth
from edx_rest_api_client.client import EdxRestApiClient
from eventtracking import tracker
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
@@ -32,25 +31,11 @@ def is_commerce_service_configured():
return bool(ecommerce_api_url)
def ecommerce_api_client(user, session=None):
def get_ecommerce_api_base_url():
"""
Returns an E-Commerce API client setup with authentication for the specified user.
DEPRECATED: To be replaced with get_ecommerce_api_client as part of FC-0001.
Returns an E-Commerce API base URL.
"""
claims = {'tracking_context': create_tracking_context(user)}
scopes = [
'user_id',
'email',
'profile'
]
jwt = create_jwt_for_user(user, additional_claims=claims, scopes=scopes)
return EdxRestApiClient(
configuration_helpers.get_value('ECOMMERCE_API_URL', settings.ECOMMERCE_API_URL),
jwt=jwt,
session=session
)
return configuration_helpers.get_value('ECOMMERCE_API_URL', settings.ECOMMERCE_API_URL)
def get_ecommerce_api_client(user):

View File

@@ -4,6 +4,7 @@ This file contains celery tasks for credentials-related functionality.
import math
import time
from urllib.parse import urljoin
from celery import shared_task
from celery.exceptions import MaxRetriesExceededError
@@ -25,7 +26,7 @@ from openedx.core.djangoapps.catalog.utils import get_programs
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credentials.helpers import is_learner_records_enabled_for_org
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
from openedx.core.djangoapps.credentials.utils import get_credentials_api_client
from openedx.core.djangoapps.credentials.utils import get_credentials_api_base_url, get_credentials_api_client
from openedx.core.djangoapps.programs.signals import handle_course_cert_awarded, handle_course_cert_changed
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
@@ -48,7 +49,9 @@ MAX_RETRIES = 11
@shared_task(bind=True, ignore_result=True)
@set_code_owner_attribute
def send_grade_to_credentials(self, username, course_run_key, verified, letter_grade, percent_grade):
""" Celery task to notify the Credentials IDA of a grade change via POST. """
"""
Celery task to notify the Credentials IDA of a grade change via POST.
"""
logger.info(f"Running task send_grade_to_credentials for username {username} and course {course_run_key}")
countdown = 2 ** self.request.retries
@@ -56,28 +59,31 @@ def send_grade_to_credentials(self, username, course_run_key, verified, letter_g
try:
credentials_client = get_credentials_api_client(
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME),
org=course_key.org,
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME)
)
credentials_client.grades.post({
'username': username,
'course_run': str(course_key),
'letter_grade': letter_grade,
'percent_grade': percent_grade,
'verified': verified,
})
api_url = urljoin(f"{get_credentials_api_base_url(org=course_key.org)}/", "grades/")
response = credentials_client.post(
api_url,
data={
'username': username,
'course_run': str(course_key),
'letter_grade': letter_grade,
'percent_grade': percent_grade,
'verified': verified,
}
)
response.raise_for_status()
logger.info(f"Sent grade for course {course_run_key} to user {username}")
except Exception as exc: # lint-amnesty, pylint: disable=unused-variable
except Exception: # lint-amnesty, pylint: disable=W0703
grade_str = f'(percent: {percent_grade} letter: {letter_grade})'
error_msg = f'Failed to send grade{grade_str} for course {course_run_key} to user {username}.'
logger.exception(error_msg)
exception = MaxRetriesExceededError(
f"Failed to send grade to credentials. Reason: {error_msg}"
)
raise self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES)
raise self.retry(exc=exception, countdown=countdown, max_retries=MAX_RETRIES) # pylint: disable=raise-missing-from
@shared_task(base=LoggedTask, ignore_result=True)
@@ -396,14 +402,22 @@ def backfill_date_for_all_course_runs():
credentials_client = get_credentials_api_client(
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME),
)
credentials_client.course_certificates.post({
"course_id": course_key,
"certificate_type": modes[0],
"certificate_available_date": course_run.certificate_available_date.strftime('%Y-%m-%dT%H:%M:%SZ'),
"is_active": True,
})
api_url = urljoin(f"{get_credentials_api_base_url()}/", "course_certificates/")
response = credentials_client.post(
api_url,
json={
"course_id": course_key,
"certificate_type": modes[0],
"certificate_available_date": course_run.certificate_available_date.strftime(
'%Y-%m-%dT%H:%M:%SZ'
),
"is_active": True,
}
)
response.raise_for_status()
logger.info(f"certificate_available_date updated for course {course_key}")
except Exception as exc: # lint-amnesty, pylint: disable=unused-variable,W0703
except Exception: # lint-amnesty, pylint: disable=W0703
error_msg = f"Failed to send certificate_available_date for course {course_key}."
logger.exception(error_msg)
if index % 10 == 0:
@@ -442,14 +456,20 @@ def clean_certificate_available_date():
credentials_client = get_credentials_api_client(
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME),
)
credentials_client.course_certificates.post({
"course_id": course_key,
"certificate_type": modes[0],
"certificate_available_date": None,
"is_active": True,
})
credentials_api_base_url = get_credentials_api_base_url()
api_url = urljoin(f"{credentials_api_base_url}/", "course_certificates/")
response = credentials_client.post(
api_url,
json={
"course_id": course_key,
"certificate_type": modes[0],
"certificate_available_date": None,
"is_active": True,
}
)
response.raise_for_status()
logger.info(f"certificate_available_date updated for course {course_key}")
except Exception as exc: # lint-amnesty, pylint: disable=unused-variable,W0703
except Exception: # lint-amnesty, pylint: disable=W0703
error_msg = f"Failed to send certificate_available_date for course {course_key}."
logger.exception(error_msg)
if index % 10 == 0:

View File

@@ -55,10 +55,9 @@ class TestSendGradeToCredentialTask(TestCase):
assert mock_get_api_client.call_count == 1
assert mock_get_api_client.call_args[0] == (self.user,)
self.assertDictEqual(mock_get_api_client.call_args[1], {'org': 'org'})
assert api_client.grades.post.call_count == 1
self.assertDictEqual(api_client.grades.post.call_args[0][0], {
assert api_client.post.call_count == 1
self.assertDictEqual(api_client.post.call_args[1]['data'], {
'username': 'user',
'course_run': 'course-v1:org+course+run',
'letter_grade': 'A',

View File

@@ -17,7 +17,7 @@ UTILS_MODULE = 'openedx.core.djangoapps.credentials.utils'
@skip_unless_lms
@mock.patch(UTILS_MODULE + '.get_edx_api_data')
@mock.patch(UTILS_MODULE + '.get_api_data')
class TestGetCredentials(CredentialsApiConfigMixin, CacheIsolationTestCase):
""" Tests for credentials utility functions. """

View File

@@ -1,11 +1,10 @@
"""Helper functions for working with Credentials."""
from edx_rest_api_client.client import EdxRestApiClient
import requests
from edx_rest_api_client.auth import SuppliedJwtAuth
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
from openedx.core.djangoapps.oauth_dispatch.jwt import create_jwt_for_user
from openedx.core.lib.edx_api_utils import get_edx_api_data
from openedx.core.lib.edx_api_utils import get_api_data
def get_credentials_records_url(program_uuid=None):
@@ -25,23 +24,34 @@ def get_credentials_records_url(program_uuid=None):
return base_url
def get_credentials_api_client(user, org=None):
def get_credentials_api_client(user):
"""
Returns an authenticated Credentials API client.
Arguments:
user (User): The user to authenticate as when requesting credentials.
org (str): Optional organization to look up the site config for, rather than the current request
"""
scopes = ['email', 'profile', 'user_id']
jwt = create_jwt_for_user(user, scopes=scopes)
client = requests.Session()
client.auth = SuppliedJwtAuth(jwt)
return client
def get_credentials_api_base_url(org=None):
"""
Returns a credentials API base URL.
Arguments:
org (str): Optional organization to look up the site config for, rather than the current request
"""
if org is None:
url = CredentialsApiConfig.current().internal_api_url # by current request
else:
url = CredentialsApiConfig.get_internal_api_url_for_org(org) # by org
return EdxRestApiClient(url, jwt=jwt)
return url
def get_credentials(user, program_uuid=None, credential_type=None):
@@ -75,8 +85,15 @@ def get_credentials(user, program_uuid=None, credential_type=None):
cache_key = f'{credential_configuration.CACHE_KEY}.{user.username}' if use_cache else None
if cache_key and program_uuid:
cache_key = f'{cache_key}.{program_uuid}'
api = get_credentials_api_client(user)
return get_edx_api_data(
credential_configuration, 'credentials', api=api, querystring=querystring, cache_key=cache_key
api_client = get_credentials_api_client(user)
base_api_url = get_credentials_api_base_url()
return get_api_data(
credential_configuration,
'credentials',
api_client=api_client,
base_api_url=base_api_url,
querystring=querystring,
cache_key=cache_key
)

View File

@@ -10,6 +10,7 @@ import urllib
import uuid
from email.mime.image import MIMEImage
from email.mime.multipart import MIMEMultipart
from urllib.parse import urljoin
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
@@ -19,14 +20,14 @@ from django.core.mail import EmailMessage, SafeMIMEText
from django.urls import reverse
from django.utils.translation import gettext as _
from eventtracking import tracker
from xmodule.modulestore.django import modulestore
from common.djangoapps.edxmako.shortcuts import render_to_string
from common.djangoapps.edxmako.template import Template
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
from openedx.core.djangoapps.credit.models import CreditConfig, CreditProvider
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangolib.markup import HTML
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
log = logging.getLogger(__name__)
@@ -225,7 +226,10 @@ def get_credit_provider_attribute_values(course_key, attribute_name):
try:
user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
response = ecommerce_api_client(user).courses(course_id).get(include_products=1)
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", f"courses/{course_id}/")
response = get_ecommerce_api_client(user).get(api_url, params={"include_products": 1})
response.raise_for_status()
response = response.json() if response.content else None
except Exception: # pylint: disable=broad-except
log.exception("Failed to receive data from the ecommerce course API for Course ID '%s'.", course_id)
return attribute_values

View File

@@ -1222,13 +1222,14 @@ class CourseApiTests(CreditApiTestBase):
response_providers = get_credit_provider_attribute_values(self.course_key, 'display_name')
self.assertListEqual(self.PROVIDERS_LIST, response_providers)
@httpretty.activate
@mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__')
def test_get_credit_provider_display_names_method_with_exception(self, mock_init):
"""Verify that in case of any exception it logs the error and return."""
mock_init.side_effect = Exception
@mock.patch('openedx.core.djangoapps.credit.email_utils.get_ecommerce_api_client')
def test_get_credit_provider_display_names_method_with_exception(self, mock_get_client):
"""
Verify that in case of any exception it logs the error and return.
"""
mock_get_client.side_effect = Exception
response = get_credit_provider_attribute_values(self.course_key, 'display_name')
assert mock_init.called
assert mock_get_client.called
assert response is None
@httpretty.activate

View File

@@ -2,6 +2,7 @@
This file contains celery tasks for programs-related functionality.
"""
from datetime import datetime
from urllib.parse import urljoin
from celery import shared_task
from celery.exceptions import MaxRetriesExceededError
@@ -11,18 +12,22 @@ from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imp
from django.contrib.sites.models import Site
from django.core.exceptions import ObjectDoesNotExist
from edx_django_utils.monitoring import set_code_owner_attribute
from edx_rest_api_client import exceptions
from opaque_keys.edx.keys import CourseKey
from requests.exceptions import HTTPError
from xmodule.data import CertificatesDisplayBehaviors
from common.djangoapps.course_modes.models import CourseMode
from lms.djangoapps.certificates.api import available_date_for_certificate
from lms.djangoapps.certificates.models import GeneratedCertificate
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
from openedx.core.djangoapps.credentials.utils import get_credentials, get_credentials_api_client
from openedx.core.djangoapps.credentials.utils import (
get_credentials,
get_credentials_api_base_url,
get_credentials_api_client,
)
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
LOGGER = get_task_logger(__name__)
# Maximum number of retries before giving up on awarding credentials.
@@ -96,7 +101,7 @@ def award_program_certificate(client, user, program_uuid, visible_date):
Args:
client:
credentials API client (EdxRestApiClient)
credentials API client (requests.Session)
user:
The student's user data
program_uuid:
@@ -106,22 +111,27 @@ def award_program_certificate(client, user, program_uuid, visible_date):
Returns:
None
"""
client.credentials.post({
'username': user.username,
'lms_user_id': user.id,
'credential': {
'type': PROGRAM_CERTIFICATE,
'program_uuid': program_uuid
},
'attributes': [
{
'name': 'visible_date',
'value': visible_date.strftime(DATE_FORMAT)
}
]
})
credentials_api_base_url = get_credentials_api_base_url()
api_url = urljoin(f"{credentials_api_base_url}/", "credentials/")
response = client.post(
api_url,
json={
'username': user.username,
'lms_user_id': user.id,
'credential': {
'type': PROGRAM_CERTIFICATE,
'program_uuid': program_uuid
},
'attributes': [
{
'name': 'visible_date',
'value': visible_date.strftime(DATE_FORMAT)
}
]
}
)
response.raise_for_status()
@shared_task(bind=True, ignore_result=True)
@@ -233,17 +243,13 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
LOGGER.info(f"Visible date for user {username} : program {program_uuid} is {visible_date}")
award_program_certificate(credentials_client, student, program_uuid, visible_date)
LOGGER.info(f"Awarded certificate for program {program_uuid} to user {username}")
except exceptions.HttpNotFoundError:
LOGGER.exception(
f"Certificate for program {program_uuid} could not be found. " +
f"Unable to award certificate to user {username}. The program might not be configured."
)
except exceptions.HttpClientError as exc:
# Grab the status code from the client error, because our API
# client handles all 4XX errors the same way. In the future,
# we may want to fork slumber, add 429 handling, and use that
# in edx_rest_api_client.
if exc.response.status_code == 429: # lint-amnesty, pylint: disable=no-else-raise, no-member
except HTTPError as exc:
if exc.response.status_code == 404:
LOGGER.exception(
f"Certificate for program {program_uuid} could not be found. " +
f"Unable to award certificate to user {username}. The program might not be configured."
)
elif exc.response.status_code == 429:
rate_limit_countdown = 60
error_msg = (
f"Rate limited. "
@@ -261,7 +267,7 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
f"Unable to award certificate to user {username} for program {program_uuid}. "
"The program might not be configured."
)
except Exception as exc: # pylint: disable=broad-except
except Exception: # pylint: disable=broad-except
# keep trying to award other certs, but retry the whole task to fix any missing entries
LOGGER.exception(f"Failed to award certificate for program {program_uuid} to user {username}.")
failed_program_certificate_award_attempts.append(program_uuid)
@@ -285,37 +291,51 @@ def award_program_certificates(self, username): # lint-amnesty, pylint: disable
def post_course_certificate_configuration(client, cert_config, certificate_available_date=None):
"""
POST a configuration for a course certificate and the date the certificate
will be available
POST to a course_certificates endpoint.
POST a configuration for a course certificate and the date the certificate will be available.
"""
client.course_certificates.post({
"course_id": cert_config['course_id'],
"certificate_type": cert_config['mode'],
"certificate_available_date": certificate_available_date,
"is_active": True
})
credentials_api_base_url = get_credentials_api_base_url()
api_url = urljoin(f"{credentials_api_base_url}/", "course_certificates/")
response = client.post(
api_url,
json={
"course_id": cert_config['course_id'],
"certificate_type": cert_config['mode'],
"certificate_available_date": certificate_available_date,
"is_active": True
}
)
response.raise_for_status()
def post_course_certificate(client, username, certificate, visible_date, date_override=None):
def post_course_certificate(client, username, certificate, visible_date, date_override=None, org=None):
"""
POST a certificate that has been updated to Credentials
"""
client.credentials.post({
'username': username,
'status': 'awarded' if certificate.is_valid() else 'revoked', # Only need the two options at this time
'credential': {
'course_run_key': str(certificate.course_id),
'mode': certificate.mode,
'type': COURSE_CERTIFICATE,
},
'date_override': {'date': date_override.strftime(DATE_FORMAT)} if date_override else None,
'attributes': [
{
'name': 'visible_date',
'value': visible_date.strftime(DATE_FORMAT)
}
]
})
credentials_api_base_url = get_credentials_api_base_url(org)
api_url = urljoin(f"{credentials_api_base_url}/", "credentials/")
response = client.post(
api_url,
json={
'username': username,
'status': 'awarded' if certificate.is_valid() else 'revoked', # Only need the two options at this time
'credential': {
'course_run_key': str(certificate.course_id),
'mode': certificate.mode,
'type': COURSE_CERTIFICATE,
},
'date_override': {'date': date_override.strftime(DATE_FORMAT)} if date_override else None,
'attributes': [
{
'name': 'visible_date',
'value': visible_date.strftime(DATE_FORMAT)
}
]
}
)
response.raise_for_status()
# pylint: disable=W0613
@@ -440,9 +460,8 @@ def award_course_certificate(self, username, course_run_key, certificate_availab
f"Task award_course_certificate was called without course overview data for course {course_key}"
)
return
credentials_client = get_credentials_api_client(User.objects.get(
username=settings.CREDENTIALS_SERVICE_USERNAME),
org=course_key.org,
credentials_client = get_credentials_api_client(
User.objects.get(username=settings.CREDENTIALS_SERVICE_USERNAME),
)
# Date is being passed via JSON and is encoded in the EMCA date time string format. The rest of the code
@@ -474,7 +493,9 @@ def award_course_certificate(self, username, course_run_key, certificate_availab
except ObjectDoesNotExist:
date_override = None
post_course_certificate(credentials_client, username, certificate, visible_date, date_override)
post_course_certificate(
credentials_client, username, certificate, visible_date, date_override, org=course_key.org
)
LOGGER.info(f"Awarded certificate for course {course_key} to user {username}")
except Exception as exc:
@@ -517,22 +538,27 @@ def revoke_program_certificate(client, username, program_uuid):
Revoke a certificate of the given student for the given program.
Args:
client: credentials API client (EdxRestApiClient)
client: credentials API client (requests.Session)
username: The username of the student
program_uuid: uuid of the program
Returns:
None
"""
client.credentials.post({
'username': username,
'status': 'revoked',
'credential': {
'type': PROGRAM_CERTIFICATE,
'program_uuid': program_uuid
credentials_api_base_url = get_credentials_api_base_url()
api_url = urljoin(f"{credentials_api_base_url}/", "credentials/")
response = client.post(
api_url,
json={
'username': username,
'status': 'revoked',
'credential': {
'type': PROGRAM_CERTIFICATE,
'program_uuid': program_uuid
}
}
})
)
response.raise_for_status()
@shared_task(bind=True, ignore_result=True)
@@ -634,21 +660,17 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
try:
revoke_program_certificate(credentials_client, username, program_uuid)
LOGGER.info(f"Revoked certificate for program {program_uuid} for user {username}")
except exceptions.HttpNotFoundError:
LOGGER.exception(
f"Certificate for program {program_uuid} could not be found. "
f"Unable to revoke certificate for user {username}"
)
except exceptions.HttpClientError as exc:
# Grab the status code from the client error, because our API
# client handles all 4XX errors the same way. In the future,
# we may want to fork slumber, add 429 handling, and use that
# in edx_rest_api_client.
if exc.response.status_code == 429: # pylint: disable=no-member, no-else-raise
except HTTPError as exc:
if exc.response.status_code == 404:
LOGGER.exception(
f"Certificate for program {program_uuid} could not be found. "
f"Unable to revoke certificate for user {username}"
)
elif exc.response.status_code == 429:
rate_limit_countdown = 60
error_msg = (
"Rate limited. "
f"Retrying task to revoke certificates for user {username} in {rate_limit_countdown} seconds"
"Rate limited. Retrying task to revoke certificates "
f"for user {username} in {rate_limit_countdown} seconds"
)
LOGGER.info(error_msg)
# Retry after 60 seconds, when we should be in a new throttling window
@@ -659,7 +681,9 @@ def revoke_program_certificates(self, username, course_key): # lint-amnesty, py
countdown=rate_limit_countdown
) from exc
else:
LOGGER.exception(f"Unable to revoke certificate for user {username} for program {program_uuid}.")
LOGGER.exception(
f"Unable to revoke certificate for user {username} for program {program_uuid}."
)
except Exception: # pylint: disable=broad-except
# keep trying to revoke other certs, but retry the whole task to fix any missing entries
LOGGER.warning(f"Failed to revoke certificate for program {program_uuid} of user {username}.")

View File

@@ -12,11 +12,13 @@ import ddt
import httpretty
import pytest
import pytz
import requests
from celery.exceptions import MaxRetriesExceededError
from django.conf import settings
from django.test import TestCase, override_settings
from edx_rest_api_client import exceptions
from edx_rest_api_client.client import EdxRestApiClient
from edx_rest_api_client.auth import SuppliedJwtAuth
from requests.exceptions import HTTPError
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
from common.djangoapps.student.tests.factories import UserFactory
@@ -28,7 +30,6 @@ from openedx.core.djangoapps.oauth_dispatch.tests.factories import ApplicationFa
from openedx.core.djangoapps.programs import tasks
from openedx.core.djangoapps.site_configuration.tests.factories import SiteConfigurationFactory, SiteFactory
from openedx.core.djangolib.testing.utils import skip_unless_lms
from xmodule.data import CertificatesDisplayBehaviors # lint-amnesty, pylint: disable=wrong-import-order
log = logging.getLogger(__name__)
@@ -81,15 +82,17 @@ class AwardProgramCertificateTestCase(TestCase):
"""
Test the award_program_certificate function
"""
@httpretty.activate
def test_award_program_certificate(self):
@mock.patch('openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url')
def test_award_program_certificate(self, mock_get_api_base_url):
"""
Ensure the correct API call gets made
"""
mock_get_api_base_url.return_value = 'http://test-server/'
student = UserFactory(username='test-username', email='test-email@email.com')
test_client = EdxRestApiClient('http://test-server', jwt='test-token')
test_client = requests.Session()
test_client.auth = SuppliedJwtAuth('test-token')
httpretty.register_uri(
httpretty.POST,
@@ -401,7 +404,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
"""
Verify that a 429 error causes the task to fail and then retry.
"""
exception = exceptions.HttpClientError()
exception = HTTPError()
exception.response = mock.Mock(status_code=429)
mock_get_completed_programs.return_value = {1: 1, 2: 2}
mock_award_program_certificate.side_effect = self._make_side_effect(
@@ -421,7 +424,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
"""
Verify that a 404 error causes the task to fail but there is no retry.
"""
exception = exceptions.HttpNotFoundError()
exception = HTTPError()
exception.response = mock.Mock(status_code=404)
mock_get_completed_programs.return_value = {1: 1, 2: 2}
mock_award_program_certificate.side_effect = self._make_side_effect(
@@ -441,7 +444,7 @@ class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiCo
"""
Verify that other 4XX errors cause task to fail but there is no retry.
"""
exception = exceptions.HttpClientError()
exception = HTTPError()
exception.response = mock.Mock(status_code=418)
mock_get_completed_programs.return_value = {1: 1, 2: 2}
mock_award_program_certificate.side_effect = self._make_side_effect(
@@ -472,11 +475,14 @@ class PostCourseCertificateTestCase(TestCase):
)
@httpretty.activate
def test_post_course_certificate(self):
@mock.patch('openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url')
def test_post_course_certificate(self, mock_get_api_base_url):
"""
Ensure the correct API call gets made
"""
test_client = EdxRestApiClient('http://test-server', jwt='test-token')
mock_get_api_base_url.return_value = 'http://test-server/'
test_client = requests.Session()
test_client.auth = SuppliedJwtAuth('test-token')
httpretty.register_uri(
httpretty.POST,
@@ -651,12 +657,15 @@ class RevokeProgramCertificateTestCase(TestCase):
"""
@httpretty.activate
def test_revoke_program_certificate(self):
@mock.patch('openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url')
def test_revoke_program_certificate(self, mock_get_api_base_url):
"""
Ensure the correct API call gets made
"""
mock_get_api_base_url.return_value = 'http://test-server/'
test_username = 'test-username'
test_client = EdxRestApiClient('http://test-server', jwt='test-token')
test_client = requests.Session()
test_client.auth = SuppliedJwtAuth('test-token')
httpretty.register_uri(
httpretty.POST,
@@ -863,7 +872,7 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
"""
Verify that a 429 error causes the task to fail and then retry.
"""
exception = exceptions.HttpClientError()
exception = HTTPError()
exception.response = mock.Mock(status_code=429)
mock_get_inverted_programs.return_value = self.inverted_programs
mock_get_certified_programs.return_value = [1, 2]
@@ -884,7 +893,7 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
"""
Verify that a 404 error causes the task to fail but there is no retry.
"""
exception = exceptions.HttpNotFoundError()
exception = HTTPError()
exception.response = mock.Mock(status_code=404)
mock_get_inverted_programs.return_value = self.inverted_programs
mock_get_certified_programs.return_value = [1, 2]
@@ -905,7 +914,7 @@ class RevokeProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiC
"""
Verify that other 4XX errors cause task to fail but there is no retry.
"""
exception = exceptions.HttpClientError()
exception = HTTPError()
exception.response = mock.Mock(status_code=418)
mock_get_inverted_programs.return_value = self.inverted_programs
mock_get_certified_programs.return_value = [1, 2]
@@ -994,11 +1003,14 @@ class PostCourseCertificateConfigurationTestCase(TestCase):
}
@httpretty.activate
def test_post_course_certificate_configuration(self):
@mock.patch('openedx.core.djangoapps.programs.tasks.get_credentials_api_base_url')
def test_post_course_certificate_configuration(self, mock_get_api_base_url):
"""
Ensure the correct API call gets made
"""
test_client = EdxRestApiClient('http://test-server', jwt='test-token')
mock_get_api_base_url.return_value = 'http://test-server/'
test_client = requests.Session()
test_client.auth = SuppliedJwtAuth('test-token')
httpretty.register_uri(
httpretty.POST,

View File

@@ -15,15 +15,17 @@ from django.contrib.sites.models import Site
from django.core.cache import cache
from django.urls import reverse
from django.utils.functional import cached_property
from edx_rest_api_client.exceptions import SlumberBaseException
from opaque_keys.edx.keys import CourseKey
from pytz import utc
from requests.exceptions import ConnectionError, Timeout # lint-amnesty, pylint: disable=redefined-builtin
from requests.exceptions import RequestException
from xmodule.modulestore.django import modulestore
from common.djangoapps.course_modes.api import get_paid_modes_for_course
from common.djangoapps.course_modes.models import CourseMode
from common.djangoapps.entitlements.api import get_active_entitlement_list_for_user
from common.djangoapps.entitlements.models import CourseEntitlement
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.util.date_utils import strftime_localized
from lms.djangoapps.certificates import api as certificate_api
from lms.djangoapps.certificates.data import CertificateStatuses
from lms.djangoapps.certificates.models import GeneratedCertificate
@@ -32,19 +34,16 @@ from openedx.core.djangoapps.catalog.api import get_programs_by_type
from openedx.core.djangoapps.catalog.constants import PathwayType
from openedx.core.djangoapps.catalog.utils import (
get_fulfillable_course_runs_for_entitlement,
get_pathways,
get_programs,
get_pathways
)
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
from openedx.core.djangoapps.content.course_overviews.models import CourseOverview
from openedx.core.djangoapps.credentials.utils import get_credentials, get_credentials_records_url
from openedx.core.djangoapps.enrollments.api import get_enrollments
from openedx.core.djangoapps.enrollments.permissions import ENROLL_IN_COURSE
from openedx.core.djangoapps.programs import ALWAYS_CALCULATE_PROGRAM_PRICE_AS_ANONYMOUS_USER
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from common.djangoapps.student.models import CourseEnrollment
from common.djangoapps.util.date_utils import strftime_localized
from xmodule.modulestore.django import modulestore # lint-amnesty, pylint: disable=wrong-import-order
# The datetime module's strftime() methods require a year >= 1900.
DEFAULT_ENROLLMENT_START_DATE = datetime.datetime(1900, 1, 1, tzinfo=utc)
@@ -756,24 +755,27 @@ class ProgramDataExtender:
api_user = service_user
is_anonymous = True
api = ecommerce_api_client(api_user)
api_client = get_ecommerce_api_client(api_user)
api_url = urljoin(f"{get_ecommerce_api_base_url()}/", "baskets/calculate/")
# The user specific program price is slow to calculate, so use switch to force the
# anonymous price for all users. See LEARNER-5555 for more details.
if is_anonymous or ALWAYS_CALCULATE_PROGRAM_PRICE_AS_ANONYMOUS_USER.is_enabled():
# The bundle uuid is necessary to see the program's discounted price
if bundle_uuid:
discount_data = api.baskets.calculate.get(sku=skus, is_anonymous=True, bundle=bundle_uuid)
params = dict(sku=skus, is_anonymous=True, bundle=bundle_uuid)
else:
discount_data = api.baskets.calculate.get(sku=skus, is_anonymous=True)
params = dict(sku=skus, is_anonymous=True)
else:
if bundle_uuid:
discount_data = api.baskets.calculate.get(
params = dict(
sku=skus, username=self.user.username, bundle=bundle_uuid
)
else:
discount_data = api.baskets.calculate.get(sku=skus, username=self.user.username)
params = dict(sku=skus, username=self.user.username)
response = api_client.get(api_url, params=params)
response.raise_for_status()
discount_data = response.json()
program_discounted_price = discount_data['total_incl_tax']
program_full_price = discount_data['total_incl_tax_excl_discounts']
discount_data['is_discounted'] = program_discounted_price < program_full_price
@@ -784,7 +786,7 @@ class ProgramDataExtender:
'full_program_price': discount_data['total_incl_tax'],
'variant': bundle_variant
})
except (ConnectionError, SlumberBaseException, Timeout):
except RequestException:
log.exception('Failed to get discount price for following product SKUs: %s ', ', '.join(skus))
self.data.update({
'discount_data': {'is_discounted': False}

View File

@@ -16,9 +16,12 @@ from django_countries import countries
from common.djangoapps import third_party_auth
from common.djangoapps.edxmako.shortcuts import render_to_response
from common.djangoapps.student.models import UserProfile
from common.djangoapps.third_party_auth import pipeline
from common.djangoapps.util.date_utils import strftime_localized
from lms.djangoapps.commerce.models import CommerceConfiguration
from lms.djangoapps.commerce.utils import EcommerceService
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
from openedx.core.djangoapps.dark_lang.models import DarkLangConfig
from openedx.core.djangoapps.lang_pref.api import all_languages, released_languages
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
@@ -28,13 +31,10 @@ from openedx.core.djangoapps.user_api.accounts.toggles import (
should_redirect_to_order_history_microfrontend
)
from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
from openedx.core.lib.edx_api_utils import get_edx_api_data
from openedx.core.lib.edx_api_utils import get_api_data
from openedx.core.lib.time_zone_utils import TIME_ZONE_CHOICES
from openedx.features.enterprise_support.api import enterprise_customer_for_request
from openedx.features.enterprise_support.utils import update_account_settings_context_for_enterprise
from common.djangoapps.student.models import UserProfile
from common.djangoapps.third_party_auth import pipeline
from common.djangoapps.util.date_utils import strftime_localized
log = logging.getLogger(__name__)
@@ -195,9 +195,13 @@ def get_user_orders(user):
use_cache = commerce_configuration.is_cache_enabled
cache_key = commerce_configuration.CACHE_KEY + '.' + str(user.id) if use_cache else None
api = ecommerce_api_client(user)
commerce_user_orders = get_edx_api_data(
commerce_configuration, 'orders', api=api, querystring=user_query, cache_key=cache_key
commerce_user_orders = get_api_data(
commerce_configuration,
'orders',
api_client=get_ecommerce_api_client(user),
base_api_url=get_ecommerce_api_base_url(),
querystring=user_query,
cache_key=cache_key
)
for order in commerce_user_orders:

View File

@@ -51,12 +51,7 @@ from common.djangoapps.student.models import ( # lint-amnesty, pylint: disable=
is_username_retired,
is_email_retired
)
from common.djangoapps.student.models_api import (
confirm_name_change,
do_name_change_request,
get_pending_name_change
)
from openedx.core.djangoapps.user_api.accounts import RETIRED_EMAIL_MSG
from common.djangoapps.student.models_api import confirm_name_change, do_name_change_request, get_pending_name_change
from openedx.core.djangoapps.ace_common.template_context import get_base_template_context
from openedx.core.djangoapps.api_admin.models import ApiAccessRequest
from openedx.core.djangoapps.course_groups.models import UnregisteredLearnerCohortAssignments
@@ -64,6 +59,7 @@ from openedx.core.djangoapps.credit.models import CreditRequest, CreditRequireme
from openedx.core.djangoapps.external_user_ids.models import ExternalId, ExternalIdType
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.profile_images.images import remove_profile_images
from openedx.core.djangoapps.user_api.accounts import RETIRED_EMAIL_MSG
from openedx.core.djangoapps.user_api.accounts.image_helpers import get_profile_image_names, set_has_profile_image
from openedx.core.djangoapps.user_authn.exceptions import AuthFailedError
from openedx.core.lib.api.authentication import BearerAuthenticationAllowInactiveUser
@@ -76,7 +72,7 @@ from ..models import (
RetirementStateError,
UserOrgTag,
UserRetirementPartnerReportingStatus,
UserRetirementStatus
UserRetirementStatus,
)
from .api import get_account_settings, update_account_settings
from .permissions import CanDeactivateUser, CanGetAccountInfo, CanReplaceUsername, CanRetireUser
@@ -84,7 +80,7 @@ from .serializers import (
PendingNameChangeSerializer,
UserRetirementPartnerReportSerializer,
UserRetirementStatusSerializer,
UserSearchEmailSerializer
UserSearchEmailSerializer,
)
from .signals import USER_RETIRE_LMS_CRITICAL, USER_RETIRE_LMS_MISC, USER_RETIRE_MAILINGS
from .utils import create_retirement_request_and_deactivate_account, username_suffix_generator
@@ -966,10 +962,8 @@ class AccountRetirementStatusView(ViewSet):
Updates the RetirementStatus row for the given user to the new
status, and append any messages to the message log.
Note that this implementation DOES NOT use the "merge patch"
implementation seen in AccountViewSet. Slumber, the project
we use to power edx-rest-api-client, does not currently support
it. The content type for this request is 'application/json'.
Note that this implementation DOES NOT use the "merge patch" implementation seen in AccountViewSet.
The content type for this request is 'application/json'.
"""
try:
username = request.data['username']

View File

@@ -8,18 +8,18 @@ Management command to sync platform users with hubspot
import json
import time
import traceback
import urllib.parse # pylint: disable=import-error
from datetime import datetime, timedelta
from textwrap import dedent
import urllib.parse # pylint: disable=import-error
import requests
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.core.management.base import BaseCommand, CommandError
from edx_rest_api_client.client import EdxRestApiClient
from slumber.exceptions import HttpClientError, HttpServerError
from requests.exceptions import HTTPError
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
from common.djangoapps.student.models import UserAttribute
from common.djangoapps.util.query import use_read_replica_if_available
from openedx.core.djangoapps.site_configuration.models import SiteConfiguration
HUBSPOT_API_BASE_URL = 'https://api.hubapi.com'
@@ -139,13 +139,14 @@ class Command(BaseCommand):
contacts.append(contact)
api_key = site_conf.get_value('HUBSPOT_API_KEY')
client = EdxRestApiClient(urllib.parse.urljoin(HUBSPOT_API_BASE_URL, 'contacts/v1/contact'))
api_url = urllib.parse.urljoin(f"{HUBSPOT_API_BASE_URL}/", 'contacts/v1/contact/batch/')
try:
client.batch.post(contacts, hapikey=api_key)
response = requests.post(api_url, json=contacts, params={"hapikey": api_key})
response.raise_for_status()
return len(contacts)
except (HttpClientError, HttpServerError) as ex:
except HTTPError as ex:
message = 'An error occurred while syncing batch of contacts for site {domain}, {message}'.format(
domain=site_conf.site.domain, message=ex.message # lint-amnesty, pylint: disable=no-member
domain=site_conf.site.domain, message=str(ex)
)
self.stderr.write(message)
return 0

View File

@@ -7,7 +7,7 @@ import logging
from django.core.exceptions import ObjectDoesNotExist
from oauth2_provider.models import Application
from slumber.exceptions import HttpClientError
from requests.exceptions import HTTPError
from openedx.core.djangoapps.video_pipeline.models import VEMPipelineIntegration
from openedx.core.djangoapps.video_pipeline.utils import create_video_pipeline_api_client
@@ -29,27 +29,30 @@ def send_transcript_credentials(pipeline_integration, credentials_payload):
oauth_client.client_id,
oauth_client.client_secret
)
error_message = "Unable to update transcript credentials -- org={}, provider={}, response={}"
error_message = "Unable to update transcript credentials -- org=%s, provider=%s, response=%s"
try:
response = client.request("POST", pipeline_integration.api_url, json=credentials_payload)
response = client.post(pipeline_integration.api_url, json=credentials_payload)
response.raise_for_status()
if response.ok:
is_updated = True
else:
is_updated = False
error_response = json.loads(response.text)
log.error(error_message.format(
log.error(
error_message,
credentials_payload.get('org'),
credentials_payload.get('provider'),
response.text
))
except HttpClientError as ex:
)
except HTTPError as ex:
is_updated = False
log.exception(error_message.format(
log.exception(
error_message,
credentials_payload.get('org'),
credentials_payload.get('provider'),
ex.content
))
error_response = json.loads(ex.content)
ex.response.content
)
error_response = json.loads(ex.response.content)
return error_response, is_updated

View File

@@ -7,9 +7,9 @@ from unittest.mock import Mock, patch
import ddt
from django.test.testcases import TestCase
from slumber.exceptions import HttpClientError
from common.djangoapps.student.tests.factories import UserFactory
from requests.exceptions import HTTPError
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.video_pipeline.api import update_3rd_party_transcription_service_credentials
from openedx.core.djangoapps.video_pipeline.tests.mixins import VideoPipelineMixin
@@ -82,7 +82,10 @@ class TestAPIUtils(VideoPipelineMixin, TestCase):
during communication with edx-video-pipeline.
"""
error_content = '{"error_type": "1"}'
mock_client.return_value.request = Mock(side_effect=HttpClientError(content=error_content))
resp = Mock()
resp.content = error_content
resp.raise_for_status.side_effect = HTTPError(response=resp)
mock_client.return_value.post.return_value = resp
# try updating the transcription service credentials
credentials_payload = {
@@ -96,9 +99,8 @@ class TestAPIUtils(VideoPipelineMixin, TestCase):
assert not is_updated
self.assertDictEqual(error_response, json.loads(error_content))
mock_logger.exception.assert_called_with(
'Unable to update transcript credentials -- org={}, provider={}, response={}'.format(
credentials_payload['org'],
credentials_payload['provider'],
error_content
)
'Unable to update transcript credentials -- org=%s, provider=%s, response=%s',
credentials_payload['org'],
credentials_payload['provider'],
error_content
)

View File

@@ -19,107 +19,6 @@ def get_fields(fields, response):
return results
def get_edx_api_data(api_config, resource, api, resource_id=None, querystring=None, cache_key=None, many=True,
traverse_pagination=True, fields=None, long_term_cache=False):
"""
GET data from an edX REST API_client.
DEPRECATED: To be replaced with get_api_data as part of FC-0001.
DRY utility for handling caching and pagination.
Arguments:
api_config (ConfigurationModel): The configuration model governing interaction with the API.
resource (str): Name of the API resource being requested.
Keyword Arguments:
api (APIClient): API client to use for requesting data.
resource_id (int or str): Identifies a specific resource to be retrieved.
querystring (dict): Optional query string parameters.
cache_key (str): Where to cache retrieved data. The cache will be ignored if this is omitted
(neither inspected nor updated).
many (bool): Whether the resource requested is a collection of objects, or a single object.
If false, an empty dict will be returned in cases of failure rather than the default empty list.
traverse_pagination (bool): Whether to traverse pagination or return paginated response..
long_term_cache (bool): Whether to use the long term cache ttl or the standard cache ttl
Returns:
Data returned by the API. When hitting a list endpoint, extracts "results" (list of dict)
returned by DRF-powered APIs.
"""
no_data = [] if many else {}
if not api_config.enabled:
log.warning('%s configuration is disabled.', api_config.API_NAME)
return no_data
if cache_key:
cache_key = f'{cache_key}.{resource_id}' if resource_id is not None else cache_key
cache_key += '.zpickled'
cached = cache.get(cache_key)
if cached:
try:
cached_response = zunpickle(cached)
except Exception: # pylint: disable=broad-except
# Data is corrupt in some way.
log.warning("Data for cache is corrupt for cache key %s", cache_key)
cache.delete(cache_key)
else:
if fields:
cached_response = get_fields(fields, cached_response)
return cached_response
try:
endpoint = getattr(api, resource)
querystring = querystring if querystring else {}
response = endpoint(resource_id).get(**querystring)
if resource_id is None and traverse_pagination:
results = _traverse_pagination(response, endpoint, querystring, no_data)
else:
results = response
except: # pylint: disable=bare-except
log.exception('Failed to retrieve data from the %s API.', api_config.API_NAME)
return no_data
if cache_key:
zdata = zpickle(results)
cache_ttl = api_config.cache_ttl
if long_term_cache:
cache_ttl = api_config.long_term_cache_ttl
cache.set(cache_key, zdata, cache_ttl)
if fields:
results = get_fields(fields, results)
return results
def _traverse_pagination(response, endpoint, querystring, no_data):
"""
Traverse a paginated API response.
DEPRECATED: To be replaced with get_results_with_traverse_pagination as part of FC-0001.
Extracts and concatenates "results" (list of dict) returned by DRF-powered APIs.
"""
results = response.get('results', no_data)
page = 1
next_page = response.get('next')
while next_page:
page += 1
querystring['page'] = page
response = endpoint.get(**querystring)
results += response.get('results', no_data)
next_page = response.get('next')
return results
def get_api_data(api_config, resource, api_client, base_api_url, resource_id=None,
querystring=None, cache_key=None, many=True,
traverse_pagination=True, fields=None, long_term_cache=False):
@@ -178,14 +77,14 @@ def get_api_data(api_config, resource, api_client, base_api_url, resource_id=Non
querystring = querystring if querystring else {}
api_url = urljoin(
f"{base_api_url}/",
f"{resource}/{resource_id if resource_id else ''}"
f"{resource}/{str(resource_id) + '/' if resource_id is not None else ''}"
)
response = api_client.get(api_url, params=querystring)
response.raise_for_status()
response = response.json()
if resource_id is None and traverse_pagination:
results = get_results_with_traverse_pagination(response, api_client, api_url, querystring, no_data)
results = _traverse_pagination(response, api_client, api_url, querystring, no_data)
else:
results = response
@@ -206,7 +105,7 @@ def get_api_data(api_config, resource, api_client, base_api_url, resource_id=Non
return results
def get_results_with_traverse_pagination(response, api_client, api_url, querystring, no_data):
def _traverse_pagination(response, api_client, api_url, querystring, no_data):
"""
Traverse a paginated API response.

View File

@@ -3,17 +3,18 @@
import json
from unittest import mock
from urllib.parse import urljoin
import httpretty
from django.core.cache import cache
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.catalog.models import CatalogIntegration
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.catalog.utils import create_catalog_api_client
from openedx.core.djangoapps.catalog.utils import get_catalog_api_client
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from openedx.core.lib.edx_api_utils import get_edx_api_data
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.lib.edx_api_utils import get_api_data
UTILITY_MODULE = 'openedx.core.lib.edx_api_utils'
TEST_API_URL = 'http://www-internal.example.com/api'
@@ -22,13 +23,16 @@ TEST_API_URL = 'http://www-internal.example.com/api'
@skip_unless_lms
@httpretty.activate
class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, CacheIsolationTestCase):
"""Tests for edX API data retrieval utility."""
"""
Tests for edX API data retrieval utility.
"""
ENABLED_CACHES = ['default']
def setUp(self):
super().setUp()
self.user = UserFactory()
self.base_api_url = CatalogIntegration.current().get_internal_api_url().strip('/')
httpretty.httpretty.reset()
cache.clear()
@@ -36,18 +40,22 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
def _mock_catalog_api(self, responses, url=None):
assert httpretty.is_enabled(), 'httpretty must be enabled to mock Catalog API calls.'
url = url if url else CatalogIntegration.current().get_internal_api_url().strip('/') + '/programs/'
url = url if url else urljoin(f"{self.base_api_url}/", "programs/")
httpretty.register_uri(httpretty.GET, url, responses=responses)
def _assert_num_requests(self, count):
"""DRY helper for verifying request counts."""
"""
DRY helper for verifying request counts.
"""
assert len(httpretty.httpretty.latest_requests) == count
def test_get_unpaginated_data(self):
"""Verify that unpaginated data can be retrieved."""
"""
Verify that unpaginated data can be retrieved.
"""
catalog_integration = self.create_catalog_integration()
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
expected_collection = ['some', 'test', 'data']
data = {
@@ -59,8 +67,10 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
[httpretty.Response(body=json.dumps(data), content_type='application/json')]
)
with mock.patch('edx_rest_api_client.client.EdxRestApiClient.__init__') as mock_init:
actual_collection = get_edx_api_data(catalog_integration, 'programs', api=api)
with mock.patch('requests.Session') as mock_init:
actual_collection = get_api_data(
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url
)
# Verify that the helper function didn't initialize its own client.
assert not mock_init.called
@@ -70,12 +80,14 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
self._assert_num_requests(1)
def test_get_paginated_data(self):
"""Verify that paginated data can be retrieved."""
"""
Verify that paginated data can be retrieved.
"""
catalog_integration = self.create_catalog_integration()
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
expected_collection = ['some', 'test', 'data']
url = CatalogIntegration.current().get_internal_api_url().strip('/') + '/programs/?page={}'
url = urljoin(f"{self.base_api_url}/", "/programs/?page={}")
responses = []
for page, record in enumerate(expected_collection, start=1):
@@ -91,7 +103,9 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
self._mock_catalog_api(responses)
actual_collection = get_edx_api_data(catalog_integration, 'programs', api=api)
actual_collection = get_api_data(
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url
)
assert actual_collection == expected_collection
self._assert_num_requests(len(expected_collection))
@@ -101,9 +115,9 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
Verify that pagination is not traversed if traverse_pagination=False is passed as argument.
"""
catalog_integration = self.create_catalog_integration()
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
url = CatalogIntegration.current().get_internal_api_url().strip('/') + '/programs/?page={}'
url = urljoin(f"{self.base_api_url}/", "/programs/?page={}")
responses = [
{
'next': url.format(2),
@@ -120,20 +134,21 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
[httpretty.Response(body=json.dumps(body), content_type='application/json') for body in responses]
)
actual_collection = get_edx_api_data(catalog_integration, 'programs', api=api, traverse_pagination=False)
actual_collection = get_api_data(
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, traverse_pagination=False
)
assert actual_collection == expected_response
self._assert_num_requests(1)
def test_get_specific_resource(self):
"""Verify that a specific resource can be retrieved."""
"""
Verify that a specific resource can be retrieved.
"""
catalog_integration = self.create_catalog_integration()
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
resource_id = 1
url = '{api_root}/programs/{resource_id}/'.format(
api_root=CatalogIntegration.current().get_internal_api_url().strip('/'),
resource_id=resource_id,
)
url = urljoin(f"{self.base_api_url}/", f"programs/{resource_id}/")
expected_resource = {'key': 'value'}
@@ -142,13 +157,17 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
url=url
)
actual_resource = get_edx_api_data(catalog_integration, 'programs', api=api, resource_id=resource_id)
actual_resource = get_api_data(
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, resource_id=resource_id
)
assert actual_resource == expected_resource
self._assert_num_requests(1)
def test_get_specific_resource_with_falsey_id(self):
"""
Check the call with falsey resource_id.
Verify that a specific resource can be retrieved, and pagination parsing is
not attempted, if the ID passed is falsey (e.g., 0). The expected resource contains
a "results" key, as a paginatable item would have, so if the function looks for falsey
@@ -156,13 +175,10 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
return the value of that "results" key.
"""
catalog_integration = self.create_catalog_integration()
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
resource_id = 0
url = '{api_root}/programs/{resource_id}/'.format(
api_root=CatalogIntegration.current().get_internal_api_url().strip('/'),
resource_id=resource_id,
)
url = urljoin(f"{self.base_api_url}/", f"programs/{resource_id}/")
expected_resource = {'key': 'value', 'results': []}
@@ -171,23 +187,24 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
url=url
)
actual_resource = get_edx_api_data(catalog_integration, 'programs', api=api, resource_id=resource_id)
actual_resource = get_api_data(
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, resource_id=resource_id
)
assert actual_resource == expected_resource
self._assert_num_requests(1)
def test_get_specific_fields_from_cache_response(self):
"""Verify that resource response is cached and get required fields from cached response"""
"""
Verify that resource response is cached and get required fields from cached response.
"""
catalog_integration = self.create_catalog_integration(cache_ttl=5)
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
response = {'lang': 'en', 'weeks_to_complete': '5'}
resource_id = 'course-v1:testX+testABC+1T2019'
url = '{api_root}/course_runs/{resource_id}/'.format(
api_root=CatalogIntegration.current().get_internal_api_url().strip('/'),
resource_id=resource_id,
)
url = urljoin(f"{self.base_api_url}/", f"course_runs/{resource_id}/")
expected_resource_for_lang = {'lang': 'en'}
expected_resource_for_weeks_to_complete = {'weeks_to_complete': '5'}
@@ -200,14 +217,25 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
cache_key = CatalogIntegration.current().CACHE_KEY
# get response and set the cache.
actual_resource_for_lang = get_edx_api_data(
catalog_integration, 'course_runs', resource_id=resource_id, api=api, cache_key=cache_key, fields=['lang']
actual_resource_for_lang = get_api_data(
catalog_integration,
'course_runs',
resource_id=resource_id,
api_client=api,
base_api_url=self.base_api_url,
cache_key=cache_key,
fields=['lang']
)
assert actual_resource_for_lang == expected_resource_for_lang
# Hit the cache
actual_resource = get_edx_api_data(
catalog_integration, 'course_runs', api=api, resource_id=resource_id, cache_key=cache_key,
actual_resource = get_api_data(
catalog_integration,
'course_runs',
api_client=api,
base_api_url=self.base_api_url,
resource_id=resource_id,
cache_key=cache_key,
fields=['weeks_to_complete']
)
@@ -217,9 +245,11 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
self._assert_num_requests(1)
def test_cache_utilization(self):
"""Verify that when enabled, the cache is used."""
"""
Verify that when enabled, the cache is used.
"""
catalog_integration = self.create_catalog_integration(cache_ttl=5)
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
expected_collection = ['some', 'test', 'data']
data = {
@@ -232,10 +262,7 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
)
resource_id = 1
url = '{api_root}/programs/{resource_id}/'.format(
api_root=CatalogIntegration.current().get_internal_api_url().strip('/'),
resource_id=resource_id,
)
url = urljoin(f"{self.base_api_url}/", f"programs/{resource_id}/")
expected_resource = {'key': 'value'}
@@ -247,15 +274,31 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
cache_key = CatalogIntegration.current().CACHE_KEY
# Warm up the cache.
get_edx_api_data(catalog_integration, 'programs', api=api, cache_key=cache_key)
get_edx_api_data(catalog_integration, 'programs', api=api, resource_id=resource_id, cache_key=cache_key)
get_api_data(
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, cache_key=cache_key
)
get_api_data(
catalog_integration,
'programs',
api_client=api,
base_api_url=self.base_api_url,
resource_id=resource_id,
cache_key=cache_key
)
# Hit the cache.
actual_collection = get_edx_api_data(catalog_integration, 'programs', api=api, cache_key=cache_key)
actual_collection = get_api_data(
catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url, cache_key=cache_key
)
assert actual_collection == expected_collection
actual_resource = get_edx_api_data(
catalog_integration, 'programs', api=api, resource_id=resource_id, cache_key=cache_key
actual_resource = get_api_data(
catalog_integration,
'programs',
api_client=api,
base_api_url=self.base_api_url,
resource_id=resource_id,
cache_key=cache_key
)
assert actual_resource == expected_resource
@@ -264,38 +307,45 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
@mock.patch(UTILITY_MODULE + '.log.warning')
def test_api_config_disabled(self, mock_warning):
"""Verify that no data is retrieved if the provided config model is disabled."""
"""
Verify that no data is retrieved if the provided config model is disabled.
"""
catalog_integration = self.create_catalog_integration(enabled=False)
actual = get_edx_api_data(catalog_integration, 'programs', api=None)
actual = get_api_data(catalog_integration, 'programs', api_client=None, base_api_url=self.base_api_url)
assert mock_warning.called
assert actual == []
@mock.patch(UTILITY_MODULE + '.log.exception')
def test_data_retrieval_failure(self, mock_exception):
"""Verify that an exception is logged when data can't be retrieved."""
"""
Verify that an exception is logged when data can't be retrieved.
"""
catalog_integration = self.create_catalog_integration()
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
self._mock_catalog_api(
[httpretty.Response(body='clunk', content_type='application/json', status_code=500)]
)
actual = get_edx_api_data(catalog_integration, 'programs', api=api)
actual = get_api_data(catalog_integration, 'programs', api_client=api, base_api_url=self.base_api_url)
assert mock_exception.called
assert actual == []
@mock.patch(UTILITY_MODULE + '.log.warning')
def test_api_config_disabled_with_id_and_not_collection(self, mock_warning):
"""Verify that no data is retrieved if the provided config model is disabled."""
"""
Verify that no data is retrieved if the provided config model is disabled.
"""
catalog_integration = self.create_catalog_integration(enabled=False)
actual = get_edx_api_data(
actual = get_api_data(
catalog_integration,
'programs',
api=None,
api_client=None,
base_api_url=self.base_api_url,
resource_id=100,
many=False
)
@@ -305,18 +355,21 @@ class TestGetEdxApiData(CatalogIntegrationMixin, CredentialsApiConfigMixin, Cach
@mock.patch(UTILITY_MODULE + '.log.exception')
def test_data_retrieval_failure_with_id(self, mock_exception):
"""Verify that an exception is logged when data can't be retrieved."""
"""
Verify that an exception is logged when data can't be retrieved.
"""
catalog_integration = self.create_catalog_integration()
api = create_catalog_api_client(self.user)
api = get_catalog_api_client(self.user)
self._mock_catalog_api(
[httpretty.Response(body='clunk', content_type='application/json', status_code=500)]
)
actual = get_edx_api_data(
actual = get_api_data(
catalog_integration,
'programs',
api=api,
api_client=api,
base_api_url=self.base_api_url,
resource_id=100,
many=False
)

View File

@@ -2,11 +2,12 @@
APIs providing support for enterprise functionality.
"""
import logging
import traceback
from functools import wraps
from urllib.parse import urljoin
import requests
from crum import get_current_request
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
@@ -18,8 +19,8 @@ from django.urls import reverse
from django.utils.http import urlencode
from django.utils.translation import gettext as _
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 edx_rest_api_client.auth import SuppliedJwtAuth
from requests.exceptions import HTTPError
from common.djangoapps.third_party_auth.pipeline import get as get_partial_pipeline
from common.djangoapps.third_party_auth.provider import Registry
@@ -70,13 +71,12 @@ class ConsentApiClient:
provided user.
"""
jwt = create_jwt_for_user(user)
url = configuration_helpers.get_value('ENTERPRISE_CONSENT_API_URL', settings.ENTERPRISE_CONSENT_API_URL)
self.client = EdxRestApiClient(
url,
jwt=jwt,
append_slash=False,
base_api_url = configuration_helpers.get_value(
'ENTERPRISE_CONSENT_API_URL', settings.ENTERPRISE_CONSENT_API_URL
)
self.consent_endpoint = self.client.data_sharing_consent
self.client = requests.Session()
self.client.auth = SuppliedJwtAuth(jwt)
self.consent_endpoint = urljoin(f"{base_api_url}/", "data_sharing_consent")
def revoke_consent(self, **kwargs):
"""
@@ -85,7 +85,9 @@ class ConsentApiClient:
This endpoint takes any given kwargs, which are understood as filtering the
conceptual scope of the consent involved in the request.
"""
return self.consent_endpoint.delete(**kwargs)
response = self.client.delete(self.consent_endpoint, json=kwargs)
response.raise_for_status()
return response.json()
def provide_consent(self, **kwargs):
"""
@@ -94,7 +96,9 @@ class ConsentApiClient:
This endpoint takes any given kwargs, which are understood as filtering the
conceptual scope of the consent involved in the request.
"""
return self.consent_endpoint.post(kwargs)
response = self.client.post(self.consent_endpoint, json=kwargs)
response.raise_for_status()
return response.json()
def consent_required(self, enrollment_exists=False, **kwargs):
"""
@@ -105,7 +109,9 @@ class ConsentApiClient:
"""
# Call the endpoint with the given kwargs, and check the value that it provides.
response = self.consent_endpoint.get(**kwargs)
response = self.client.get(self.consent_endpoint, params=kwargs)
response.raise_for_status()
response = response.json()
LOGGER.info(
'[ENTERPRISE DSC] Consent Requirement Info. APIParams: [%s], APIResponse: [%s], EnrollmentExists: [%s]',
@@ -149,19 +155,21 @@ class EnterpriseApiClient:
def __init__(self, user):
"""
Initialize an authenticated Enterprise service API client by using the
provided user.
Initialize an authenticated Enterprise service API client.
Authentificate by jwt token using the provided user.
"""
self.user = user
jwt = create_jwt_for_user(user)
self.client = EdxRestApiClient(
configuration_helpers.get_value('ENTERPRISE_API_URL', settings.ENTERPRISE_API_URL),
jwt=jwt
)
self.base_api_url = configuration_helpers.get_value('ENTERPRISE_API_URL', settings.ENTERPRISE_API_URL)
self.client = requests.Session()
self.client.auth = SuppliedJwtAuth(jwt)
def get_enterprise_customer(self, uuid):
endpoint = getattr(self.client, 'enterprise-customer')
return endpoint(uuid).get()
api_url = urljoin(f"{self.base_api_url}/", f"enterprise-customer/{uuid}/")
response = self.client.get(api_url)
response.raise_for_status()
return response.json()
def post_enterprise_course_enrollment(self, username, course_id):
"""
@@ -171,10 +179,11 @@ class EnterpriseApiClient:
'username': username,
'course_id': course_id,
}
endpoint = getattr(self.client, 'enterprise-course-enrollment')
api_url = urljoin(f"{self.base_api_url}/", "enterprise-course-enrollment/")
try:
endpoint.post(data=data)
except (HttpClientError, HttpServerError):
response = self.client.post(api_url, data=data)
response.raise_for_status()
except HTTPError:
message = (
"An error occured while posting EnterpriseCourseEnrollment for user {username} and "
"course run {course_id}."
@@ -251,26 +260,17 @@ class EnterpriseApiClient:
}
],
}
Raises:
ConnectionError: requests exception "ConnectionError", raised if if ecommerce is unable to connect
to enterprise api server.
SlumberBaseException: base slumber exception "SlumberBaseException", raised if API response contains
http error status like 4xx, 5xx etc.
Timeout: requests exception "Timeout", raised if enterprise API is taking too long for returning
a response. This exception is raised for both connection timeout and read timeout.
"""
if not user.is_authenticated:
return None
api_resource_name = 'enterprise-learner'
api_url = urljoin(f"{self.base_api_url}/", "enterprise-learner/")
try:
endpoint = getattr(self.client, api_resource_name)
querystring = {'username': user.username}
response = endpoint().get(**querystring)
except (HttpClientError, HttpServerError):
response = self.client.get(api_url, params=querystring)
response.raise_for_status()
except HTTPError:
LOGGER.exception(
'Failed to get enterprise-learner for user [%s] with client user [%s]. Caller: %s, Request PATH: %s',
user.username,
@@ -280,7 +280,7 @@ class EnterpriseApiClient:
)
return None
return response
return response.json()
class EnterpriseApiServiceClient(EnterpriseServiceClientMixin, EnterpriseApiClient):
@@ -295,8 +295,10 @@ class EnterpriseApiServiceClient(EnterpriseServiceClientMixin, EnterpriseApiClie
"""
enterprise_customer = enterprise_customer_from_cache(uuid=uuid)
if enterprise_customer is _CACHE_MISS:
endpoint = getattr(self.client, 'enterprise-customer')
enterprise_customer = endpoint(uuid).get()
api_url = urljoin(f"{self.base_api_url}/", f"enterprise-customer/{uuid}/")
response = self.client.get(api_url)
response.raise_for_status()
enterprise_customer = response.json() if response.content else None
if enterprise_customer:
cache_enterprise(enterprise_customer)
@@ -476,8 +478,11 @@ def enterprise_customer_from_api(request):
try:
enterprise_customer = enterprise_api_client.get_enterprise_customer(enterprise_customer_uuid)
except HttpNotFoundError:
enterprise_customer = None
except HTTPError as err:
if err.response.status_code == 404:
enterprise_customer = None
else:
raise
return enterprise_customer

View File

@@ -4,23 +4,24 @@ This module contains signals related to enterprise.
import logging
from urllib.parse import urljoin
from django.conf import settings
from django.contrib.auth.models import User # lint-amnesty, pylint: disable=imported-auth-user
from django.db.models.signals import post_save, pre_save
from django.dispatch import receiver
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomer, EnterpriseCustomerUser # lint-amnesty, pylint: disable=unused-import
from enterprise.models import EnterpriseCourseEnrollment, EnterpriseCustomer
from integrated_channels.integrated_channel.tasks import (
transmit_single_learner_data,
transmit_single_subsection_learner_data
)
from slumber.exceptions import HttpClientError
from requests.exceptions import HTTPError
from openedx.core.djangoapps.commerce.utils import ecommerce_api_client
from openedx.core.djangoapps.signals.signals import COURSE_GRADE_NOW_PASSED, COURSE_ASSESSMENT_GRADE_CHANGED
from common.djangoapps.student.signals import UNENROLL_DONE
from openedx.core.djangoapps.commerce.utils import get_ecommerce_api_base_url, get_ecommerce_api_client
from openedx.core.djangoapps.signals.signals import COURSE_ASSESSMENT_GRADE_CHANGED, COURSE_GRADE_NOW_PASSED
from openedx.features.enterprise_support.tasks import clear_enterprise_customer_data_consent_share_cache
from openedx.features.enterprise_support.utils import clear_data_consent_share_cache, is_enterprise_learner
from common.djangoapps.student.signals import UNENROLL_DONE
log = logging.getLogger(__name__)
@@ -105,13 +106,17 @@ def refund_order_voucher(sender, course_enrollment, skip_refund=False, **kwargs)
return
service_user = User.objects.get(username=settings.ECOMMERCE_SERVICE_WORKER_USERNAME)
client = ecommerce_api_client(service_user)
client = get_ecommerce_api_client(service_user)
api_url = urljoin(
f"{get_ecommerce_api_base_url()}/", "coupons/create_refunded_voucher/"
)
order_number = course_enrollment.get_order_attribute_value('order_number')
if order_number:
error_message = "Encountered {} from ecommerce while creating refund voucher. Order={}, enrollment={}, user={}"
try:
client.enterprise.coupons.create_refunded_voucher.post({"order": order_number})
except HttpClientError as ex:
response = client.post(api_url, data={"order": order_number})
response.raise_for_status()
except HTTPError as ex:
log.info(
error_message.format(type(ex).__name__, order_number, course_enrollment, course_enrollment.user)
)

View File

@@ -15,8 +15,8 @@ from django.test.utils import override_settings
from django.urls import reverse
from edx_django_utils.cache import get_cache_key
from enterprise.models import EnterpriseCustomerUser # lint-amnesty, pylint: disable=wrong-import-order
from requests.exceptions import HTTPError
from six.moves.urllib.parse import parse_qs
from slumber.exceptions import HttpClientError
from common.djangoapps.student.tests.factories import UserFactory
from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory
@@ -48,13 +48,13 @@ from openedx.features.enterprise_support.api import (
get_enterprise_learner_data_from_db,
get_enterprise_learner_portal_enabled_message,
insert_enterprise_pipeline_elements,
unlink_enterprise_user_from_idp
unlink_enterprise_user_from_idp,
)
from openedx.features.enterprise_support.tests import FEATURES_WITH_ENTERPRISE_ENABLED
from openedx.features.enterprise_support.tests.factories import (
EnterpriseCourseEnrollmentFactory,
EnterpriseCustomerIdentityProviderFactory,
EnterpriseCustomerUserFactory
EnterpriseCustomerUserFactory,
)
from openedx.features.enterprise_support.tests.mixins.enterprise import EnterpriseServiceMockMixin
from openedx.features.enterprise_support.utils import clear_data_consent_share_cache
@@ -98,7 +98,7 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
mocked_jwt_builder.assert_called_once_with(enterprise_service_user)
# pylint: disable=protected-access
assert enterprise_api_service_client.client._store['session'].auth.token == 'test-token'
assert enterprise_api_service_client.client.auth.token == 'test-token'
def _assert_api_client_with_user(self, api_client, mocked_jwt_builder):
"""
@@ -115,7 +115,7 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
mocked_jwt_builder.assert_called_once_with(dummy_enterprise_user)
# pylint: disable=protected-access
assert enterprise_api_service_client.client._store['session'].auth.token == 'test-token'
assert enterprise_api_service_client.client.auth.token == 'test-token'
return enterprise_api_service_client
def _assert_get_enterprise_customer(self, api_client, enterprise_api_data_for_mock):
@@ -181,10 +181,10 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
authenticate and access enterprise API.
"""
api_client = self._assert_api_client_with_user(EnterpriseApiClient, mock_jwt_builder)
setattr(api_client.client, 'enterprise-course-enrollment', mock.Mock())
mock_endpoint = getattr(api_client.client, 'enterprise-course-enrollment')
mock_client = mock.Mock()
api_client.client = mock_client
if should_raise_http_error:
mock_endpoint.post.side_effect = HttpClientError
mock_client.post.side_effect = HTTPError
username = 'spongebob'
course_id = 'burger-flipping-101'
@@ -195,10 +195,13 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
else:
api_client.post_enterprise_course_enrollment(username, course_id)
mock_endpoint.post.assert_called_once_with(data={
'username': username,
'course_id': course_id,
})
mock_client.post.assert_called_once_with(
f"{api_client.base_api_url}enterprise-course-enrollment/",
data={
'username': username,
'course_id': course_id,
}
)
@mock.patch('openedx.features.enterprise_support.api.enterprise_customer_uuid_for_request')
@mock.patch('openedx.features.enterprise_support.api.EnterpriseApiClient')
@@ -227,7 +230,8 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
user to authenticate and access enterprise API.
"""
consent_client = self._assert_api_client_with_user(ConsentApiClient, mock_jwt_builder)
consent_client.consent_endpoint = mock.Mock()
mock_client = mock.Mock()
consent_client.client = mock_client
kwargs = {
'foo': 'a',
@@ -236,8 +240,8 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
consent_client.provide_consent(**kwargs)
consent_client.revoke_consent(**kwargs)
consent_client.consent_endpoint.post.assert_called_once_with(kwargs)
consent_client.consent_endpoint.delete.assert_called_once_with(**kwargs)
mock_client.post.assert_called_once_with(consent_client.consent_endpoint, json=kwargs)
mock_client.delete.assert_called_once_with(consent_client.consent_endpoint, json=kwargs)
@httpretty.activate
@mock.patch('openedx.features.enterprise_support.api.get_enterprise_learner_data_from_db')
@@ -343,23 +347,33 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
@mock.patch('openedx.features.enterprise_support.api.create_jwt_for_user')
def test_fetch_enterprise_learner_data(self, mock_jwt_builder):
"""
Test EnterpriseApiClient's fetch_enterprise_learner_data method.
"""
api_client = self._assert_api_client_with_user(EnterpriseApiClient, mock_jwt_builder)
setattr(api_client.client, 'enterprise-learner', mock.Mock())
mock_endpoint = getattr(api_client.client, 'enterprise-learner')
mock_client = mock.Mock()
api_client.client = mock_client
user = mock.Mock(is_authenticated=True, username='spongebob')
response = api_client.fetch_enterprise_learner_data(user)
assert mock_endpoint.return_value.get.return_value == response
mock_endpoint.return_value.get.assert_called_once_with(username=user.username)
assert mock_client.get.return_value.json.return_value == response
mock_client.get.assert_called_once_with(
f"{api_client.base_api_url}enterprise-learner/",
params={'username': user.username},
)
@mock.patch('openedx.features.enterprise_support.api.get_current_request')
@mock.patch('openedx.features.enterprise_support.api.create_jwt_for_user')
def test_fetch_enterprise_learner_data_http_error(self, mock_jwt_builder, mock_get_current_request):
"""
Test error handling for the EnterpriseApiClient's fetch_enterprise_learner_data method.
"""
api_client = self._assert_api_client_with_user(EnterpriseApiClient, mock_jwt_builder)
setattr(api_client.client, 'enterprise-learner', mock.Mock())
mock_endpoint = getattr(api_client.client, 'enterprise-learner')
mock_endpoint.return_value.get.side_effect = HttpClientError
mock_client = mock.Mock()
mock_client.get.side_effect = HTTPError
api_client.client = mock_client
mock_get_current_request.return_value.META = {
'PATH_INFO': 'whatever',
}
@@ -367,8 +381,8 @@ class TestEnterpriseApi(EnterpriseServiceMockMixin, CacheIsolationTestCase):
user = mock.Mock(is_authenticated=True, username='spongebob')
assert api_client.fetch_enterprise_learner_data(user) is None
mock_endpoint.return_value.get.assert_called_once_with(username=user.username)
url = f"{api_client.base_api_url}enterprise-learner/"
mock_client.get.assert_called_once_with(url, params={'username': user.username})
@mock.patch('openedx.features.enterprise_support.api.EnterpriseApiClient')
def test_get_enterprise_learner_data_from_api(self, mock_api_client_class):

View File

@@ -10,7 +10,8 @@ from django.test.utils import override_settings
from django.utils.timezone import now
from edx_django_utils.cache import TieredCache
from opaque_keys.edx.keys import CourseKey
from slumber.exceptions import HttpClientError, HttpServerError
# from slumber.exceptions import HttpClientError, HttpServerError
from requests.exceptions import HTTPError
from testfixtures import LogCapture
from common.djangoapps.course_modes.tests.factories import CourseModeFactory
@@ -152,27 +153,30 @@ class EnterpriseSupportSignals(SharedModuleStoreTestCase):
api_called,
mock_is_order_voucher_refundable
):
"""Test refund_order_voucher signal"""
"""
Test refund_order_voucher signal
"""
mock_is_order_voucher_refundable.return_value = order_voucher_refundable
enrollment = self._create_enrollment_to_refund(no_of_days_placed, enterprise_enrollment_exists)
with patch('openedx.features.enterprise_support.signals.ecommerce_api_client') as mock_ecommerce_api_client:
with patch('openedx.features.enterprise_support.signals.get_ecommerce_api_client') as mock_ecommerce_api_client:
enrollment.update_enrollment(is_active=False, skip_refund=skip_refund)
assert mock_ecommerce_api_client.called == api_called
@patch('common.djangoapps.student.models.CourseEnrollment.is_order_voucher_refundable')
@ddt.data(
(HttpClientError, 'INFO'),
(HttpServerError, 'ERROR'),
(HTTPError, 'INFO'),
(Exception, 'ERROR'),
)
@ddt.unpack
def test_refund_order_voucher_with_client_errors(self, mock_error, log_level, mock_is_order_voucher_refundable):
"""Test refund_order_voucher signal client_error"""
"""
Test refund_order_voucher signal client_error.
"""
mock_is_order_voucher_refundable.return_value = True
enrollment = self._create_enrollment_to_refund()
with patch('openedx.features.enterprise_support.signals.ecommerce_api_client') as mock_ecommerce_api_client:
with patch('openedx.features.enterprise_support.signals.get_ecommerce_api_client') as mock_ecommerce_api_client:
client_instance = mock_ecommerce_api_client.return_value
client_instance.enterprise.coupons.create_refunded_voucher.post.side_effect = mock_error()
client_instance.post.side_effect = mock_error()
with LogCapture(LOGGER_NAME) as logger:
enrollment.update_enrollment(is_active=False)
assert mock_ecommerce_api_client.called is True