diff --git a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py index b22c96931..0fe0ca9af 100644 --- a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py +++ b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py @@ -7,6 +7,7 @@ from uuid import UUID, uuid4 import ddt +import requests from django.conf import settings from rest_framework import status from rest_framework.reverse import reverse @@ -682,6 +683,52 @@ def test_redeem_policy(self, mock_transactions_cache_for_learner): # pylint: di ), ) + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.get_and_cache_transactions_for_learner') + @mock.patch('enterprise_access.apps.api.v1.views.subsidy_access_policy.LmsApiClient') + @ddt.data( + { + 'subsidy_error_code': 'fulfillment_error', + 'subsidy_error_detail': [{'message': 'woozit duplicate order woohoo!'}], + 'expected_redeem_error_payload': { + 'reason': 'the-reason', + 'user-message': 'the-user-message', + 'metadata': { + 'enterprise_administrators': 'edx@example.com', + }, + }, + }, + ) + def test_redeem_policy_subsidy_api_error( + self, mock_lms_api_client, mock_transactions_cache_for_learner, + subsidy_error_code, subsidy_error_detail, expected_redeem_error_payload + ): + """ + Verify that SubsidyAccessPolicyRedeemViewset redeem endpoint returns a well-structured + error response payload when the subsidy API call to redeem/fulfill responds with an error. + """ + mock_lms_api_client().get_enterprise_customer_data.return_value = { + 'slug': 'the-slug', + 'admin_users': [{'email': 'edx@example.com'}], + } + self.mock_get_content_metadata.return_value = {'content_price': 123} + mock_response = mock.MagicMock() + mock_response.json.return_value = { + 'code': subsidy_error_code, + 'detail': subsidy_error_detail, + } + self.redeemable_policy.subsidy_client.create_subsidy_transaction.side_effect = requests.exceptions.HTTPError( + response=mock_response + ) + + payload = { + 'lms_user_id': 1234, + 'content_key': 'course-v1:edX+edXPrivacy101+3T2020', + } + + response = self.client.post(self.subsidy_access_policy_redeem_endpoint, payload) + + response_json = self.load_json(response.content) + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.get_and_cache_transactions_for_learner') def test_redeem_policy_with_metadata(self, mock_transactions_cache_for_learner): # pylint: disable=unused-argument """ diff --git a/enterprise_access/apps/api/v1/views/subsidy_access_policy.py b/enterprise_access/apps/api/v1/views/subsidy_access_policy.py index b575a1d8e..9d335f439 100644 --- a/enterprise_access/apps/api/v1/views/subsidy_access_policy.py +++ b/enterprise_access/apps/api/v1/views/subsidy_access_policy.py @@ -47,6 +47,9 @@ REASON_POLICY_NOT_ACTIVE, REASON_POLICY_SPEND_LIMIT_REACHED, MissingSubsidyAccessReasonUserMessages, + SubsidyRedemptionErrorCodes, + SubsidyRedemptionErrorReasons, + SubsidyFulfillmentErrorReasons, TransactionStateChoices ) from enterprise_access.apps.subsidy_access_policy.content_metadata_api import get_and_cache_content_metadata @@ -485,11 +488,71 @@ def redeem(self, request, *args, **kwargs): raise SubsidyAccessPolicyLockedException() from exc except SubsidyAPIHTTPError as exc: logger.exception(f'{exc} when creating transaction in subsidy API') - error_payload = exc.error_payload() - error_payload['detail'] = f"Subsidy Transaction API error: {error_payload['detail']}" - raise RedemptionRequestException( - detail=error_payload, - ) from exc + try: + error_payload = self._get_subsidy_api_error_reason(policy, exc) + except Exception: # pylint: disable=broad-except + error_payload = exc.error_payload() + error_payload['detail'] = f"Subsidy Transaction API error: {error_payload['detail']}" + + raise RedemptionRequestException(detail=error_payload) from exc + + def _get_subsidy_api_error_reason(self, policy, exc_object): + """ + Helper to build error response payload on Subsidy API errors. + """ + subsidy_error_detail = exc_object.error_payload().get('detail') + subsidy_error_code = exc_object.error_payload().get('code') + + metadata = { + 'enterprise_administrators': self._get_enterprise_admin_users(policy.enterprise_customer_uuid), + } + + # We currently only have enough data to say more specific things + # about fulfillment errors during subsidy API redemption. + if subsidy_error_code == SubsidyRedemptionErrorCodes.FULFILLMENT_ERROR: + return self._get_subsidy_fulfillment_error_reason(subsidy_error_detail, metadata) + + default_reason = SubsidyRedemptionErrorReasons.DEFAULT_REASON + return { + 'reason': default_reason, + 'user_message': SubsidyRedemptionErrorReasons.USER_MESSAGES_BY_REASON[default_reason], + 'metadata': metadata, + } + + def _get_subsidy_fulfillment_error_reason(self, subsidy_error_detail, metadata): + """ + Helper to return a reason, user_message, and metadata + for the given subsidy_error_detail. + """ + reason = SubsidyFulfillmentErrorReasons.DEFAULT_REASON + metadata['subsidy_error_detail'] = subsidy_error_detail + + if subsidy_error_detail: + message_string = self._get_subsidy_fulfillment_error_message(subsidy_error_detail) + if cause_of_message := SubsidyFulfillmentErrorReasons.get_cause_from_error_message(message_string): + reason = cause_of_message + + return { + "reason": reason, + "user_message": SubsidyFulfillmentErrorReasons.USER_MESSAGES_BY_REASON.get(reason), + "metadata": metadata, + } + + def _get_subsidy_fulfillment_error_message(self, subsidy_error_detail): + """ + ``subsidy_error_detail`` is either a string describing an error message, + a dict with a "message" key describing an error message, or a list of such + dicts. This helper method widdles any of those things down into a single + error message string. + """ + if isinstance(subsidy_error_detail, str): + return subsidy_error_detail + + subsidy_message_dict = subsidy_error_detail + if isinstance(subsidy_error_detail, list): + subsidy_message_dict = subsidy_error_detail[0] + + return subsidy_message_dict.get('message') def get_existing_redemptions(self, policies, lms_user_id): """ @@ -673,9 +736,7 @@ def _get_reasons_for_no_redeemable_policies(self, enterprise_customer_uuid, non_ for each non-redeemable policy. """ reasons = [] - lms_client = LmsApiClient() - enterprise_customer_data = lms_client.get_enterprise_customer_data(enterprise_customer_uuid) - enterprise_admin_users = enterprise_customer_data.get('admin_users') + enterprise_admin_users = self._get_enterprise_admin_users(enterprise_customer_uuid) for reason, policies in non_redeemable_policies.items(): reasons.append({ @@ -689,6 +750,14 @@ def _get_reasons_for_no_redeemable_policies(self, enterprise_customer_uuid, non_ return reasons + def _get_enterprise_admin_users(self, enterprise_customer_uuid): + """ + Helper to fetch admin users for the given customer uuid. + """ + lms_client = LmsApiClient() + enterprise_customer_data = lms_client.get_enterprise_customer_data(enterprise_customer_uuid) + return enterprise_customer_data.get('admin_users') + def _get_list_price(self, enterprise_customer_uuid, content_key): """ Determine the price for content for display purposes only. diff --git a/enterprise_access/apps/subsidy_access_policy/constants.py b/enterprise_access/apps/subsidy_access_policy/constants.py index a7183ac41..7412c0bd2 100644 --- a/enterprise_access/apps/subsidy_access_policy/constants.py +++ b/enterprise_access/apps/subsidy_access_policy/constants.py @@ -1,4 +1,5 @@ """ Constants for the subsidy_access_policy app. """ +import re class AccessMethods: @@ -99,3 +100,56 @@ class MissingSubsidyAccessReasonUserMessages: REASON_LEARNER_MAX_SPEND_REACHED = "learner_max_spend_reached" REASON_POLICY_SPEND_LIMIT_REACHED = "policy_spend_limit_reached" REASON_LEARNER_MAX_ENROLLMENTS_REACHED = "learner_max_enrollments_reached" + + +class SubsidyRedemptionErrorCodes: + """ + Collection of error ``code`` values that the subsidy API's + redeem endpoint might return in an error response payload. + """ + FULFILLMENT_ERROR = 'fulfillment_error' + + +class SubsidyRedemptionErrorReasons: + """ + Somewhat more generic collection of reasons that redemption may have + failed in ways that are *not* related to fulfillment. + """ + DEFAULT_REASON = 'default_subsidy_redemption_error' + + USER_MESSAGES_BY_REASON = { + DEFAULT_REASON: "Something went wrong during subsidy redemption", + } + + +class SubsidyFulfillmentErrorReasons: + """ + Codifies standard reasons that fulfillment may have failed, + along with a mapping of those reasons to user-friendly display messages. + """ + DEFAULT_REASON = 'default_fulfillment_error' + DUPLICATE_FULFILLMENT = 'duplicate_fulfillment' + + USER_MESSAGES_BY_REASON = { + DEFAULT_REASON: "Something went wrong during fulfillment", + DUPLICATE_FULFILLMENT: "A legacy fulfillment already exists for this content.", + } + + CAUSES_REGEXP_BY_REASON = { + DUPLICATE_FULFILLMENT: re.compile(".*duplicate order.*"), + } + + @staticmethod + def get_cause_from_error_message(message_string): + """ + Helper to find the cause of a given error message string + by matching against the regexs mapped above. + """ + if not message_string: + return None + + for cause_of_message, regex in SubsidyRedemptionErrorReasons.CAUSES_REGEXP_BY_REASON.items(): + if regex.match(message_string): + return cause_of_message + + return None