diff --git a/enterprise_access/apps/api/serializers/subsidy_access_policy.py b/enterprise_access/apps/api/serializers/subsidy_access_policy.py index e981d768..9471ba39 100755 --- a/enterprise_access/apps/api/serializers/subsidy_access_policy.py +++ b/enterprise_access/apps/api/serializers/subsidy_access_policy.py @@ -37,6 +37,13 @@ def policy_pre_write_validation(policy_instance_or_class, field_values_by_name): If a constraint occurs, raises a `ValidationError`. """ violations = [] + + if isinstance(policy_instance_or_class, SubsidyAccessPolicy): + policy_instance_or_class.clean() + else: + instance = policy_instance_or_class(**field_values_by_name) + instance.clean() + constraints = policy_instance_or_class.FIELD_CONSTRAINTS for field_name, new_value in field_values_by_name.items(): diff --git a/enterprise_access/apps/api/tests/test_serializers.py b/enterprise_access/apps/api/tests/test_serializers.py index b07d9e98..4969f47c 100644 --- a/enterprise_access/apps/api/tests/test_serializers.py +++ b/enterprise_access/apps/api/tests/test_serializers.py @@ -86,6 +86,7 @@ def test_aggregates( 'expiration_datetime': datetime.utcnow() + timedelta(days=1), 'current_balance': starting_balance - redeemed, 'is_active': True, + 'total_deposits': starting_balance } # Create a test policy with a limit set to ``policy_spend_limit``. Reminder: a value of 0 means no limit. @@ -205,6 +206,7 @@ def test_get_subsidy_end_date(self, mock_subsidy_record, mock_transactions_for_l 'expiration_datetime': subsidy_exp_date, 'active_datetime': '2020-01-01 12:00:00Z', 'current_balance': '1000', + 'total_deposits': '1000', } serializer = SubsidyAccessPolicyCreditsAvailableResponseSerializer( [self.redeemable_policy], diff --git a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py index a073f67a..7c45f85a 100755 --- a/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py +++ b/enterprise_access/apps/api/v1/tests/test_subsidy_access_policy_views.py @@ -10,6 +10,7 @@ import ddt from django.conf import settings +from django.core.exceptions import ValidationError from requests.exceptions import HTTPError from rest_framework import status from rest_framework.reverse import reverse @@ -95,6 +96,8 @@ def setup_subsidy_mocks(self): 'expiration_datetime': self.tomorrow, 'current_balance': 4, 'is_active': True, + 'starting_balance': 4, + 'total_deposits': 4, } subsidy_client_patcher = patch.object( SubsidyAccessPolicy, 'subsidy_client' @@ -648,6 +651,43 @@ def test_update_views(self, is_patch, request_payload): expected_response.update(request_payload) self.assertEqual(expected_response, response.json()) + def test_update_views_with_exceeding_spend_limit(self): + """ + Test that the update and partial_update views can modify certain + fields of a policy record. + """ + # Set the JWT-based auth to an operator. + self.set_jwt_cookie([ + {'system_wide_role': SYSTEM_ENTERPRISE_OPERATOR_ROLE, 'context': str(TEST_ENTERPRISE_UUID)} + ]) + + policy_for_edit = PerLearnerSpendCapLearnerCreditAccessPolicyFactory( + enterprise_customer_uuid=self.enterprise_uuid, + display_name='old display_name', + spend_limit=5, + active=False, + ) + + request_payload = { + 'description': 'the new description', + 'display_name': 'new display_name', + 'active': True, + 'retired': True, + 'catalog_uuid': str(uuid4()), + 'subsidy_uuid': str(uuid4()), + 'access_method': AccessMethods.ASSIGNED, + 'spend_limit': 6, + 'per_learner_spend_limit': 10000, + } + + url = reverse( + 'api:v1:subsidy-access-policies-detail', + kwargs={'uuid': str(policy_for_edit.uuid)} + ) + + with self.assertRaises(ValidationError): + self.client.patch(url, data=request_payload) + @ddt.data( { 'enterprise_customer_uuid': str(uuid4()), diff --git a/enterprise_access/apps/subsidy_access_policy/constants.py b/enterprise_access/apps/subsidy_access_policy/constants.py index ccded9d5..63587311 100644 --- a/enterprise_access/apps/subsidy_access_policy/constants.py +++ b/enterprise_access/apps/subsidy_access_policy/constants.py @@ -121,3 +121,9 @@ class MissingSubsidyAccessReasonUserMessages: SORT_BY_ENROLLMENT_COUNT = 'enrollment_count' GROUP_MEMBERS_WITH_AGGREGATES_DEFAULT_PAGE_SIZE = 10 + +# Exceeding the spend_limit validation error +VALIDATION_ERROR_SPEND_LIMIT_EXCEEDS_STARTING_BALANCE = "You cannot make this change, as the value of all budget \ +limits would exceed the funds available on the subsidy. Please double-check the subsidy’s initial value and any \ +adjustments, then ensure the budgets sum to an equal or lower amount. If you are trying to re-balance policies, \ +please reduce the value of one first, then proceed to increase the value of another" diff --git a/enterprise_access/apps/subsidy_access_policy/models.py b/enterprise_access/apps/subsidy_access_policy/models.py index 287984c3..1ba179df 100644 --- a/enterprise_access/apps/subsidy_access_policy/models.py +++ b/enterprise_access/apps/subsidy_access_policy/models.py @@ -38,6 +38,7 @@ REASON_POLICY_EXPIRED, REASON_POLICY_SPEND_LIMIT_REACHED, REASON_SUBSIDY_EXPIRED, + VALIDATION_ERROR_SPEND_LIMIT_EXCEEDS_STARTING_BALANCE, AccessMethods, TransactionStateChoices ) @@ -298,6 +299,33 @@ def get_policy_class_by_type(cls, policy_type): return policy_class return None + @property + def total_spend_limit_for_all_policies_associated_to_subsidy(self): + """ + Sums the policies spend_limit excluding the db's instance of this policy + """ + policy_balances = [] + policy_balances.append(self.spend_limit or 0) + sibling_policies = SubsidyAccessPolicy.objects.filter( + enterprise_customer_uuid=self.enterprise_customer_uuid, + subsidy_uuid=self.subsidy_uuid, + ).exclude(uuid=self.uuid) + + for sibling in sibling_policies: + policy_balances.append(sibling.spend_limit or 0) + return sum(policy_balances) + + @property + def is_spend_limit_updated(self): + """ + Checks if SubsidyAccessPolicy object exists in the database, and determines if the + database value of spend_limit differs from the current instance of spend_limit + """ + if self._state.adding: + return False + record_from_db = SubsidyAccessPolicy.objects.get(uuid=self.uuid) + return record_from_db.spend_limit != self.spend_limit + @property def is_assignable(self): """ @@ -309,6 +337,9 @@ def clean(self): """ Used to help validate field values before saving this model instance. """ + if self.is_spend_limit_updated: + if self.total_spend_limit_for_all_policies_associated_to_subsidy > self.subsidy_total_deposits(): + raise ValidationError(f'{self} {VALIDATION_ERROR_SPEND_LIMIT_EXCEEDS_STARTING_BALANCE}') for field_name, (constraint_function, error_message) in self.FIELD_CONSTRAINTS.items(): field = getattr(self, field_name) if not constraint_function(field): @@ -403,6 +434,13 @@ def subsidy_balance(self): current_balance = self.subsidy_record().get('current_balance') or 0 return int(current_balance) + def subsidy_total_deposits(self): + """ + Returns total remaining balance for the associated subsidy ledger. + """ + total_deposits = self.subsidy_record().get('total_deposits') or 0 + return int(total_deposits) + @property def spend_available(self): """