Skip to content

Commit

Permalink
feat: ForcedPolicyRedemption model
Browse files Browse the repository at this point in the history
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 commit
introduces a new Django Model to support such an operation. ENT-8703
  • Loading branch information
iloveagent57 committed Apr 5, 2024
1 parent 1946d7b commit 3f28c3c
Show file tree
Hide file tree
Showing 15 changed files with 598 additions and 80 deletions.
2 changes: 2 additions & 0 deletions .annotation_safe_list.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
5 changes: 2 additions & 3 deletions enterprise_access/apps/content_assignments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,7 +32,6 @@
send_assignment_automatically_expired_email,
send_email_for_new_assignment
)
from .utils import chunks

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -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.
"""

Expand Down
3 changes: 2 additions & 1 deletion enterprise_access/apps/content_assignments/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -25,7 +27,6 @@
AssignmentRecentActionTypes,
LearnerContentAssignmentStateChoices
)
from .utils import format_traceback

logger = logging.getLogger(__name__)

Expand Down
13 changes: 2 additions & 11 deletions enterprise_access/apps/content_assignments/tests/factories.py
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
17 changes: 0 additions & 17 deletions enterprise_access/apps/content_assignments/utils.py
Original file line number Diff line number Diff line change
@@ -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}'
39 changes: 39 additions & 0 deletions enterprise_access/apps/subsidy_access_policy/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
]
Original file line number Diff line number Diff line change
@@ -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,
},
),
]
153 changes: 150 additions & 3 deletions enterprise_access/apps/subsidy_access_policy/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand Down Expand Up @@ -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)
Expand All @@ -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
Expand Down Expand Up @@ -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
Loading

0 comments on commit 3f28c3c

Please sign in to comment.