From dc19dccc5fd7a2754fbb59faaaf79829095e4c97 Mon Sep 17 00:00:00 2001 From: Troy Sankey Date: Tue, 16 Jul 2024 15:19:13 -0700 Subject: [PATCH] feat: add API endpoint to create deposits ENT-9133 --- enterprise_subsidy/apps/api/exceptions.py | 29 ++- .../apps/api/v2/serializers/deposits.py | 149 ++++++++++++++ .../apps/api/v2/tests/test_deposit_views.py | 189 ++++++++++++++++++ .../apps/api/v2/tests/test_serializers.py | 66 ++++++ enterprise_subsidy/apps/api/v2/urls.py | 6 + .../apps/api/v2/views/deposit.py | 109 ++++++++++ .../apps/api/v2/views/transaction.py | 5 +- enterprise_subsidy/apps/subsidy/constants.py | 1 + enterprise_subsidy/apps/subsidy/rules.py | 2 + .../apps/subsidy/tests/factories.py | 2 +- 10 files changed, 550 insertions(+), 8 deletions(-) create mode 100644 enterprise_subsidy/apps/api/v2/serializers/deposits.py create mode 100644 enterprise_subsidy/apps/api/v2/tests/test_deposit_views.py create mode 100644 enterprise_subsidy/apps/api/v2/tests/test_serializers.py create mode 100644 enterprise_subsidy/apps/api/v2/views/deposit.py diff --git a/enterprise_subsidy/apps/api/exceptions.py b/enterprise_subsidy/apps/api/exceptions.py index f51077b6..24622219 100644 --- a/enterprise_subsidy/apps/api/exceptions.py +++ b/enterprise_subsidy/apps/api/exceptions.py @@ -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): """ @@ -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 diff --git a/enterprise_subsidy/apps/api/v2/serializers/deposits.py b/enterprise_subsidy/apps/api/v2/serializers/deposits.py new file mode 100644 index 00000000..0f421509 --- /dev/null +++ b/enterprise_subsidy/apps/api/v2/serializers/deposits.py @@ -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 diff --git a/enterprise_subsidy/apps/api/v2/tests/test_deposit_views.py b/enterprise_subsidy/apps/api/v2/tests/test_deposit_views.py new file mode 100644 index 00000000..2c8d64bf --- /dev/null +++ b/enterprise_subsidy/apps/api/v2/tests/test_deposit_views.py @@ -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 diff --git a/enterprise_subsidy/apps/api/v2/tests/test_serializers.py b/enterprise_subsidy/apps/api/v2/tests/test_serializers.py new file mode 100644 index 00000000..40b292f6 --- /dev/null +++ b/enterprise_subsidy/apps/api/v2/tests/test_serializers.py @@ -0,0 +1,66 @@ +""" +Tests for the serializers in the API. +""" +from uuid import uuid4 + +import ddt +from django.test import TestCase +from openedx_ledger.test_utils.factories import DepositFactory, SalesContractReferenceProviderFactory + +from enterprise_subsidy.apps.api.v2.serializers.deposits import DepositCreationRequestSerializer, DepositSerializer +from enterprise_subsidy.apps.subsidy.tests.factories import SubsidyFactory + + +@ddt.ddt +class TestDepositSerializer(TestCase): + """ + Tests for the DepositSerializer. + """ + @ddt.data( + { + "desired_deposit_quantity": 100, + "sales_contract_reference_id": str(uuid4()), + "set_sales_contract_reference_provider": True, + }, + { + "desired_deposit_quantity": 100, + "sales_contract_reference_id": None, + "set_sales_contract_reference_provider": False, + }, + ) + @ddt.unpack + def test_deposit_serializer( + self, + desired_deposit_quantity, + sales_contract_reference_id, + set_sales_contract_reference_provider, + ): + """ + Test that the DepositRequest serializer returns the correct values. + """ + # Set up the deposit to serialize. + subsidy = SubsidyFactory() + sales_contract_reference_provider = None + if set_sales_contract_reference_provider: + sales_contract_reference_provider = SalesContractReferenceProviderFactory(slug="good-provider-slug") + deposit = DepositFactory( + ledger=subsidy.ledger, + desired_deposit_quantity=desired_deposit_quantity, + sales_contract_reference_id=sales_contract_reference_id, + sales_contract_reference_provider=sales_contract_reference_provider, + ) + + # Serialize the deposit. + serializer = DepositSerializer(deposit) + data = serializer.data + + assert data["uuid"] == str(deposit.uuid) + assert data["ledger"] == subsidy.ledger.uuid + assert data["desired_deposit_quantity"] == desired_deposit_quantity + assert data["transaction"] == deposit.transaction.uuid + assert data["sales_contract_reference_id"] == sales_contract_reference_id + assert data["sales_contract_reference_provider"] == ( + "good-provider-slug" + if set_sales_contract_reference_provider + else None + ) diff --git a/enterprise_subsidy/apps/api/v2/urls.py b/enterprise_subsidy/apps/api/v2/urls.py index d87eb54e..a51f3be6 100644 --- a/enterprise_subsidy/apps/api/v2/urls.py +++ b/enterprise_subsidy/apps/api/v2/urls.py @@ -15,6 +15,7 @@ """ from django.urls import path +from enterprise_subsidy.apps.api.v2.views.deposit import DepositAdminCreate from enterprise_subsidy.apps.api.v2.views.transaction import TransactionAdminListCreate, TransactionUserList app_name = 'v2' @@ -31,4 +32,9 @@ TransactionUserList.as_view(), name='transaction-user-list', ), + path( + 'subsidies//admin/deposits/', + DepositAdminCreate.as_view(), + name='deposit-admin-create', + ), ] diff --git a/enterprise_subsidy/apps/api/v2/views/deposit.py b/enterprise_subsidy/apps/api/v2/views/deposit.py new file mode 100644 index 00000000..270dee96 --- /dev/null +++ b/enterprise_subsidy/apps/api/v2/views/deposit.py @@ -0,0 +1,109 @@ +""" +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.v2.serializers.deposits 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): + """ + Note: This created context setter/getter framework is really only here for when we eventually make deposit + creation actually idempotent, but for now it's just for show because we only ever pass True from the serializer. + """ + 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 diff --git a/enterprise_subsidy/apps/api/v2/views/transaction.py b/enterprise_subsidy/apps/api/v2/views/transaction.py index 6f4c100d..e4ae787c 100644 --- a/enterprise_subsidy/apps/api/v2/views/transaction.py +++ b/enterprise_subsidy/apps/api/v2/views/transaction.py @@ -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 @@ -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 def set_transaction_was_created(self, created): self.extra_context['created'] = created diff --git a/enterprise_subsidy/apps/subsidy/constants.py b/enterprise_subsidy/apps/subsidy/constants.py index 8b101a52..2047593b 100644 --- a/enterprise_subsidy/apps/subsidy/constants.py +++ b/enterprise_subsidy/apps/subsidy/constants.py @@ -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" diff --git a/enterprise_subsidy/apps/subsidy/rules.py b/enterprise_subsidy/apps/subsidy/rules.py index b1ec680c..c5c659d7 100644 --- a/enterprise_subsidy/apps/subsidy/rules.py +++ b/enterprise_subsidy/apps/subsidy/rules.py @@ -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, @@ -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) diff --git a/enterprise_subsidy/apps/subsidy/tests/factories.py b/enterprise_subsidy/apps/subsidy/tests/factories.py index a4228c39..da5b0d08 100644 --- a/enterprise_subsidy/apps/subsidy/tests/factories.py +++ b/enterprise_subsidy/apps/subsidy/tests/factories.py @@ -47,7 +47,7 @@ class Meta: reference_type = SubsidyReferenceChoices.SALESFORCE_OPPORTUNITY_LINE_ITEM enterprise_customer_uuid = factory.LazyFunction(uuid4) active_datetime = factory.LazyFunction(fake_datetime) - expiration_datetime = factory.LazyFunction(lambda: fake_datetime(True)) + expiration_datetime = factory.LazyFunction(lambda: fake_datetime(is_future=True)) revenue_category = RevenueCategoryChoices.BULK_ENROLLMENT_PREPAY internal_only = False title = factory.Faker("sentence")