Skip to content

Commit

Permalink
feat: add API endpoint to create deposits
Browse files Browse the repository at this point in the history
ENT-9133
  • Loading branch information
pwnage101 committed Jul 16, 2024
1 parent 3df84ef commit 82a72d8
Show file tree
Hide file tree
Showing 6 changed files with 284 additions and 7 deletions.
29 changes: 24 additions & 5 deletions enterprise_subsidy/apps/api/exceptions.py
Original file line number Diff line number Diff line change
Expand Up @@ -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):
"""
Expand All @@ -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
149 changes: 149 additions & 0 deletions enterprise_subsidy/apps/api/v2/serializers.py
Original file line number Diff line number Diff line change
@@ -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
105 changes: 105 additions & 0 deletions enterprise_subsidy/apps/api/v2/views/deposit.py
Original file line number Diff line number Diff line change
@@ -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
5 changes: 3 additions & 2 deletions enterprise_subsidy/apps/api/v2/views/transaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Check warning on line 107 in enterprise_subsidy/apps/api/v2/views/transaction.py

View check run for this annotation

Codecov / codecov/patch

enterprise_subsidy/apps/api/v2/views/transaction.py#L107

Added line #L107 was not covered by tests

def set_transaction_was_created(self, created):
self.extra_context['created'] = created
Expand Down
1 change: 1 addition & 0 deletions enterprise_subsidy/apps/subsidy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
2 changes: 2 additions & 0 deletions enterprise_subsidy/apps/subsidy/rules.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 82a72d8

Please sign in to comment.