Skip to content

Commit

Permalink
feat: new redeemability_disabled_at field on policy models
Browse files Browse the repository at this point in the history
Implements ADR 0016-policy-retirement

ENT-8206
  • Loading branch information
pwnage101 committed Jan 16, 2024
1 parent 6bfbfbe commit a403ebd
Show file tree
Hide file tree
Showing 7 changed files with 129 additions and 18 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ class Meta:
'display_name',
'description',
'active',
'redeemability_disabled_at',
'enterprise_customer_uuid',
'catalog_uuid',
'subsidy_uuid',
Expand Down Expand Up @@ -194,6 +195,7 @@ class Meta:
'display_name',
'description',
'active',
'redeemability_disabled_at',
'enterprise_customer_uuid',
'catalog_uuid',
'subsidy_uuid',
Expand Down Expand Up @@ -395,6 +397,7 @@ class Meta:
'display_name',
'description',
'active',
'redeemability_disabled_at',
'catalog_uuid',
'subsidy_uuid',
'access_method',
Expand Down Expand Up @@ -422,6 +425,10 @@ class Meta:
'allow_null': False,
'required': False,
},
'redeemability_disabled_at': {
'allow_null': True,
'required': False,
},
'catalog_uuid': {
'allow_null': False,
'required': False,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -270,6 +270,7 @@ def test_detail_view(self, role_context_dict):
self.assertEqual({
'access_method': 'direct',
'active': True,
'redeemability_disabled_at': None,
'catalog_uuid': str(self.redeemable_policy.catalog_uuid),
'display_name': self.redeemable_policy.display_name,
'description': 'A generic description',
Expand Down Expand Up @@ -361,6 +362,7 @@ def test_list_view(self, role_context_dict):
{
'access_method': 'direct',
'active': True,
'redeemability_disabled_at': None,
'catalog_uuid': str(self.non_redeemable_policy.catalog_uuid),
'display_name': self.non_redeemable_policy.display_name,
'description': 'A generic description',
Expand All @@ -387,6 +389,7 @@ def test_list_view(self, role_context_dict):
{
'access_method': 'direct',
'active': True,
'redeemability_disabled_at': None,
'catalog_uuid': str(self.redeemable_policy.catalog_uuid),
'display_name': self.redeemable_policy.display_name,
'description': 'A generic description',
Expand Down Expand Up @@ -480,6 +483,7 @@ def test_destroy_view(self, request_payload, expected_change_reason):
expected_response = {
'access_method': 'direct',
'active': False,
'redeemability_disabled_at': None,
'catalog_uuid': str(self.redeemable_policy.catalog_uuid),
'display_name': self.redeemable_policy.display_name,
'description': 'A generic description',
Expand Down Expand Up @@ -526,6 +530,7 @@ def test_destroy_view(self, request_payload, expected_change_reason):
'description': 'the new description',
'display_name': 'new display_name',
'active': True,
'redeemability_disabled_at': '2023-01-01T00:00:00Z',
'catalog_uuid': str(uuid4()),
'subsidy_uuid': str(uuid4()),
'access_method': AccessMethods.ASSIGNED,
Expand All @@ -540,6 +545,7 @@ def test_destroy_view(self, request_payload, expected_change_reason):
'description': 'the new description',
'display_name': 'new display_name',
'active': True,
'redeemability_disabled_at': '2023-01-01T00:00:00Z',
'catalog_uuid': str(uuid4()),
'subsidy_uuid': str(uuid4()),
'access_method': AccessMethods.ASSIGNED,
Expand Down Expand Up @@ -586,6 +592,7 @@ def test_update_views(self, is_patch, request_payload):
# Fields that we officially support PATCHing.
'access_method': policy_for_edit.access_method,
'active': policy_for_edit.active,
'redeemability_disabled_at': policy_for_edit.redeemability_disabled_at,
'catalog_uuid': str(policy_for_edit.catalog_uuid),
'display_name': policy_for_edit.display_name,
'description': policy_for_edit.description,
Expand Down Expand Up @@ -802,12 +809,13 @@ def test_create_view(self, policy_type, extra_fields, expected_response_code, ex
},
])

# Test the retrieve endpoint
# Test the create endpoint
payload = {
'policy_type': policy_type,
'display_name': 'created policy',
'description': 'test description',
'active': True,
'redeemability_disabled_at': None,
'enterprise_customer_uuid': str(TEST_ENTERPRISE_UUID),
'catalog_uuid': str(uuid4()),
'subsidy_uuid': str(uuid4()),
Expand Down Expand Up @@ -863,6 +871,7 @@ def test_idempotent_create_view(self, policy_type, extra_fields, expected_respon
'display_name': 'new policy',
'description': 'test description',
'active': True,
'redeemability_disabled_at': None,
'enterprise_customer_uuid': enterprise_customer_uuid,
'catalog_uuid': catalog_uuid,
'subsidy_uuid': subsidy_uuid,
Expand Down Expand Up @@ -1288,6 +1297,11 @@ def test_credits_available_endpoint(
enterprise_customer_uuid=self.enterprise_uuid,
active=False,
)
# The following policy should never be returned as it has redeemability disabled.
PerLearnerEnrollmentCapLearnerCreditAccessPolicyFactory(
enterprise_customer_uuid=self.enterprise_uuid,
redeemability_disabled_at=datetime.utcnow() - timedelta(days=1),
)
# The following policy should never be returned as it's had more spend than the `spend_limit`.
PerLearnerSpendCapLearnerCreditAccessPolicyFactory(
enterprise_customer_uuid=self.enterprise_uuid,
Expand Down
5 changes: 2 additions & 3 deletions enterprise_access/apps/api/v1/views/subsidy_access_policy.py
Original file line number Diff line number Diff line change
Expand Up @@ -388,12 +388,11 @@ def get_permission_object(self):

def get_queryset(self):
"""
Base queryset that returns all active policies associated
Base queryset that returns all active & redeemable policies associated
with the customer uuid requested by the client.
"""
return SubsidyAccessPolicy.objects.filter(
return SubsidyAccessPolicy.policies_with_redemption_enabled().filter(
enterprise_customer_uuid=self.enterprise_customer_uuid,
active=True,
).order_by('-created')

def evaluate_policies(self, enterprise_customer_uuid, lms_user_id, content_key):
Expand Down
5 changes: 5 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/admin.py
Original file line number Diff line number Diff line change
Expand Up @@ -47,11 +47,13 @@ class BaseSubsidyAccessPolicyMixin(admin.ModelAdmin):
'uuid',
'modified',
'active',
'redeemability_disabled_at',
'display_name_or_short_description',
'policy_spend_limit_dollars',
)
list_filter = (
'active',
'redeemability_disabled_at',
'access_method',
)
ordering = ['-modified']
Expand Down Expand Up @@ -140,6 +142,7 @@ class PerLearnerEnrollmentCreditAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAcc
'display_name',
'description',
'active',
'redeemability_disabled_at',
'catalog_uuid',
'subsidy_uuid',
'created',
Expand Down Expand Up @@ -188,6 +191,7 @@ class PerLearnerSpendCreditAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAccessPo
'display_name',
'description',
'active',
'redeemability_disabled_at',
'catalog_uuid',
'subsidy_uuid',
'created',
Expand Down Expand Up @@ -244,6 +248,7 @@ class LearnerContentAssignmentAccessPolicy(DjangoQLSearchMixin, BaseSubsidyAcces
'display_name',
'description',
'active',
'redeemability_disabled_at',
'catalog_uuid',
'subsidy_uuid',
'assignment_configuration',
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
# Generated by Django 4.2.9 on 2024-01-16 05:25

from django.db import migrations, models


class Migration(migrations.Migration):

dependencies = [
('subsidy_access_policy', '0020_alter_historicalassignedlearnercreditaccesspolicy_options_and_more'),
]

operations = [
migrations.AddField(
model_name='historicalsubsidyaccesspolicy',
name='redeemability_disabled_at',
field=models.DateTimeField(blank=True, default=None, help_text="Defines when redeemability of content using this policy was disabled. Set this to deactivate the policy but keep it visible from an admin's perspective (useful when you want to just expire a policy without expiring the whole plan).", null=True),
),
migrations.AddField(
model_name='subsidyaccesspolicy',
name='redeemability_disabled_at',
field=models.DateTimeField(blank=True, default=None, help_text="Defines when redeemability of content using this policy was disabled. Set this to deactivate the policy but keep it visible from an admin's perspective (useful when you want to just expire a policy without expiring the whole plan).", null=True),
),
migrations.AlterField(
model_name='historicalsubsidyaccesspolicy',
name='active',
field=models.BooleanField(default=False, help_text='Set to FALSE to deactivate and hide this policy. Use this when you want to disable redemption and make it disappear from all frontends, effectively soft-deleting it. Default is False (deactivated).'),
),
migrations.AlterField(
model_name='subsidyaccesspolicy',
name='active',
field=models.BooleanField(default=False, help_text='Set to FALSE to deactivate and hide this policy. Use this when you want to disable redemption and make it disappear from all frontends, effectively soft-deleting it. Default is False (deactivated).'),
),
]
38 changes: 34 additions & 4 deletions enterprise_access/apps/subsidy_access_policy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -132,7 +132,20 @@ class Meta:
)
active = models.BooleanField(
default=False,
help_text='Whether this policy is active, defaults to false.',
help_text=(
'Set to FALSE to deactivate and hide this policy. Use this when you want to disable redemption and make '
'it disappear from all frontends, effectively soft-deleting it. Default is False (deactivated).'
),
)
redeemability_disabled_at = models.DateTimeField(
null=True,
blank=True,
default=None,
help_text=(
"Defines when redeemability of content using this policy was disabled. "
"Set this to deactivate the policy but keep it visible from an admin's perspective "
"(useful when you want to just expire a policy without expiring the whole plan)."
),
)
catalog_uuid = models.UUIDField(
db_index=True,
Expand Down Expand Up @@ -195,6 +208,23 @@ class Meta:
# ProxyAwareHistoricalRecords docstring for more info.
history = ProxyAwareHistoricalRecords(inherit=True)

@classmethod
def policies_with_redemption_enabled(cls):
"""
Return all policies which have redemption enabled.
"""
return cls.objects.filter(
active=True,
redeemability_disabled_at__isnull=True,
)

@property
def is_redemption_enabled(self):
"""
Return True if this policy has redemption enabled.
"""
return self.active and not self.redeemability_disabled_at

@property
def subsidy_active_datetime(self):
"""
Expand Down Expand Up @@ -526,7 +556,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).
"""
# inactive policy
if not self.active:
if not self.is_redemption_enabled:
return (False, REASON_POLICY_EXPIRED, [])

# learner not associated to enterprise
Expand Down Expand Up @@ -605,7 +635,7 @@ def credit_available(self, lms_user_id, skip_customer_user_check=False):
* Whether the transactions associated with policy have exceeded the policy-wide spend limit.
"""
# inactive policy
if not self.active:
if not self.is_redemption_enabled:
logger.info('[credit_available] policy %s inactive', self.uuid)
return False

Expand Down Expand Up @@ -1227,7 +1257,7 @@ def can_allocate(self, number_of_learners, content_key, content_price_cents):
self.validate_requested_allocation_price(content_key, content_price_cents)

# inactive policy
if not self.active:
if not self.is_redemption_enabled:
return (False, REASON_POLICY_EXPIRED)

# no content key in catalog
Expand Down
Loading

0 comments on commit a403ebd

Please sign in to comment.