Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add API endpoint to create deposits #274

Merged
merged 1 commit into from
Jul 18, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Loading