diff --git a/cms/envs/common.py b/cms/envs/common.py index bfecbffcaa..b620644f61 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -110,6 +110,9 @@ from lms.envs.common import ( # Enterprise service settings ENTERPRISE_CATALOG_INTERNAL_ROOT_URL, + ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY, + ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_SECRET, + ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL, # Blockstore BLOCKSTORE_USE_BLOCKSTORE_APP_API, diff --git a/cms/envs/devstack.py b/cms/envs/devstack.py index 76af9a6cd2..ee0e74f433 100644 --- a/cms/envs/devstack.py +++ b/cms/envs/devstack.py @@ -213,6 +213,8 @@ IDA_LOGOUT_URI_LIST = [ 'http://localhost:18150/logout/', # credentials ] +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://edx.devstack.lms/oauth2" + ############################### BLOCKSTORE ##################################### BLOCKSTORE_API_URL = "http://edx.devstack.blockstore:18250/api/v1/" diff --git a/lms/envs/common.py b/lms/envs/common.py index 74bfb6e979..e9a8dc1862 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -5115,3 +5115,8 @@ DISCUSSION_MODERATION_CLOSE_REASON_CODES = { IS_ELIGIBLE_FOR_FINANCIAL_ASSISTANCE_URL = '/core/api/course_eligibility/' FINANCIAL_ASSISTANCE_APPLICATION_STATUS_URL = "/core/api/financial_assistance_application/status/" CREATE_FINANCIAL_ASSISTANCE_APPLICATION_URL = '/core/api/financial_assistance_applications' + +######################## Enterprise API Client ######################## +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_KEY = "enterprise-backend-service-key" +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_SECRET = "enterprise-backend-service-secret" +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://127.0.0.1:8000/oauth2" diff --git a/lms/envs/devstack.py b/lms/envs/devstack.py index 133d3cf1e3..58d76aca27 100644 --- a/lms/envs/devstack.py +++ b/lms/envs/devstack.py @@ -390,6 +390,8 @@ MKTG_URLS = { ENTERPRISE_MARKETING_FOOTER_QUERY_PARAMS = {} +ENTERPRISE_BACKEND_SERVICE_EDX_OAUTH2_PROVIDER_URL = "http://edx.devstack.lms:18000/oauth2" + CREDENTIALS_SERVICE_USERNAME = 'credentials_worker' COURSE_CATALOG_URL_ROOT = 'http://edx.devstack.discovery:18381' diff --git a/openedx/core/djangoapps/commerce/utils.py b/openedx/core/djangoapps/commerce/utils.py index ccadba7c8f..0105b2c11d 100644 --- a/openedx/core/djangoapps/commerce/utils.py +++ b/openedx/core/djangoapps/commerce/utils.py @@ -1,7 +1,9 @@ """ Commerce API Service. """ +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 @@ -31,7 +33,11 @@ def is_commerce_service_configured(): def ecommerce_api_client(user, session=None): - """ Returns an E-Commerce API client setup with authentication for the specified user. """ + """ + 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. + """ claims = {'tracking_context': create_tracking_context(user)} scopes = [ 'user_id', @@ -45,3 +51,21 @@ def ecommerce_api_client(user, session=None): jwt=jwt, session=session ) + + +def get_ecommerce_api_client(user): + """ + Returns an E-Commerce API client setup with authentication for the specified user. + """ + claims = {'tracking_context': create_tracking_context(user)} + scopes = [ + 'user_id', + 'email', + 'profile' + ] + jwt = create_jwt_for_user(user, additional_claims=claims, scopes=scopes) + + client = requests.Session() + client.auth = SuppliedJwtAuth(jwt) + + return client diff --git a/openedx/core/lib/edx_api_utils.py b/openedx/core/lib/edx_api_utils.py index ed3ecf78fb..3828c06743 100644 --- a/openedx/core/lib/edx_api_utils.py +++ b/openedx/core/lib/edx_api_utils.py @@ -2,6 +2,7 @@ import logging +from urllib.parse import urljoin from django.core.cache import cache @@ -20,7 +21,10 @@ def get_fields(fields, response): 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. + """ + 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. @@ -95,7 +99,10 @@ def get_edx_api_data(api_config, resource, api, resource_id=None, querystring=No def _traverse_pagination(response, endpoint, querystring, no_data): - """Traverse a paginated API response. + """ + 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. """ @@ -111,3 +118,111 @@ def _traverse_pagination(response, endpoint, querystring, 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): + """ + GET data from an edX REST API endpoint using the API client. + + 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. + api_client (requests.Session): API client (either raw requests.Session or OAuthAPIClient) to use for + requesting data. + base_api_url (str): base API url, used to construct the full API URL across with resource and + resource_id (if any). + + Keyword Arguments: + 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: + querystring = querystring if querystring else {} + api_url = urljoin( + f"{base_api_url}/", + f"{resource}/{resource_id if resource_id 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) + 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 get_results_with_traverse_pagination(response, api_client, api_url, querystring, no_data): + """ + Traverse a paginated API response. + + 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 = api_client.get(api_url, params=querystring) + response.raise_for_status() + response = response.json() + results += response.get('results', no_data) + next_page = response.get('next') + + return results