Skip to content

Commit

Permalink
feat: validates spend limit against subsidy balance (#477)
Browse files Browse the repository at this point in the history
  • Loading branch information
brobro10000 authored Jun 5, 2024
1 parent 430d768 commit 61f5180
Show file tree
Hide file tree
Showing 5 changed files with 93 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -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():
Expand Down
2 changes: 2 additions & 0 deletions enterprise_access/apps/api/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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'
Expand Down Expand Up @@ -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()),
Expand Down
6 changes: 6 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
38 changes: 38 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@
REASON_POLICY_EXPIRED,
REASON_POLICY_SPEND_LIMIT_REACHED,
REASON_SUBSIDY_EXPIRED,
VALIDATION_ERROR_SPEND_LIMIT_EXCEEDS_STARTING_BALANCE,
AccessMethods,
TransactionStateChoices
)
Expand Down Expand Up @@ -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):
"""
Expand All @@ -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):
Expand Down Expand Up @@ -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):
"""
Expand Down

0 comments on commit 61f5180

Please sign in to comment.