-
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
10 changed files
with
550 additions
and
8 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( | ||
slug_field='slug', | ||
many=False, | ||
read_only=True, | ||
) | ||
|
||
class Meta: | ||
""" | ||
Meta class for DepositSerializer. | ||
""" | ||
model = Deposit | ||
fields = [ | ||
'uuid', | ||
'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( | ||
slug_field='slug', | ||
many=False, | ||
queryset=SalesContractReferenceProvider.objects.all(), | ||
) | ||
idempotency_key = serializers.CharField( | ||
help_text=( | ||
"An optional idempotency key that a client may want to associate with the " | ||
"related Transaction instance to be created." | ||
), | ||
required=False, | ||
) | ||
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, | ||
required=False, | ||
) | ||
|
||
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': True}, | ||
'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: | ||
enterprise_subsidy.apps.api.v2.serializers.deposits.DepositCreationError: | ||
Catch-all exception for when any other problem occurs during deposit creation. One possibility is that the | ||
caller attempted to create the same deposit twice. | ||
""" | ||
# subsidy() is a convenience property on the instance of the view class that uses this serializer. | ||
subsidy = self.calling_view.subsidy | ||
|
||
try: | ||
deposit = 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=validated_data['sales_contract_reference_provider'], | ||
idempotency_key=validated_data.get('idempotency_key'), | ||
**validated_data.get('metadata', {}), | ||
) | ||
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_created(True) | ||
return deposit |
189 changes: 189 additions & 0 deletions
189
enterprise_subsidy/apps/api/v2/tests/test_deposit_views.py
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,189 @@ | ||
""" | ||
Tests for the v2 deposit views. | ||
""" | ||
import uuid | ||
|
||
import ddt | ||
from openedx_ledger.models import Deposit, SalesContractReferenceProvider | ||
from rest_framework import status | ||
from rest_framework.reverse import reverse | ||
|
||
from enterprise_subsidy.apps.api.v1.tests.mixins import STATIC_ENTERPRISE_UUID, APITestMixin | ||
from enterprise_subsidy.apps.subsidy.models import SubsidyReferenceChoices | ||
from enterprise_subsidy.apps.subsidy.tests.factories import SubsidyFactory | ||
|
||
# This test depends on data migration subsidy.0022_backfill_initial_deposits having been run to seed the | ||
# SalesContractReferenceProvider table with a record that has this slug. | ||
DEFAULT_SALES_CONTRACT_REFERENCE_PROVIDER_SLUG = SubsidyReferenceChoices.SALESFORCE_OPPORTUNITY_LINE_ITEM | ||
|
||
|
||
@ddt.ddt | ||
class DepositCreateViewTests(APITestMixin): | ||
""" | ||
Test deposit creation via the deposit-admin-create view. | ||
""" | ||
|
||
@ddt.data( | ||
### | ||
# Happy paths: | ||
### | ||
{ | ||
"subsidy_active": True, | ||
# Typical request we expect to see 99% of the time. | ||
"creation_request_data": { | ||
"desired_deposit_quantity": 100, | ||
"sales_contract_reference_id": str(uuid.uuid4()), | ||
"sales_contract_reference_provider": DEFAULT_SALES_CONTRACT_REFERENCE_PROVIDER_SLUG, | ||
"metadata": {"foo": "bar"}, | ||
}, | ||
"expected_response_status": status.HTTP_201_CREATED, | ||
}, | ||
{ | ||
"subsidy_active": True, | ||
# Only the minimal set of required request fields included. | ||
"creation_request_data": { | ||
"desired_deposit_quantity": 100, | ||
"sales_contract_reference_id": str(uuid.uuid4()), | ||
"sales_contract_reference_provider": DEFAULT_SALES_CONTRACT_REFERENCE_PROVIDER_SLUG, | ||
}, | ||
"expected_response_status": status.HTTP_201_CREATED, | ||
}, | ||
### | ||
# Sad paths: | ||
### | ||
{ | ||
"subsidy_active": False, # Inactive subsidy invalidates request. | ||
"creation_request_data": { | ||
"desired_deposit_quantity": 100, | ||
"sales_contract_reference_id": str(uuid.uuid4()), | ||
"sales_contract_reference_provider": DEFAULT_SALES_CONTRACT_REFERENCE_PROVIDER_SLUG, | ||
}, | ||
"expected_response_status": status.HTTP_422_UNPROCESSABLE_ENTITY, | ||
}, | ||
{ | ||
"subsidy_active": True, | ||
"creation_request_data": { | ||
"desired_deposit_quantity": -100, # Invalid deposit quantity. | ||
"sales_contract_reference_id": str(uuid.uuid4()), | ||
"sales_contract_reference_provider": DEFAULT_SALES_CONTRACT_REFERENCE_PROVIDER_SLUG, | ||
}, | ||
"expected_response_status": status.HTTP_400_BAD_REQUEST, | ||
}, | ||
{ | ||
"subsidy_active": True, | ||
"creation_request_data": { | ||
"desired_deposit_quantity": 0, # Invalid deposit quantity. | ||
"sales_contract_reference_id": str(uuid.uuid4()), | ||
"sales_contract_reference_provider": DEFAULT_SALES_CONTRACT_REFERENCE_PROVIDER_SLUG, | ||
}, | ||
"expected_response_status": status.HTTP_400_BAD_REQUEST, | ||
}, | ||
{ | ||
"subsidy_active": True, | ||
"creation_request_data": { | ||
"desired_deposit_quantity": 100, | ||
"sales_contract_reference_id": str(uuid.uuid4()), | ||
"sales_contract_reference_provider": "totally-invalid-slug", # Slug doesn't have existing object in db. | ||
}, | ||
"expected_response_status": status.HTTP_400_BAD_REQUEST, | ||
}, | ||
) | ||
@ddt.unpack | ||
def test_deposit_creation( | ||
self, | ||
subsidy_active, | ||
creation_request_data, | ||
expected_response_status, | ||
): | ||
""" | ||
Test that the DepositCreationRequestSerializer correctly creates a deposit idempotently OR fails with the | ||
correct status code. | ||
""" | ||
self.set_up_operator() | ||
|
||
subsidy = SubsidyFactory(enterprise_customer_uuid=STATIC_ENTERPRISE_UUID) | ||
if not subsidy_active: | ||
subsidy.expiration_datetime = subsidy.active_datetime | ||
subsidy.save() | ||
|
||
url = reverse("api:v2:deposit-admin-create", args=[subsidy.uuid]) | ||
|
||
response = self.client.post(url, creation_request_data) | ||
assert response.status_code == expected_response_status | ||
if response.status_code < 300: | ||
assert response.data["ledger"] == subsidy.ledger.uuid | ||
assert response.data["desired_deposit_quantity"] == creation_request_data["desired_deposit_quantity"] | ||
assert response.data["sales_contract_reference_id"] == creation_request_data["sales_contract_reference_id"] | ||
assert response.data["sales_contract_reference_provider"] == \ | ||
creation_request_data["sales_contract_reference_provider"] | ||
assert Deposit.objects.count() == 2 | ||
created_deposit = Deposit.objects.get(uuid=response.data["uuid"]) | ||
assert created_deposit.transaction.metadata == creation_request_data.get("metadata", {}) | ||
else: | ||
assert Deposit.objects.count() == 1 | ||
|
||
response_x2 = self.client.post(url, creation_request_data) | ||
if expected_response_status == status.HTTP_201_CREATED: | ||
assert response_x2.status_code == status.HTTP_422_UNPROCESSABLE_ENTITY | ||
else: | ||
assert response_x2.status_code == expected_response_status | ||
|
||
@ddt.data( | ||
{ | ||
"role": "learner", | ||
"subsidy_enterprise_uuid": STATIC_ENTERPRISE_UUID, | ||
"expected_response_status": status.HTTP_403_FORBIDDEN, | ||
}, | ||
{ | ||
"role": "admin", | ||
"subsidy_enterprise_uuid": STATIC_ENTERPRISE_UUID, | ||
"expected_response_status": status.HTTP_403_FORBIDDEN, | ||
}, | ||
{ | ||
"role": "learner", | ||
"subsidy_enterprise_uuid": uuid.uuid4(), | ||
"expected_response_status": status.HTTP_403_FORBIDDEN, | ||
}, | ||
{ | ||
"role": "admin", | ||
"subsidy_enterprise_uuid": uuid.uuid4(), | ||
"expected_response_status": status.HTTP_403_FORBIDDEN, | ||
}, | ||
{ | ||
"role": "operator", | ||
"subsidy_enterprise_uuid": uuid.uuid4(), | ||
"expected_response_status": status.HTTP_201_CREATED, | ||
}, | ||
) | ||
@ddt.unpack | ||
def test_learners_and_admins_cannot_create_deposits( | ||
self, | ||
role, | ||
subsidy_enterprise_uuid, | ||
expected_response_status, | ||
): | ||
""" | ||
Neither learner nor admin roles should provide the ability to create transactions. | ||
""" | ||
if role == 'admin': | ||
self.set_up_admin() | ||
if role == 'learner': | ||
self.set_up_learner() | ||
if role == 'operator': | ||
self.set_up_operator() | ||
|
||
# Create a subsidy either in or not in the requesting user's enterprise. | ||
subsidy = SubsidyFactory(enterprise_customer_uuid=subsidy_enterprise_uuid) | ||
|
||
# Construct and make a request that is guaranteed to work if the user's role has correct permissions. | ||
url = reverse("api:v2:deposit-admin-create", args=[subsidy.uuid]) | ||
creation_request_data = { | ||
"desired_deposit_quantity": 50, | ||
"sales_contract_reference_id": "abc123", | ||
"sales_contract_reference_provider": SalesContractReferenceProvider.objects.first().slug, | ||
"metadata": {"foo": "bar"}, | ||
} | ||
response = self.client.post(url, creation_request_data) | ||
|
||
assert response.status_code == expected_response_status |
Oops, something went wrong.