diff --git a/enterprise_subsidy/apps/api/exceptions.py b/enterprise_subsidy/apps/api/exceptions.py index f51077b6..24622219 100644 --- a/enterprise_subsidy/apps/api/exceptions.py +++ b/enterprise_subsidy/apps/api/exceptions.py @@ -13,18 +13,19 @@ class ErrorCodes: CONTENT_NOT_FOUND = 'content_not_found' INVALID_REQUESTED_PRICE = 'invalid_requested_price' TRANSACTION_CREATION_ERROR = 'transaction_creation_error' + DEPOSIT_CREATION_ERROR = 'deposit_creation_error' LEDGER_LOCK_ERROR = 'ledger_lock_error' INACTIVE_SUBSIDY_CREATION_ERROR = 'inactive_subsidy_creation_error' FULFILLMENT_ERROR = 'fulfillment_error' -class TransactionCreationAPIException(APIException): +class SubsidyAPIException(APIException): """ - Custom exception raised when transactions cannot be created. + Custom API exception class allowing for overriding the status code and exposing the error code. """ - status_code = status.HTTP_422_UNPROCESSABLE_ENTITY - default_detail = 'Error creating transaction.' - default_code = ErrorCodes.TRANSACTION_CREATION_ERROR + status_code = None + default_detail = None + default_code = None def __init__(self, detail=None, code=None, status_code=None): """ @@ -39,3 +40,21 @@ def __init__(self, detail=None, code=None, status_code=None): if not isinstance(self.detail, dict): self.detail = {'detail': self.detail} self.detail['code'] = code or self.default_code + + +class TransactionCreationAPIException(SubsidyAPIException): + """ + Custom exception raised when transactions cannot be created. + """ + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + default_detail = 'Error creating transaction.' + default_code = ErrorCodes.TRANSACTION_CREATION_ERROR + + +class DepositCreationAPIException(SubsidyAPIException): + """ + Custom exception raised when Deposits cannot be created. + """ + status_code = status.HTTP_422_UNPROCESSABLE_ENTITY + default_detail = 'Error creating deposit.' + default_code = ErrorCodes.DEPOSIT_CREATION_ERROR diff --git a/enterprise_subsidy/apps/api/v2/serializers.py b/enterprise_subsidy/apps/api/v2/serializers.py new file mode 100644 index 00000000..2f4ac487 --- /dev/null +++ b/enterprise_subsidy/apps/api/v2/serializers.py @@ -0,0 +1,149 @@ +""" +V2 Serializers for the enterprise-subsidy API. +""" +import logging + +import openedx_ledger.api +from openedx_ledger.models import Deposit, SalesContractReferenceProvider +from rest_framework import serializers + +logger = logging.getLogger(__name__) + + +class DepositSerializer(serializers.ModelSerializer): + """ + Read-only response serializer for the `Deposit` model. + """ + # Unless we override this field, it will use the primary key via PriaryKeyRelatedField which is less useful than the + # slug. + sales_contract_reference_provider = serializers.SlugRelatedField( + many=False, + slug_field='slug', + ) + + class Meta: + """ + Meta class for DepositSerializer. + """ + model = Deposit + fields = [ + 'ledger', + 'desired_deposit_quantity', + 'transaction', + 'sales_contract_reference_id', + 'sales_contract_reference_provider', + ] + + +class DepositCreationError(Exception): + """ + Generic exception related to errors during transaction creation. + """ + + +class DepositCreationRequestSerializer(serializers.ModelSerializer): + """ + Serializer for creating instances of the `Transaction` model. + """ + sales_contract_reference_provider = serializers.SlugRelatedField( + many=False, + slug_field='slug', + ) + metadata = serializers.JSONField( + help_text=( + "Any additional metadata that a client may want to associate with the " + "related Transaction instance to be created." + ), + allow_null=True, + ) + + class Meta: + """ + Meta class for DepositCreationSerializer. + """ + model = Deposit + fields = [ + 'desired_deposit_quantity', + 'sales_contract_reference_id', + 'sales_contract_reference_provider', + 'idempotency_key', + 'metadata', + ] + extra_kwargs = { + 'desired_deposit_quantity': { + 'required': True, + 'min_value': 1, + }, + 'sales_contract_reference_id': {'required': True}, + 'sales_contract_reference_provider': {'required': False}, + 'idempotency_key': {'required': False}, + 'metadata': {'required': False}, + } + + def to_representation(self, instance): + """ + Once a Deposit has been created, we want to serialize more fields from the instance than are required in this, + the input serializer. + """ + read_serializer = DepositSerializer(instance) + return read_serializer.data + + @property + def calling_view(self): + """ + Helper to get the calling DRF view object from context + """ + return self.context['view'] + + def create(self, validated_data): + """ + Gets or creates a Deposit record via the `openedx_ledger.api.create_deposit()` method. + + If an existing Deposit is found with the same ledger, quantity, and related transaction idempotency_key, that + Desposit is returned. + + Raises: + SalesContractReferenceProvider.DoesNotExist: If the provided sales_contract_reference_provider_slug does not + correspond to an existing SalesContractReferenceProvider object. + """ + # subsidy() is a convenience property on the instance of the view class that uses this serializer. + subsidy = self.calling_view.subsidy + + try: + sales_contract_reference_provider = None + sales_contract_reference_provider_slug = validated_data.get('sales_contract_reference_provider_slug') + if sales_contract_reference_provider_slug: + sales_contract_reference_provider = SalesContractReferenceProvider.get( + slug=sales_contract_reference_provider_slug + ) + deposit, created = openedx_ledger.api.create_deposit( + ledger=subsidy.ledger, + quantity=validated_data['desired_deposit_quantity'], + sales_contract_reference_id=validated_data['sales_contract_reference_id'], + sales_contract_reference_provider=sales_contract_reference_provider, + idempotency_key=validated_data.get('idempotency_key'), + metadata=validated_data.get('metadata'), + ) + except SalesContractReferenceProvider.DoesNotExist as exc: + logger.exception( + 'Encountered a non-existant SalesContractReferenceProvider for given slug "%s"', + sales_contract_reference_provider_slug + ) + raise exc + except openedx_ledger.api.DepositCreationError as exc: + logger.error( + 'Encountered DepositCreationError while creating Deposit in subsidy %s using values %s', + subsidy.uuid, + validated_data, + ) + raise DepositCreationError(str(exc)) from exc + if not deposit: + logger.error( + 'Deposit was None after attempting deposit creation in subsidy %s using values %s', + subsidy.uuid, + validated_data, + ) + raise DepositCreationError('Deposit was None after attempting to redeem') + + self.calling_view.set_transaction_was_created(created) + return deposit diff --git a/enterprise_subsidy/apps/api/v2/views/deposit.py b/enterprise_subsidy/apps/api/v2/views/deposit.py new file mode 100644 index 00000000..15880019 --- /dev/null +++ b/enterprise_subsidy/apps/api/v2/views/deposit.py @@ -0,0 +1,105 @@ +""" +Views for the enterprise-subsidy service relating to the Deposit model +""" +import logging + +from django.utils.functional import cached_property +from drf_spectacular.utils import extend_schema +from edx_rbac.decorators import permission_required +from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication +from openedx_ledger.models import Deposit +from requests.exceptions import HTTPError +from rest_framework import generics, permissions, status +from rest_framework.authentication import SessionAuthentication +from rest_framework.exceptions import APIException, NotFound, PermissionDenied, Throttled + +from enterprise_subsidy.apps.api.exceptions import DepositCreationAPIException, ErrorCodes +from enterprise_subsidy.apps.api.utils import get_subsidy_customer_uuid_from_view +from enterprise_subsidy.apps.api.v1.serializers import ( + DepositCreationError, + DepositCreationRequestSerializer, + DepositSerializer +) +from enterprise_subsidy.apps.subsidy.api import get_subsidy_by_uuid +from enterprise_subsidy.apps.subsidy.constants import PERMISSION_CAN_CREATE_DEPOSITS + +logger = logging.getLogger(__name__) + + +class DepositAdminCreate(generics.CreateAPIView): + """ + A create-only API view for deposits. + + This is only accessible to admins of the related subsidy's enterprise customer. + """ + authentication_classes = [JwtAuthentication, SessionAuthentication] + permission_classes = [permissions.IsAuthenticated] + serializer_class = DepositCreationRequestSerializer + + def __init__(self, *args, **kwargs): + self.extra_context = {} + super().__init__(*args, **kwargs) + + @property + def requested_subsidy_uuid(self): + """ + Returns the requested ``subsidy_uuid`` path parameter. + """ + return self.kwargs.get('subsidy_uuid') + + @cached_property + def subsidy(self): + """ + Returns the Subsidy instance from the requested ``subsidy_uuid``. + """ + return get_subsidy_by_uuid(self.requested_subsidy_uuid, should_raise=True) + + def set_created(self, created): + self.extra_context['created'] = created + + @property + def created(self): + return self.extra_context.get('created', True) + + @permission_required(PERMISSION_CAN_CREATE_DEPOSITS, fn=get_subsidy_customer_uuid_from_view) + @extend_schema( + tags=['deposits'], + request=DepositCreationRequestSerializer, + responses={ + status.HTTP_200_OK: DepositSerializer, + status.HTTP_201_CREATED: DepositSerializer, + status.HTTP_403_FORBIDDEN: PermissionDenied, + status.HTTP_429_TOO_MANY_REQUESTS: Throttled, + status.HTTP_422_UNPROCESSABLE_ENTITY: APIException, + }, + ) + def create(self, request, subsidy_uuid): + """ + A create view that is accessible only to operators of the system. + + It creates (or just gets, if a matching Transaction is found with same ledger and idempotency_key) a + transaction via the `Subsidy.redeem()` method. Normally, the logic of this view + is responsible for determining the price of the requested content key, with which + the redeemed transaction's quantity will be valued. + + Note that, under some circumstances (for example, assigned learner content), it is + appropriate and allowable for the *caller* of this view to request a specific price + at which a redeemed transaction should occur. In these circumstances, this service + still does some validation of the requested price to ensure that it falls within + a reasonable interval around the *true* price of the related content key. See: + + https://github.com/openedx/enterprise-access/blob/main/docs/decisions/0012-assignment-based-policies.rst + https://github.com/openedx/enterprise-access/blob/main/docs/decisions/0014-assignment-price-validation.rst + """ + if not self.subsidy.is_active: + raise DepositCreationAPIException( + detail='Cannot create a deposit in an inactive subsidy', + code=ErrorCodes.INACTIVE_SUBSIDY_CREATION_ERROR, + ) + try: + response = super().create(request, subsidy_uuid) + if not self.created: + response.status_code = status.HTTP_200_OK + return response # The default create() response status is HTTP_201_CREATED + except (HTTPError, DepositCreationError) as exc: + raise DepositCreationAPIException(detail=str(exc)) from exc diff --git a/enterprise_subsidy/apps/api/v2/views/transaction.py b/enterprise_subsidy/apps/api/v2/views/transaction.py index 6f4c100d..e4ae787c 100644 --- a/enterprise_subsidy/apps/api/v2/views/transaction.py +++ b/enterprise_subsidy/apps/api/v2/views/transaction.py @@ -5,7 +5,7 @@ from django.utils.functional import cached_property from django_filters import rest_framework as drf_filters -from drf_spectacular.utils import extend_schema, extend_schema_view +from drf_spectacular.utils import extend_schema from edx_rbac.decorators import permission_required from edx_rest_framework_extensions.auth.jwt.authentication import JwtAuthentication from openedx_ledger.models import LedgerLockAttemptFailed, Transaction @@ -97,13 +97,14 @@ class TransactionAdminListCreate(TransactionBaseViewMixin, generics.ListCreateAP def __init__(self, *args, **kwargs): self.extra_context = {} - return super().__init__(*args, **kwargs) + super().__init__(*args, **kwargs) def get_serializer_class(self): if self.request.method.lower() == 'get': return TransactionSerializer if self.request.method.lower() == 'post': return TransactionCreationRequestSerializer + return None def set_transaction_was_created(self, created): self.extra_context['created'] = created diff --git a/enterprise_subsidy/apps/subsidy/constants.py b/enterprise_subsidy/apps/subsidy/constants.py index 8b101a52..2047593b 100644 --- a/enterprise_subsidy/apps/subsidy/constants.py +++ b/enterprise_subsidy/apps/subsidy/constants.py @@ -17,6 +17,7 @@ ENTERPRISE_SUBSIDY_OPERATOR_ROLE = 'enterprise_subsidy_operator' # Permissions directly control the specific code paths and functionality granted to the user within the subsidy app. +PERMISSION_CAN_CREATE_DEPOSITS = "subsidy.can_create_deposits" PERMISSION_CAN_CREATE_TRANSACTIONS = "subsidy.can_create_transactions" PERMISSION_CAN_READ_SUBSIDIES = "subsidy.can_read_subsidies" PERMISSION_CAN_READ_TRANSACTIONS = "subsidy.can_read_transactions" diff --git a/enterprise_subsidy/apps/subsidy/rules.py b/enterprise_subsidy/apps/subsidy/rules.py index b1ec680c..c5c659d7 100644 --- a/enterprise_subsidy/apps/subsidy/rules.py +++ b/enterprise_subsidy/apps/subsidy/rules.py @@ -11,6 +11,7 @@ ENTERPRISE_SUBSIDY_ADMIN_ROLE, ENTERPRISE_SUBSIDY_LEARNER_ROLE, ENTERPRISE_SUBSIDY_OPERATOR_ROLE, + PERMISSION_CAN_CREATE_DEPOSITS, PERMISSION_CAN_CREATE_TRANSACTIONS, PERMISSION_CAN_READ_ALL_TRANSACTIONS, PERMISSION_CAN_READ_CONTENT_METADATA, @@ -101,6 +102,7 @@ def has_explicit_access_to_subsidy_learner(user, context): # Finally, grant specific permissions to the appropriate access level. +rules.add_perm(PERMISSION_CAN_CREATE_DEPOSITS, has_operator_level_access) rules.add_perm(PERMISSION_CAN_CREATE_TRANSACTIONS, has_operator_level_access) rules.add_perm(PERMISSION_CAN_READ_SUBSIDIES, has_admin_level_access) rules.add_perm(PERMISSION_CAN_READ_TRANSACTIONS, has_learner_level_access)