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 18, 2024
1 parent 3df84ef commit dc19dcc
Show file tree
Hide file tree
Showing 10 changed files with 550 additions and 8 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/deposits.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(
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(

Check warning on line 141 in enterprise_subsidy/apps/api/v2/serializers/deposits.py

View check run for this annotation

Codecov / codecov/patch

enterprise_subsidy/apps/api/v2/serializers/deposits.py#L141

Added line #L141 was not covered by tests
'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')

Check warning on line 146 in enterprise_subsidy/apps/api/v2/serializers/deposits.py

View check run for this annotation

Codecov / codecov/patch

enterprise_subsidy/apps/api/v2/serializers/deposits.py#L146

Added line #L146 was not covered by tests

self.calling_view.set_created(True)
return deposit
189 changes: 189 additions & 0 deletions enterprise_subsidy/apps/api/v2/tests/test_deposit_views.py
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
Loading

0 comments on commit dc19dcc

Please sign in to comment.