-
Notifications
You must be signed in to change notification settings - Fork 6
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat: add API endpoint to create deposits
ENT-9133
- Loading branch information
Showing
6 changed files
with
284 additions
and
7 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters