From d12fb574dd28907fea5b24ee93f44bca44325727 Mon Sep 17 00:00:00 2001 From: Alex Dusenbery Date: Tue, 29 Aug 2023 10:00:41 -0400 Subject: [PATCH] feat: can_allocate() implementation for assignment-based policies. ENT-7532 --- .../apps/content_assignments/api.py | 39 +++ .../apps/content_assignments/models.py | 4 +- .../content_assignments/tests/__init__.py | 0 .../content_assignments/tests/factories.py | 37 +++ .../content_assignments/tests/test_api.py | 95 ++++++++ .../apps/subsidy_access_policy/models.py | 51 ++++ .../subsidy_access_policy/tests/factories.py | 14 ++ .../tests/test_models.py | 226 +++++++++++++++++- 8 files changed, 456 insertions(+), 10 deletions(-) create mode 100644 enterprise_access/apps/content_assignments/api.py create mode 100644 enterprise_access/apps/content_assignments/tests/__init__.py create mode 100644 enterprise_access/apps/content_assignments/tests/factories.py create mode 100644 enterprise_access/apps/content_assignments/tests/test_api.py diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py new file mode 100644 index 00000000..9c532af8 --- /dev/null +++ b/enterprise_access/apps/content_assignments/api.py @@ -0,0 +1,39 @@ +""" +Primary Python API for interacting with Assignment +records and business logic. +""" +from django.db.models import Sum + +from .constants import LearnerContentAssignmentStateChoices +from .models import LearnerContentAssignment + + +def get_assignments_for_policy( + subsidy_access_policy, + state=LearnerContentAssignmentStateChoices.ALLOCATED, +): + """ + Returns a queryset of all ``LearnerContentAssignment`` records + for the given policy, optionally filtered to only those + associated with the given ``learner_emails``. + """ + queryset = LearnerContentAssignment.objects.select_related( + 'assignment_policy', + 'assignment_policy__subsidy_access_policy', + ).filter( + assignment_policy__subsidy_access_policy=subsidy_access_policy, + state=state, + ) + return queryset + + +def get_allocated_quantity_for_policy(subsidy_access_policy): + """ + Returns a float representing the total quantity, in USD cents, currently allocated + via Assignments for the given policy. + """ + assignments_queryset = get_assignments_for_policy(subsidy_access_policy) + aggregate = assignments_queryset.aggregate( + total_quantity=Sum('content_quantity'), + ) + return aggregate['total_quantity'] diff --git a/enterprise_access/apps/content_assignments/models.py b/enterprise_access/apps/content_assignments/models.py index b89ed572..033378f5 100644 --- a/enterprise_access/apps/content_assignments/models.py +++ b/enterprise_access/apps/content_assignments/models.py @@ -7,8 +7,6 @@ from django_extensions.db.models import TimeStampedModel from simple_history.models import HistoricalRecords -from enterprise_access.apps.subsidy_access_policy.models import SubsidyAccessPolicy - from .constants import LearnerContentAssignmentStateChoices @@ -25,7 +23,7 @@ class AssignmentPolicy(TimeStampedModel): unique=True, ) subsidy_access_policy = models.ForeignKey( - SubsidyAccessPolicy, + 'subsidy_access_policy.SubsidyAccessPolicy', related_name="assignment_policy", on_delete=models.CASCADE, db_index=True, diff --git a/enterprise_access/apps/content_assignments/tests/__init__.py b/enterprise_access/apps/content_assignments/tests/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/enterprise_access/apps/content_assignments/tests/factories.py b/enterprise_access/apps/content_assignments/tests/factories.py new file mode 100644 index 00000000..e09d80d3 --- /dev/null +++ b/enterprise_access/apps/content_assignments/tests/factories.py @@ -0,0 +1,37 @@ +""" +Factoryboy factories. +""" + +from uuid import uuid4 + +import factory +from faker import Faker + +from ..models import LearnerContentAssignment + +FAKER = Faker() + + +def random_content_key(): + """ + Helper to craft a random content key. + """ + fake_words = [ + FAKER.word() + str(FAKER.random_int()) + for _ in range(3) + ] + return 'course-v1:{}+{}+{}'.format(*fake_words) + + +class LearnerContentAssignmentFactory(factory.django.DjangoModelFactory): + """ + Base Test factory for the ``LearnerContentAssisgnment`` model. + """ + class Meta: + model = LearnerContentAssignment + + uuid = factory.LazyFunction(uuid4) + learner_email = factory.LazyAttribute(lambda _: FAKER.email()) + lms_user_id = factory.LazyAttribute(lambda _: FAKER.pyint()) + content_key = factory.LazyAttribute(lambda _: random_content_key()) + content_quantity = factory.LazyAttribute(lambda _: FAKER.pyint()) diff --git a/enterprise_access/apps/content_assignments/tests/test_api.py b/enterprise_access/apps/content_assignments/tests/test_api.py new file mode 100644 index 00000000..b223ea23 --- /dev/null +++ b/enterprise_access/apps/content_assignments/tests/test_api.py @@ -0,0 +1,95 @@ +""" +Tests for the ``api.py`` module of the content_assignments app. +""" +import uuid + +from django.test import TestCase + +from ...subsidy_access_policy.tests.factories import AssignedLearnerCreditAccessPolicyFactory +from ..api import get_allocated_quantity_for_policy, get_assignments_for_policy +from ..constants import LearnerContentAssignmentStateChoices +from ..models import AssignmentPolicy +from .factories import LearnerContentAssignmentFactory + +ACTIVE_ASSIGNED_LEARNER_CREDIT_POLICY_UUID = uuid.uuid4() + + +class TestContentAssignmentApi(TestCase): + """ + Tests functions of the ``content_assignment.api`` module. + """ + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.active_policy = AssignedLearnerCreditAccessPolicyFactory( + uuid=ACTIVE_ASSIGNED_LEARNER_CREDIT_POLICY_UUID, + spend_limit=10000, + ) + cls.assignment_policy = AssignmentPolicy.objects.create( + subsidy_access_policy=cls.active_policy, + ) + + def test_get_assignments_for_policy(self): + """ + Simple test to fetch assignment records related to a given policy. + """ + expected_assignments = [ + LearnerContentAssignmentFactory.create( + assignment_policy=self.assignment_policy, + ) for _ in range(10) + ] + + with self.assertNumQueries(1): + actual_assignments = list(get_assignments_for_policy(self.active_policy)) + + self.assertEqual( + sorted(actual_assignments, key=lambda record: record.uuid), + sorted(expected_assignments, key=lambda record: record.uuid), + ) + + def test_get_assignments_for_policy_different_states(self): + """ + Simple test to fetch assignment records related to a given policy, + filtered among different states + """ + expected_assignments = { + LearnerContentAssignmentStateChoices.CANCELLED: [], + LearnerContentAssignmentStateChoices.ACCEPTED: [], + } + for index in range(10): + if index % 2: + state = LearnerContentAssignmentStateChoices.CANCELLED + else: + state = LearnerContentAssignmentStateChoices.ACCEPTED + + expected_assignments[state].append( + LearnerContentAssignmentFactory.create(assignment_policy=self.assignment_policy, state=state) + ) + + for filter_state in ( + LearnerContentAssignmentStateChoices.CANCELLED, + LearnerContentAssignmentStateChoices.ACCEPTED, + ): + with self.assertNumQueries(1): + actual_assignments = list(get_assignments_for_policy(self.active_policy, filter_state)) + + self.assertEqual( + sorted(actual_assignments, key=lambda record: record.uuid), + sorted(expected_assignments[filter_state], key=lambda record: record.uuid), + ) + + def test_get_allocated_quantity_for_policy(self): + """ + Tests to verify that we can fetch the total allocated quantity across a set of assignments + related to some policy. + """ + for amount in (1000, 2000, 3000): + LearnerContentAssignmentFactory.create( + assignment_policy=self.assignment_policy, + content_quantity=amount, + ) + + with self.assertNumQueries(1): + actual_amount = get_allocated_quantity_for_policy(self.active_policy) + self.assertEqual(actual_amount, 6000) diff --git a/enterprise_access/apps/subsidy_access_policy/models.py b/enterprise_access/apps/subsidy_access_policy/models.py index 3858fc95..57490966 100644 --- a/enterprise_access/apps/subsidy_access_policy/models.py +++ b/enterprise_access/apps/subsidy_access_policy/models.py @@ -15,6 +15,7 @@ from edx_django_utils.cache.utils import get_cache_key from enterprise_access.apps.api_client.lms_client import LmsApiClient +from enterprise_access.apps.content_assignments import api as assignments_api from .constants import ( CREDIT_POLICY_TYPE_PRIORITY, @@ -809,3 +810,53 @@ def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False): def redeem(self, lms_user_id, content_key, all_transactions, metadata=None): raise NotImplementedError + + def can_allocate(self, number_of_learners, content_key, content_price_cents): + """ + Takes allocated LearnerContentAssignment records related to this policy + into account to determine if ``number_of_learners`` new assignment + records can be allocated in this policy for the given ``content_key`` + and it's current ``content_price_cents``. + """ + # inactive policy + if not self.active: + return (False, REASON_POLICY_EXPIRED) + + # no content key in catalog + if not self.catalog_contains_content_key(content_key): + return (False, REASON_CONTENT_NOT_IN_CATALOG) + + if not self.is_subsidy_active: + return (False, REASON_SUBSIDY_EXPIRED) + + # Determine total cost, in cents, of content to potentially allocated + total_price_cents = number_of_learners * content_price_cents + + # Determine total amount, in cents, already transacted via this policy. + # This is a number <= 0 + spent_amount_cents = self.aggregates_for_policy().get('total_quantity') or 0 + + # Determine total amount, in cents, of assignments already + # allocated via this policy. This is a number <= 0 + total_allocated_assignments_cents = assignments_api.get_allocated_quantity_for_policy(self) + total_allocated_and_spent_cents = spent_amount_cents + total_allocated_assignments_cents + + # Use all of these pieces to ensure that the assignments to potentially + # allocate won't exceed the remaining balance of the related subsidy. + if self.content_would_exceed_limit( + total_allocated_and_spent_cents, + self.subsidy_balance(), + total_price_cents, + ): + return (False, REASON_NOT_ENOUGH_VALUE_IN_SUBSIDY) + + # Lastly, use all of these pieces to ensure that the assignments to potentially + # allocate won't exceed the spend limit of this policy + if self.content_would_exceed_limit( + total_allocated_and_spent_cents, + self.spend_limit, + total_price_cents, + ): + return (False, REASON_POLICY_SPEND_LIMIT_REACHED) + + return (True, None) diff --git a/enterprise_access/apps/subsidy_access_policy/tests/factories.py b/enterprise_access/apps/subsidy_access_policy/tests/factories.py index 863eed9c..0ff6bfc9 100644 --- a/enterprise_access/apps/subsidy_access_policy/tests/factories.py +++ b/enterprise_access/apps/subsidy_access_policy/tests/factories.py @@ -9,6 +9,7 @@ from enterprise_access.apps.subsidy_access_policy.constants import AccessMethods from enterprise_access.apps.subsidy_access_policy.models import ( + AssignedLearnerCreditAccessPolicy, PerLearnerEnrollmentCreditAccessPolicy, PerLearnerSpendCreditAccessPolicy ) @@ -47,3 +48,16 @@ class PerLearnerSpendCapLearnerCreditAccessPolicyFactory(SubsidyAccessPolicyFact class Meta: model = PerLearnerSpendCreditAccessPolicy + + +class AssignedLearnerCreditAccessPolicyFactory(SubsidyAccessPolicyFactory): + """ + Test factory for the `AssignedLearnerCreditAccessPolicy` model. + """ + + class Meta: + model = AssignedLearnerCreditAccessPolicy + + access_method = AccessMethods.ASSIGNED + per_learner_spend_limit = None + per_learner_enrollment_limit = None diff --git a/enterprise_access/apps/subsidy_access_policy/tests/test_models.py b/enterprise_access/apps/subsidy_access_policy/tests/test_models.py index 9ea684d2..cfc73bc3 100644 --- a/enterprise_access/apps/subsidy_access_policy/tests/test_models.py +++ b/enterprise_access/apps/subsidy_access_policy/tests/test_models.py @@ -8,6 +8,7 @@ import ddt import pytest from django.core.cache import cache as django_cache +from django.core.exceptions import ValidationError from django.test import TestCase, override_settings from enterprise_access.apps.subsidy_access_policy.constants import ( @@ -21,27 +22,30 @@ REASON_SUBSIDY_EXPIRED ) from enterprise_access.apps.subsidy_access_policy.models import ( + AssignedLearnerCreditAccessPolicy, PerLearnerEnrollmentCreditAccessPolicy, PerLearnerSpendCreditAccessPolicy, SubsidyAccessPolicy, SubsidyAccessPolicyLockAttemptFailed ) from enterprise_access.apps.subsidy_access_policy.tests.factories import ( + AssignedLearnerCreditAccessPolicyFactory, PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory, PerLearnerSpendCapLearnerCreditAccessPolicyFactory ) +from ..constants import AccessMethods + ACTIVE_LEARNER_SPEND_CAP_POLICY_UUID = uuid4() ACTIVE_LEARNER_ENROLL_CAP_POLICY_UUID = uuid4() +ACTIVE_ASSIGNED_LEARNER_CREDIT_POLICY_UUID = uuid4() -@ddt.ddt -class SubsidyAccessPolicyTests(TestCase): - """ SubsidyAccessPolicy model tests. """ - - lms_user_id = 12345 - course_id = 'course-v1:DemoX:2T2023' - +class MockPolicyDependenciesMixin: + """ + Mixin to help mock out all access policy dependencies + on external services. + """ def setUp(self): """ Initialize mocked service clients. @@ -79,6 +83,14 @@ def setUp(self): self.addCleanup(lms_api_client_patcher.stop) self.addCleanup(django_cache.clear) # clear any leftover policy locks. + +@ddt.ddt +class SubsidyAccessPolicyTests(MockPolicyDependenciesMixin, TestCase): + """ SubsidyAccessPolicy model tests. """ + + lms_user_id = 12345 + course_id = 'course-v1:DemoX:2T2023' + @classmethod def setUpClass(cls): super().setUpClass() @@ -678,3 +690,203 @@ def test_resolve_two_policies_by_type_priority(self): with patch.object(PerLearnerSpendCreditAccessPolicy, 'priority', new_callable=PropertyMock) as mock: mock.return_value = 100 assert SubsidyAccessPolicy.resolve_policy(policies) == self.policy_one + + +@ddt.ddt +class AssignedLearnerCreditAccessPolicyTests(MockPolicyDependenciesMixin, TestCase): + """ Tests specific to the assigned learner credit type of access policy. """ + + lms_user_id = 12345 + content_key = 'course-v1:DemoX:2T2023' + + def setUp(self): + """ + Mocks out dependencies on other services, as well as dependencies + on the Assignments API module. + """ + super().setUp() + + assignments_api_patcher = patch( + 'enterprise_access.apps.subsidy_access_policy.models.assignments_api', + ) + self.mock_assignments_api = assignments_api_patcher.start() + self.addCleanup(assignments_api_patcher.stop) + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.active_policy = AssignedLearnerCreditAccessPolicyFactory( + uuid=ACTIVE_ASSIGNED_LEARNER_CREDIT_POLICY_UUID, + spend_limit=10000, + ) + cls.inactive_policy = AssignedLearnerCreditAccessPolicyFactory( + active=False, + spend_limit=10000, + ) + + def test_clean(self): + """ + Tests the model-level validation of this policy type. + """ + with self.assertRaisesRegex(ValidationError, 'must define a spend_limit'): + AssignedLearnerCreditAccessPolicy(spend_limit=None).clean() + with self.assertRaisesRegex(ValidationError, 'must not define a per-learner spend limit'): + AssignedLearnerCreditAccessPolicy(spend_limit=1, per_learner_spend_limit=1).clean() + with self.assertRaisesRegex(ValidationError, 'must not define a per-learner enrollment limit'): + AssignedLearnerCreditAccessPolicy(spend_limit=1, per_learner_enrollment_limit=1).clean() + + def test_save(self): + """ + These types of policies should always get saved with an + access_method of 'assigned'. + """ + policy = AssignedLearnerCreditAccessPolicyFactory( + access_method=AccessMethods.DIRECT, + ) + policy.save() + + self.assertEqual(policy.access_method, AccessMethods.ASSIGNED) + + def test_can_redeem(self): + """ + Test stub for non-implemented method. + """ + with self.assertRaises(NotImplementedError): + self.active_policy.can_redeem(123, 'abc') + + def test_redeem(self): + """ + Test stub for non-implemented method. + """ + with self.assertRaises(NotImplementedError): + self.active_policy.redeem(123, 'abc', []) + + def test_can_allocate_inactive_policy(self): + """ + Tests that inactive policies can't be allocated against. + """ + can_allocate, message = self.inactive_policy.can_allocate(10, self.content_key, 1000) + + self.assertFalse(can_allocate) + self.assertEqual(message, REASON_POLICY_EXPIRED) + + def test_can_allocate_content_not_in_catalog(self): + """ + Tests that active policies can't be allocated against for content + that is not included in the related catalog. + """ + self.mock_catalog_contains_content_key.return_value = False + + can_allocate, message = self.active_policy.can_allocate(10, self.content_key, 1000) + + self.assertFalse(can_allocate) + self.assertEqual(message, REASON_CONTENT_NOT_IN_CATALOG) + self.mock_catalog_contains_content_key.assert_called_once_with(self.content_key) + + def test_can_allocate_subsidy_inactive(self): + """ + Test that active policies of this type can't be allocated + against if the related subsidy is inactive. + """ + self.mock_catalog_contains_content_key.return_value = True + mock_subsidy = { + 'id': 12345, + 'is_active': False, + } + self.mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + + can_allocate, message = self.active_policy.can_allocate(10, self.content_key, 1000) + + self.assertFalse(can_allocate) + self.assertEqual(message, REASON_SUBSIDY_EXPIRED) + self.mock_catalog_contains_content_key.assert_called_once_with(self.content_key) + self.mock_subsidy_client.retrieve_subsidy.assert_called_once_with( + subsidy_uuid=self.active_policy.subsidy_uuid, + ) + + def test_can_allocate_not_enough_subsidy_balance(self): + """ + Test that active policies of this type can't be allocated + against if the related subsidy does not have enough remaining balance. + """ + self.mock_catalog_contains_content_key.return_value = True + mock_subsidy = { + 'id': 12345, + 'is_active': True, + 'current_balance': 7999, + } + self.mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + transactions_for_policy = { + 'transactions': [], # we don't actually use this + 'aggregates': { + 'total_quantity': -500, + }, + } + self.mock_subsidy_client.list_subsidy_transactions.return_value = transactions_for_policy + self.mock_assignments_api.get_allocated_quantity_for_policy.return_value = -500 + + # The balance of the subsidy is just a bit less + # than the amount to potentially allocated, e.g. + # ((7 * 1000) + 500 + 500) > 7999 + can_allocate, message = self.active_policy.can_allocate(7, self.content_key, 1000) + + self.assertFalse(can_allocate) + self.assertEqual(message, REASON_NOT_ENOUGH_VALUE_IN_SUBSIDY) + + def test_can_allocate_spend_limit_exceeded(self): + """ + Test that active policies of this type can't be allocated + against if it would exceed the policy spend_limit. + """ + self.mock_catalog_contains_content_key.return_value = True + mock_subsidy = { + 'id': 12345, + 'is_active': True, + 'current_balance': 15000, + } + self.mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + transactions_for_policy = { + 'transactions': [], # we don't actually use this + 'aggregates': { + 'total_quantity': -2000, + }, + } + self.mock_subsidy_client.list_subsidy_transactions.return_value = transactions_for_policy + self.mock_assignments_api.get_allocated_quantity_for_policy.return_value = -2000 + + # The balance of the subsidy is just a bit less + # than the amount to potentially allocated, e.g. + # ((7 * 1000) + 2000 + 2000) < 15000 (the subsidy balance) but, + # ((7 * 1000) + 2000 + 2000) > 10000 (the policy spend limit) + can_allocate, message = self.active_policy.can_allocate(7, self.content_key, 1000) + + self.assertFalse(can_allocate) + self.assertEqual(message, REASON_POLICY_SPEND_LIMIT_REACHED) + + def test_can_allocate_happy_path(self): + """ + Test that active policies of this type can be allocated + against if there's enough remaining balance and the total + of (allocated + potentially allocated + spent) < spend_limit. + """ + self.mock_catalog_contains_content_key.return_value = True + mock_subsidy = { + 'id': 12345, + 'is_active': True, + 'current_balance': 10000, + } + self.mock_subsidy_client.retrieve_subsidy.return_value = mock_subsidy + transactions_for_policy = { + 'transactions': [], # we don't actually use this + 'aggregates': { + 'total_quantity': -1000, + }, + } + self.mock_subsidy_client.list_subsidy_transactions.return_value = transactions_for_policy + self.mock_assignments_api.get_allocated_quantity_for_policy.return_value = -1000 + + # the subidy remaining balance and the spend limit are both 10,000 + # ((7 * 1000) + 1000 + 1000) < 10000 + can_allocate, _ = self.active_policy.can_allocate(7, self.content_key, 1000) + + self.assertTrue(can_allocate)