diff --git a/.annotation_safe_list.yml b/.annotation_safe_list.yml index 42831b77..772dc7d1 100644 --- a/.annotation_safe_list.yml +++ b/.annotation_safe_list.yml @@ -47,6 +47,8 @@ subsidy_request.HistoricalCouponCodeRequest: ".. no_pii:": "This model has no PII" subsidy_request.HistoricalSubsidyRequestCustomerConfiguration: ".. no_pii:": "This model has no PII" +subsidy_access_policy.HistoricalForcedPolicyRedemption: + ".. no_pii:": "This model has no PII" subsidy_access_policy.HistoricalSubsidyAccessPolicy: ".. no_pii:": "This model has no PII" content_assignments.HistoricalAssignmentConfiguration: diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 0d37bc15..3432c93e 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -12,7 +12,7 @@ jobs: strategy: matrix: python-version: ["3.8"] - django-version: ["pinned", "4.2"] + django-version: ["pinned"] steps: - uses: actions/checkout@v2 - uses: actions/setup-python@v2 diff --git a/enterprise_access/apps/content_assignments/api.py b/enterprise_access/apps/content_assignments/api.py index 525a1aef..ef754462 100644 --- a/enterprise_access/apps/content_assignments/api.py +++ b/enterprise_access/apps/content_assignments/api.py @@ -23,7 +23,7 @@ ) from enterprise_access.apps.core.models import User from enterprise_access.apps.subsidy_access_policy.content_metadata_api import get_and_cache_content_metadata -from enterprise_access.utils import get_automatic_expiration_date_and_reason, localized_utcnow +from enterprise_access.utils import chunks, get_automatic_expiration_date_and_reason, localized_utcnow from .constants import AssignmentAutomaticExpiredReason, LearnerContentAssignmentStateChoices from .models import AssignmentConfiguration, LearnerContentAssignment @@ -32,7 +32,6 @@ send_assignment_automatically_expired_email, send_email_for_new_assignment ) -from .utils import chunks logger = logging.getLogger(__name__) @@ -146,7 +145,7 @@ def get_assignments_for_admin( def _normalize_course_key_from_metadata(assignment_configuration, content_key): """ - Helper method to take a course run key and normalize it into a coourse key + Helper method to take a course run key and normalize it into a course key utilizing the enterprise subsidy content metadata summary endpoint. """ diff --git a/enterprise_access/apps/content_assignments/models.py b/enterprise_access/apps/content_assignments/models.py index 42e54c1b..8032dece 100644 --- a/enterprise_access/apps/content_assignments/models.py +++ b/enterprise_access/apps/content_assignments/models.py @@ -16,6 +16,8 @@ from simple_history.models import HistoricalRecords from simple_history.utils import bulk_create_with_history, bulk_update_with_history +from enterprise_access.utils import format_traceback + from .constants import ( NUM_DAYS_BEFORE_AUTO_EXPIRATION, RETIRED_EMAIL_ADDRESS, @@ -25,7 +27,6 @@ AssignmentRecentActionTypes, LearnerContentAssignmentStateChoices ) -from .utils import format_traceback logger = logging.getLogger(__name__) diff --git a/enterprise_access/apps/content_assignments/tests/factories.py b/enterprise_access/apps/content_assignments/tests/factories.py index efacb944..0b19e543 100644 --- a/enterprise_access/apps/content_assignments/tests/factories.py +++ b/enterprise_access/apps/content_assignments/tests/factories.py @@ -7,22 +7,13 @@ import factory from faker import Faker +from test_utils import random_content_key + from ..models import AssignmentConfiguration, 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 AssignmentConfigurationFactory(factory.django.DjangoModelFactory): """ Base Test factory for the ``AssignmentConfiguration`` model. diff --git a/enterprise_access/apps/content_assignments/utils.py b/enterprise_access/apps/content_assignments/utils.py index e8ec4155..e69de29b 100644 --- a/enterprise_access/apps/content_assignments/utils.py +++ b/enterprise_access/apps/content_assignments/utils.py @@ -1,17 +0,0 @@ -""" -Utility functions for the content_assignments app. -""" -import traceback - - -def chunks(a_list, chunk_size): - """ - Helper to break a list up into chunks. Returns a generator of lists. - """ - for i in range(0, len(a_list), chunk_size): - yield a_list[i:i + chunk_size] - - -def format_traceback(exception): - trace = ''.join(traceback.format_tb(exception.__traceback__)) - return f'{exception}\n{trace}' diff --git a/enterprise_access/apps/subsidy_access_policy/admin/__init__.py b/enterprise_access/apps/subsidy_access_policy/admin/__init__.py index 9bffd140..b4049cdf 100644 --- a/enterprise_access/apps/subsidy_access_policy/admin/__init__.py +++ b/enterprise_access/apps/subsidy_access_policy/admin/__init__.py @@ -337,3 +337,42 @@ class PolicyGroupAssociationAdmin(admin.ModelAdmin): 'subsidy_access_policy', 'enterprise_group_uuid', ) + + +@admin.register(models.SubsidyAccessPolicy) +class SubsidAccessPolicyAdmin(admin.ModelAdmin): + """ + We need this not-particularly-useful admin class + to let the ForcedPolicyRedemptionAdmin class refer + to subsidy access policies, of all types, via its + ``autocomplete_fields``. + It's hidden from the admin index page. + """ + fields = [] + search_fields = [ + 'uuid', + 'display_name', + ] + + def has_module_permission(self, request): + """ + Hide this view from the admin index page. + """ + return False + + def has_change_permission(self, request, obj=None): + """ + For good measure, declare no change permissions on this admin class. + """ + return False + + +@admin.register(models.ForcedPolicyRedemption) +class ForcedPolicyRedemptionAdmin(DjangoQLSearchMixin, SimpleHistoryAdmin): + autocomplete_fields = [ + 'subsidy_access_policy', + ] + readonly_fields = [ + 'redeemed_at', + 'errored_at', + ] diff --git a/enterprise_access/apps/subsidy_access_policy/migrations/0025_forced_policy_redemption_model.py b/enterprise_access/apps/subsidy_access_policy/migrations/0025_forced_policy_redemption_model.py new file mode 100644 index 00000000..8f1d88d5 --- /dev/null +++ b/enterprise_access/apps/subsidy_access_policy/migrations/0025_forced_policy_redemption_model.py @@ -0,0 +1,69 @@ +# Generated by Django 4.2.10 on 2024-03-27 20:28 + +from django.conf import settings +from django.db import migrations, models +import django.db.models.deletion +import django_extensions.db.fields +import simple_history.models +import uuid + + +class Migration(migrations.Migration): + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ('subsidy_access_policy', '0024_subsidyaccesspolicy_late_redemption_allowed_until'), + ] + + operations = [ + migrations.CreateModel( + name='HistoricalForcedPolicyRedemption', + fields=[ + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(db_index=True, default=uuid.uuid4, editable=False, help_text='The uuid that uniquely identifies this policy record.')), + ('lms_user_id', models.IntegerField(db_index=True, help_text=('The id of the Open edX LMS user record that identifies the learner.',))), + ('course_run_key', models.CharField(db_index=True, help_text=('The course run key to enroll the learner into.',), max_length=255)), + ('content_price_cents', models.BigIntegerField(help_text='Cost of the content in USD Cents, should be >= 0.')), + ('wait_to_redeem', models.BooleanField(default=False, help_text='If selected, will not force redemption when the record is saved via Django admin.')), + ('redeemed_at', models.DateTimeField(blank=True, help_text='The time the forced redemption succeeded.', null=True)), + ('errored_at', models.DateTimeField(blank=True, help_text='The time the forced redemption failed.', null=True)), + ('traceback', models.TextField(blank=True, editable=False, help_text='Any traceback we recorded when an error was encountered.', null=True)), + ('transaction_uuid', models.UUIDField(db_index=True, help_text=('The transaction uuid caused by successful redemption.',), null=True)), + ('history_id', models.AutoField(primary_key=True, serialize=False)), + ('history_date', models.DateTimeField()), + ('history_change_reason', models.CharField(max_length=100, null=True)), + ('history_type', models.CharField(choices=[('+', 'Created'), ('~', 'Changed'), ('-', 'Deleted')], max_length=1)), + ('history_user', models.ForeignKey(null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='+', to=settings.AUTH_USER_MODEL)), + ('subsidy_access_policy', models.ForeignKey(blank=True, db_constraint=False, help_text='The SubsidyAccessPolicy that this forced redemption relates to.', null=True, on_delete=django.db.models.deletion.DO_NOTHING, related_name='+', to='subsidy_access_policy.subsidyaccesspolicy')), + ], + options={ + 'verbose_name': 'historical forced policy redemption', + 'verbose_name_plural': 'historical forced policy redemptions', + 'ordering': ('-history_date', '-history_id'), + 'get_latest_by': ('history_date', 'history_id'), + }, + bases=(simple_history.models.HistoricalChanges, models.Model), + ), + migrations.CreateModel( + name='ForcedPolicyRedemption', + fields=[ + ('created', django_extensions.db.fields.CreationDateTimeField(auto_now_add=True, verbose_name='created')), + ('modified', django_extensions.db.fields.ModificationDateTimeField(auto_now=True, verbose_name='modified')), + ('uuid', models.UUIDField(default=uuid.uuid4, editable=False, help_text='The uuid that uniquely identifies this policy record.', primary_key=True, serialize=False, unique=True)), + ('lms_user_id', models.IntegerField(db_index=True, help_text=('The id of the Open edX LMS user record that identifies the learner.',))), + ('course_run_key', models.CharField(db_index=True, help_text=('The course run key to enroll the learner into.',), max_length=255)), + ('content_price_cents', models.BigIntegerField(help_text='Cost of the content in USD Cents, should be >= 0.')), + ('wait_to_redeem', models.BooleanField(default=False, help_text='If selected, will not force redemption when the record is saved via Django admin.')), + ('redeemed_at', models.DateTimeField(blank=True, help_text='The time the forced redemption succeeded.', null=True)), + ('errored_at', models.DateTimeField(blank=True, help_text='The time the forced redemption failed.', null=True)), + ('traceback', models.TextField(blank=True, editable=False, help_text='Any traceback we recorded when an error was encountered.', null=True)), + ('transaction_uuid', models.UUIDField(db_index=True, help_text=('The transaction uuid caused by successful redemption.',), null=True)), + ('subsidy_access_policy', models.ForeignKey(help_text='The SubsidyAccessPolicy that this forced redemption relates to.', null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='forced_redemptions', to='subsidy_access_policy.subsidyaccesspolicy')), + ], + options={ + 'get_latest_by': 'modified', + 'abstract': False, + }, + ), + ] diff --git a/enterprise_access/apps/subsidy_access_policy/models.py b/enterprise_access/apps/subsidy_access_policy/models.py index fde3f77b..0712d47f 100644 --- a/enterprise_access/apps/subsidy_access_policy/models.py +++ b/enterprise_access/apps/subsidy_access_policy/models.py @@ -13,12 +13,14 @@ from django.db import models from django_extensions.db.models import TimeStampedModel from edx_django_utils.cache.utils import get_cache_key +from simple_history.models import HistoricalRecords from enterprise_access.apps.api_client.lms_client import LmsApiClient from enterprise_access.apps.content_assignments import api as assignments_api from enterprise_access.apps.content_assignments.constants import LearnerContentAssignmentStateChoices +from enterprise_access.apps.core.models import User from enterprise_access.cache_utils import request_cache, versioned_cache_key -from enterprise_access.utils import is_none, is_not_none, localized_utcnow +from enterprise_access.utils import format_traceback, is_none, is_not_none, localized_utcnow from ..content_assignments.models import AssignmentConfiguration from .constants import ( @@ -600,7 +602,7 @@ def _log_redeemability(self, is_redeemable, reason, lms_user_id, content_key, ex Helper to log decision points in the can_redeem() function. """ message = ( - '[POLICY REDEEMABILITY]: policy: %s, is_redeemable: %s, reason: %s' + '[POLICY REDEEMABILITY]: policy: %s, is_redeemable: %s, reason: %s ' 'lms_user_id: %s, content_key: %s, extra=%s' ) logger.info(message, self.uuid, is_redeemable, reason, lms_user_id, content_key, extra) @@ -622,7 +624,7 @@ def can_redeem(self, lms_user_id, content_key, skip_customer_user_check=False): * third a list of any transactions representing existing redemptions (any state). """ logger.info( - f'[POLICY REDEEMABILITY] Checking for policy: {self.uuid},' + f'[POLICY REDEEMABILITY] Checking for policy: {self.uuid}, ' f'lms_user_id: {lms_user_id}, content_key: {content_key}' ) # inactive policy @@ -1509,3 +1511,148 @@ class Meta: blank=True, help_text='The uuid that uniquely identifies the associated group.', ) + + +class ForcedPolicyRedemption(TimeStampedModel): + """ + There is frequently a need to force through a redemption + (and related enrollment/fulfillment) of a particular learner, + covered by a particular subsidy access policy, into some specific course run. + This needs exists for reasons related to upstream business constraints, + notably in cases where a course is included in a policy's catalog, + but the desired course run is not discoverable due to the + current state of its metadata. This model supports executing such a redemption. + + .. no_pii: This model has no PII + """ + uuid = models.UUIDField( + primary_key=True, + default=uuid4, + editable=False, + unique=True, + help_text='The uuid that uniquely identifies this policy record.', + ) + subsidy_access_policy = models.ForeignKey( + SubsidyAccessPolicy, + related_name="forced_redemptions", + on_delete=models.SET_NULL, + null=True, + help_text="The SubsidyAccessPolicy that this forced redemption relates to.", + ) + lms_user_id = models.IntegerField( + null=False, + blank=False, + db_index=True, + help_text=( + "The id of the Open edX LMS user record that identifies the learner.", + ), + ) + course_run_key = models.CharField( + max_length=255, + blank=False, + null=False, + db_index=True, + help_text=( + "The course run key to enroll the learner into.", + ), + ) + content_price_cents = models.BigIntegerField( + null=False, + blank=False, + help_text="Cost of the content in USD Cents, should be >= 0.", + ) + wait_to_redeem = models.BooleanField( + default=False, + help_text="If selected, will not force redemption when the record is saved via Django admin.", + ) + redeemed_at = models.DateTimeField( + null=True, + blank=True, + help_text="The time the forced redemption succeeded.", + ) + errored_at = models.DateTimeField( + null=True, + blank=True, + help_text="The time the forced redemption failed.", + ) + traceback = models.TextField( + blank=True, + null=True, + editable=False, + help_text="Any traceback we recorded when an error was encountered.", + ) + transaction_uuid = models.UUIDField( + null=True, + db_index=True, + help_text=( + "The transaction uuid caused by successful redemption.", + ), + ) + history = HistoricalRecords() + + def __str__(self): + return ( + f'policy_uuid={self.subsidy_access_policy.uuid}, transaction_uuid={self.transaction_uuid}, ' + f'lms_user_id={self.lms_user_id}, course_run_key={self.course_run_key}' + ) + + def create_assignment(self): + """ + For assignment-based policies, an allocated ``LearnerContentAssignment`` must exist + before redemption can occur. + """ + assignment_configuration = self.subsidy_access_policy.assignment_configuration + content_metadata = get_and_cache_content_metadata( + assignment_configuration.enterprise_customer_uuid, + self.course_run_key, + ) + course_key = content_metadata.get('content_key') + user_record = User.objects.filter(lms_user_id=self.lms_user_id).first() + if not user_record: + raise Exception(f'No email for {self.lms_user_id}') + + return assignments_api.allocate_assignments( + assignment_configuration, + [user_record.email], + course_key, + self.content_price_cents, + ) + + def force_redeem(self): + """ + Forces redemption for the requested course run key in the associated policy. + """ + if self.redeemed_at and self.transaction_uuid: + # Just return if we've already got a successful redemption. + return + + if self.subsidy_access_policy.access_method == AccessMethods.ASSIGNED: + self.create_assignment() + + try: + with self.subsidy_access_policy.lock(): + can_redeem, _, existing_transactions = self.subsidy_access_policy.can_redeem( + self.lms_user_id, self.course_run_key, + ) + if can_redeem: + result = self.subsidy_access_policy.redeem( + self.lms_user_id, + self.course_run_key, + existing_transactions, + ) + self.transaction_uuid = result['uuid'] + self.redeemed_at = result['modified'] + self.save() + except SubsidyAccessPolicyLockAttemptFailed as exc: + logger.exception(exc) + self.errored_at = localized_utcnow() + self.traceback = format_traceback(exc) + self.save() + raise + except SubsidyAPIHTTPError as exc: + error_payload = exc.error_payload() + self.errored_at = localized_utcnow() + self.traceback = format_traceback(exc) + f'\nResponse payload:\n{error_payload}' + self.save() + logger.exception(f'{exc} when creating transaction in subsidy API: {error_payload}') + raise diff --git a/enterprise_access/apps/subsidy_access_policy/tests/factories.py b/enterprise_access/apps/subsidy_access_policy/tests/factories.py index 1cbfa217..8e0602b6 100644 --- a/enterprise_access/apps/subsidy_access_policy/tests/factories.py +++ b/enterprise_access/apps/subsidy_access_policy/tests/factories.py @@ -10,10 +10,12 @@ from enterprise_access.apps.subsidy_access_policy.constants import AccessMethods from enterprise_access.apps.subsidy_access_policy.models import ( AssignedLearnerCreditAccessPolicy, + ForcedPolicyRedemption, PerLearnerEnrollmentCreditAccessPolicy, PerLearnerSpendCreditAccessPolicy, PolicyGroupAssociation ) +from test_utils import random_content_key FAKER = Faker() @@ -77,3 +79,15 @@ class Meta: enterprise_group_uuid = factory.LazyFunction(uuid4) subsidy_access_policy = factory.SubFactory(SubsidyAccessPolicyFactory) + + +class ForcedPolicyRedemptionFactory(factory.django.DjangoModelFactory): + """ + Test factory for ForcedPolicyRedemptions. + """ + class Meta: + model = ForcedPolicyRedemption + + lms_user_id = factory.LazyAttribute(lambda _: FAKER.pyint(min_value=1)) + course_run_key = factory.LazyAttribute(lambda _: random_content_key()) + content_price_cents = factory.LazyAttribute(lambda _: FAKER.pytint(min_value=1, max_value=1000)) diff --git a/enterprise_access/apps/subsidy_access_policy/tests/mixins.py b/enterprise_access/apps/subsidy_access_policy/tests/mixins.py new file mode 100644 index 00000000..51195c1e --- /dev/null +++ b/enterprise_access/apps/subsidy_access_policy/tests/mixins.py @@ -0,0 +1,51 @@ +""" +Defines shared subsidy access policy test mixins. +""" +from unittest.mock import patch + +from django.core.cache import cache as django_cache + +from ..models import SubsidyAccessPolicy + + +class MockPolicyDependenciesMixin: + """ + Mixin to help mock out all access policy dependencies + on external services. + """ + def setUp(self): + """ + Initialize mocked service clients. + """ + super().setUp() + subsidy_client_patcher = patch.object( + SubsidyAccessPolicy, 'subsidy_client' + ) + self.mock_subsidy_client = subsidy_client_patcher.start() + + transactions_cache_for_learner_patcher = patch( + 'enterprise_access.apps.subsidy_access_policy.models.get_and_cache_transactions_for_learner' + ) + self.mock_transactions_cache_for_learner = transactions_cache_for_learner_patcher.start() + + catalog_contains_content_key_patcher = patch.object( + SubsidyAccessPolicy, 'catalog_contains_content_key' + ) + self.mock_catalog_contains_content_key = catalog_contains_content_key_patcher.start() + + get_content_metadata_patcher = patch( + 'enterprise_access.apps.subsidy_access_policy.models.get_and_cache_content_metadata' + ) + self.mock_get_content_metadata = get_content_metadata_patcher.start() + + lms_api_client_patcher = patch.object( + SubsidyAccessPolicy, 'lms_api_client' + ) + self.mock_lms_api_client = lms_api_client_patcher.start() + + self.addCleanup(subsidy_client_patcher.stop) + self.addCleanup(transactions_cache_for_learner_patcher.stop) + self.addCleanup(catalog_contains_content_key_patcher.stop) + self.addCleanup(get_content_metadata_patcher.stop) + self.addCleanup(lms_api_client_patcher.stop) + self.addCleanup(django_cache.clear) # clear any leftover policy locks. diff --git a/enterprise_access/apps/subsidy_access_policy/tests/test_forced_redemption.py b/enterprise_access/apps/subsidy_access_policy/tests/test_forced_redemption.py new file mode 100644 index 00000000..719f57f0 --- /dev/null +++ b/enterprise_access/apps/subsidy_access_policy/tests/test_forced_redemption.py @@ -0,0 +1,237 @@ +""" +Tests the ForcedPolicyRedemption model. +""" +from unittest import mock +from uuid import uuid4 + +from django.test import TestCase +from django.utils import timezone + +from enterprise_access.apps.content_assignments.models import LearnerContentAssignment +from enterprise_access.apps.content_assignments.tests.factories import AssignmentConfigurationFactory +from enterprise_access.apps.core.tests.factories import UserFactory +from enterprise_access.apps.subsidy_access_policy.exceptions import ( + SubsidyAccessPolicyLockAttemptFailed, + SubsidyAPIHTTPError +) +from enterprise_access.apps.subsidy_access_policy.models import REQUEST_CACHE_NAMESPACE +from enterprise_access.apps.subsidy_access_policy.tests.factories import ( + AssignedLearnerCreditAccessPolicyFactory, + ForcedPolicyRedemptionFactory, + PerLearnerSpendCapLearnerCreditAccessPolicyFactory +) +from enterprise_access.cache_utils import request_cache + +from .mixins import MockPolicyDependenciesMixin + +ACTIVE_LEARNER_SPEND_CAP_ENTERPRISE_UUID = uuid4() +ACTIVE_LEARNER_SPEND_CAP_POLICY_UUID = uuid4() + +MOCK_DATETIME_1 = timezone.now() + +MOCK_TRANSACTION_UUID_1 = uuid4() + + +class BaseForcedRedemptionTestCase(MockPolicyDependenciesMixin, TestCase): + """ + Provides base functionality for tests of forced redemption. + """ + lms_user_id = 12345 + course_run_key = 'course-v1:edX+DemoX+1T2042' + course_key = 'edX+DemoX' + default_content_price = 200 + + def tearDown(self): + """ + Clears any cached data for the test policy instances between test runs. + """ + super().tearDown() + request_cache(namespace=REQUEST_CACHE_NAMESPACE).clear() + + def _setup_redemption_state( + self, content_price=None, course_key=None, course_run_key=None, can_redeem=True, + subsidy_is_active=True, existing_transactions=None, existing_aggregates=None + ): + """ + Helper to setup state of content metadata and the subsidy/transactions + related to a policy/budget prior to forced redemption occuring. + """ + self.mock_get_content_metadata.return_value = { + 'content_price': content_price or self.default_content_price, + 'content_key': course_key or self.course_key, + 'course_run_key': course_run_key or self.course_run_key, + } + self.mock_subsidy_client.can_redeem.return_value = { + 'can_redeem': can_redeem, + 'active': subsidy_is_active, + } + self.mock_transactions_cache_for_learner.return_value = { + 'transactions': existing_transactions or [], + 'aggregates': existing_aggregates or {'total_quantity': 0}, + } + self.mock_subsidy_client.list_subsidy_transactions.return_value = { + 'results': existing_transactions or [], + 'aggregates': existing_aggregates or {'total_quantity': 0}, + } + self.mock_subsidy_client.create_subsidy_transaction.return_value = { + 'uuid': MOCK_TRANSACTION_UUID_1, + 'modified': MOCK_DATETIME_1, + } + + +class ForcedPolicyRedemptionPerLearnerSpendTests(BaseForcedRedemptionTestCase): + """ + Tests forced redemption against PerLearnerSpendCapLearnerCreditAccessPolicies. + """ + def _new_per_learner_spend_budget(self, spend_limit, per_learner_spend_limit): + return PerLearnerSpendCapLearnerCreditAccessPolicyFactory( + enterprise_customer_uuid=ACTIVE_LEARNER_SPEND_CAP_ENTERPRISE_UUID, + spend_limit=spend_limit, + per_learner_spend_limit=per_learner_spend_limit, + ) + + def test_force_redemption_happy_path(self): + """ + Starting from a clean, unspent state of some policy's subsidy, + test that we can force redemption. + """ + policy = self._new_per_learner_spend_budget(spend_limit=10000, per_learner_spend_limit=1000) + self._setup_redemption_state() + + forced_redemption_record = ForcedPolicyRedemptionFactory( + subsidy_access_policy=policy, + lms_user_id=self.lms_user_id, + course_run_key=self.course_run_key, + content_price_cents=self.default_content_price, + ) + + forced_redemption_record.force_redeem() + + forced_redemption_record.refresh_from_db() + self.assertEqual(MOCK_DATETIME_1, forced_redemption_record.redeemed_at) + self.assertEqual(MOCK_TRANSACTION_UUID_1, forced_redemption_record.transaction_uuid) + + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.localized_utcnow', return_value=MOCK_DATETIME_1) + def test_acquire_lock_fails(self, _): + """ + Test that we don't force a redemption when the requested policy is locked. + """ + policy = self._new_per_learner_spend_budget(spend_limit=10000, per_learner_spend_limit=1000) + policy.acquire_lock() + + forced_redemption_record = ForcedPolicyRedemptionFactory( + subsidy_access_policy=policy, + lms_user_id=self.lms_user_id, + course_run_key=self.course_run_key, + content_price_cents=self.default_content_price, + ) + + with self.assertRaisesRegex(SubsidyAccessPolicyLockAttemptFailed, 'Failed to acquire lock'): + forced_redemption_record.force_redeem() + forced_redemption_record.refresh_from_db() + self.assertEqual(MOCK_DATETIME_1, forced_redemption_record.errored_at) + self.assertIn('Failed to acquire lock', forced_redemption_record.traceback) + + # release the lock when we're done + policy.release_lock() + + @mock.patch('enterprise_access.apps.subsidy_access_policy.models.localized_utcnow', return_value=MOCK_DATETIME_1) + def test_subsidy_api_fails(self, _): + """ + Test that we don't force a redemption when the subsidy API fails. + """ + policy = self._new_per_learner_spend_budget(spend_limit=10000, per_learner_spend_limit=1000) + self._setup_redemption_state() + + self.mock_subsidy_client.create_subsidy_transaction.side_effect = SubsidyAPIHTTPError + + forced_redemption_record = ForcedPolicyRedemptionFactory( + subsidy_access_policy=policy, + lms_user_id=self.lms_user_id, + course_run_key=self.course_run_key, + content_price_cents=self.default_content_price, + ) + + with self.assertRaisesRegex(SubsidyAPIHTTPError, 'HTTPError occurred in Subsidy API request'): + forced_redemption_record.force_redeem() + forced_redemption_record.refresh_from_db() + self.assertEqual(MOCK_DATETIME_1, forced_redemption_record.errored_at) + self.assertIn('HTTPError occurred in Subsidy API request', forced_redemption_record.traceback) + + +class ForcedPolicyRedemptionAssignmentTests(BaseForcedRedemptionTestCase): + """ + Tests forced redemption against Assignment-based policies. + """ + def setUp(self): + """ + Mocks out the ``content_assignments.api.get_and_cache_content_metadata`` function. + """ + super().setUp() + + mock_assignment_content_metadata_patcher = mock.patch( + 'enterprise_access.apps.content_assignments.api.get_and_cache_content_metadata', + ) + self.mock_assignment_content_metadata = mock_assignment_content_metadata_patcher.start() + self.addCleanup(mock_assignment_content_metadata_patcher.stop) + + def _setup_redemption_state( + self, content_price=None, course_key=None, course_run_key=None, can_redeem=True, + subsidy_is_active=True, existing_transactions=None, existing_aggregates=None + ): + """ + Setup state of the assignment content metadata mock. + """ + super()._setup_redemption_state( + content_price=None, course_key=None, course_run_key=None, can_redeem=True, + subsidy_is_active=True, existing_transactions=None, existing_aggregates=None, + ) + self.mock_assignment_content_metadata.return_value = { + 'content_price': content_price or self.default_content_price, + 'content_key': course_key or self.course_key, + 'course_run_key': course_run_key or self.course_run_key, + } + + def _new_assignment_budget(self): + """ + Helper to setup a new assignment-based budget. + """ + customer_uuid = uuid4() + assignment_config = AssignmentConfigurationFactory( + enterprise_customer_uuid=customer_uuid, + ) + return AssignedLearnerCreditAccessPolicyFactory( + enterprise_customer_uuid=customer_uuid, + assignment_configuration=assignment_config, + ) + + @mock.patch('enterprise_access.apps.content_assignments.api.send_email_for_new_assignment') + @mock.patch( + 'enterprise_access.apps.content_assignments.api.create_pending_enterprise_learner_for_assignment_task' + ) + def test_force_redemption_with_assignment_happy_path(self, mock_pending_learner_task, mock_send_email): + """ + Starting from a clean, unspent state of some policy's subsidy, + test that we can force redemption. + """ + policy = self._new_assignment_budget() + self._setup_redemption_state() + + user = UserFactory(username='alice', email='alice@foo.com', lms_user_id=self.lms_user_id) + + forced_redemption_record = ForcedPolicyRedemptionFactory( + subsidy_access_policy=policy, + lms_user_id=self.lms_user_id, + course_run_key=self.course_run_key, + content_price_cents=self.default_content_price, + ) + + forced_redemption_record.force_redeem() + + forced_redemption_record.refresh_from_db() + self.assertEqual(MOCK_DATETIME_1, forced_redemption_record.redeemed_at) + self.assertEqual(MOCK_TRANSACTION_UUID_1, forced_redemption_record.transaction_uuid) + + assignment = LearnerContentAssignment.objects.filter(lms_user_id=user.lms_user_id).first() + mock_send_email.delay.assert_called_once_with(assignment.uuid) + mock_pending_learner_task.delay.assert_called_once_with(assignment.uuid) 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 90e3277c..cb052c4f 100644 --- a/enterprise_access/apps/subsidy_access_policy/tests/test_models.py +++ b/enterprise_access/apps/subsidy_access_policy/tests/test_models.py @@ -9,7 +9,6 @@ import pytest import requests from django.conf import settings -from django.core.cache import cache as django_cache from django.core.exceptions import ValidationError from django.test import TestCase, override_settings @@ -56,55 +55,13 @@ from ..constants import AccessMethods from ..exceptions import PriceValidationError +from .mixins import MockPolicyDependenciesMixin ACTIVE_LEARNER_SPEND_CAP_POLICY_UUID = uuid4() ACTIVE_LEARNER_ENROLL_CAP_POLICY_UUID = uuid4() ACTIVE_ASSIGNED_LEARNER_CREDIT_POLICY_UUID = uuid4() -class MockPolicyDependenciesMixin: - """ - Mixin to help mock out all access policy dependencies - on external services. - """ - def setUp(self): - """ - Initialize mocked service clients. - """ - super().setUp() - subsidy_client_patcher = patch.object( - SubsidyAccessPolicy, 'subsidy_client' - ) - self.mock_subsidy_client = subsidy_client_patcher.start() - - transactions_cache_for_learner_patcher = patch( - 'enterprise_access.apps.subsidy_access_policy.models.get_and_cache_transactions_for_learner' - ) - self.mock_transactions_cache_for_learner = transactions_cache_for_learner_patcher.start() - - catalog_contains_content_key_patcher = patch.object( - SubsidyAccessPolicy, 'catalog_contains_content_key' - ) - self.mock_catalog_contains_content_key = catalog_contains_content_key_patcher.start() - - get_content_metadata_patcher = patch( - 'enterprise_access.apps.subsidy_access_policy.models.get_and_cache_content_metadata' - ) - self.mock_get_content_metadata = get_content_metadata_patcher.start() - - lms_api_client_patcher = patch.object( - SubsidyAccessPolicy, 'lms_api_client' - ) - self.mock_lms_api_client = lms_api_client_patcher.start() - - self.addCleanup(subsidy_client_patcher.stop) - self.addCleanup(transactions_cache_for_learner_patcher.stop) - self.addCleanup(catalog_contains_content_key_patcher.stop) - self.addCleanup(get_content_metadata_patcher.stop) - 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. """ diff --git a/enterprise_access/utils.py b/enterprise_access/utils.py index 9dba5f60..7eefc397 100644 --- a/enterprise_access/utils.py +++ b/enterprise_access/utils.py @@ -2,6 +2,7 @@ Utils for any app in the enterprise-access project. """ import logging +import traceback from datetime import datetime from django.apps import apps @@ -49,6 +50,19 @@ def localized_utcnow(): return datetime.now().replace(tzinfo=UTC) +def chunks(a_list, chunk_size): + """ + Helper to break a list up into chunks. Returns a generator of lists. + """ + for i in range(0, len(a_list), chunk_size): + yield a_list[i:i + chunk_size] + + +def format_traceback(exception): + trace = ''.join(traceback.format_tb(exception.__traceback__)) + return f'{exception}\n{trace}' + + def _get_subsidy_expiration(assignment): """ Returns the datetime at which the subsidy for this assignment expires. diff --git a/test_utils/__init__.py b/test_utils/__init__.py index c4145be5..209061ee 100644 --- a/test_utils/__init__.py +++ b/test_utils/__init__.py @@ -17,6 +17,7 @@ from django.test.client import RequestFactory from edx_rest_framework_extensions.auth.jwt.cookies import jwt_cookie_name from edx_rest_framework_extensions.auth.jwt.tests.utils import generate_jwt_token, generate_unversioned_payload +from faker import Faker from pytest import mark from rest_framework.test import APIClient, APITestCase @@ -63,6 +64,19 @@ TEST_USER_RECORD_NO_GROUPS = copy.deepcopy(TEST_USER_RECORD) TEST_USER_RECORD_NO_GROUPS['enterprise_group'] = [] +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) + @mark.django_db class APITest(APITestCase):