Skip to content

Commit

Permalink
feat: support course run based assignments in credits_available, expi…
Browse files Browse the repository at this point in the history
…ration, emails, etc. (#561)
  • Loading branch information
adamstankiewicz authored Sep 12, 2024
1 parent 53875bd commit 16e2f11
Show file tree
Hide file tree
Showing 14 changed files with 380 additions and 198 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
LearnerContentAssignmentStateChoices
)
from enterprise_access.apps.content_assignments.models import LearnerContentAssignment, LearnerContentAssignmentAction
from enterprise_access.utils import get_automatic_expiration_date_and_reason
from enterprise_access.utils import get_automatic_expiration_date_and_reason, get_normalized_metadata_for_assignment

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -145,8 +145,8 @@ def get_earliest_possible_expiration(self, assignment):
"""
Returns the earliest possible expiration date for the assignment.
"""
assignment_content_metadata = self.get_content_metadata_from_context(assignment.content_key)
return get_automatic_expiration_date_and_reason(assignment, content_metadata=assignment_content_metadata)
content_metadata = self.get_content_metadata_from_context(assignment.content_key)
return get_automatic_expiration_date_and_reason(assignment, content_metadata)


class LearnerContentAssignmentAdminResponseSerializer(LearnerContentAssignmentResponseSerializer):
Expand Down Expand Up @@ -290,29 +290,45 @@ class ContentMetadataForAssignmentSerializer(serializers.Serializer):
content_price = serializers.SerializerMethodField(
help_text='The price, in USD, of this content',
)
course_type = serializers.CharField(
course_type = serializers.SerializerMethodField(
help_text='The type of course, something like "executive-education-2u" or "verified-audit"',
# Try to be a little defensive against malformed data.
required=False,
allow_null=True,
)
partners = serializers.SerializerMethodField()

def _assignment(self, obj):
return obj.get('assignment')

def _content_metadata(self, obj):
return obj.get('content_metadata')

def _normalized_metadata(self, obj):
return get_normalized_metadata_for_assignment(self._assignment(obj), self._content_metadata(obj))

@extend_schema_field(serializers.DateTimeField)
def get_start_date(self, obj):
return obj.get('normalized_metadata', {}).get('start_date')
return self._normalized_metadata(obj).get('start_date')

@extend_schema_field(serializers.DateTimeField)
def get_end_date(self, obj):
return obj.get('normalized_metadata', {}).get('end_date')
return self._normalized_metadata(obj).get('end_date')

@extend_schema_field(serializers.DateTimeField)
def get_enroll_by_date(self, obj):
return obj.get('normalized_metadata', {}).get('enroll_by_date')
return self._normalized_metadata(obj).get('enroll_by_date')

@extend_schema_field(serializers.IntegerField)
def get_content_price(self, obj):
return obj.get('normalized_metadata', {}).get('content_price')
return self._normalized_metadata(obj).get('content_price')

@extend_schema_field(serializers.CharField)
def get_course_type(self, obj):
"""
Returns the course type for the content metadata, if available.
"""
return self._content_metadata(obj).get('course_type')

@extend_schema_field(CoursePartnerSerializer)
def get_partners(self, obj):
Expand All @@ -321,7 +337,7 @@ def get_partners(self, obj):
enterprise-catalog/enterprise_catalog/apps/catalog/algolia_utils.py
"""
partners = []
owners = obj.get('owners') or []
owners = self._content_metadata(obj).get('owners') or []

for owner in owners:
partner_name = owner.get('name')
Expand Down Expand Up @@ -353,10 +369,10 @@ def get_content_metadata(self, obj):
"""
Serializers content metadata for the assignment, if available.
"""
assignment_content_metadata = self.get_content_metadata_from_context(obj.content_key)
if not assignment_content_metadata:
content_metadata = self.get_content_metadata_from_context(obj.content_key)
if not content_metadata:
return None
return ContentMetadataForAssignmentSerializer(assignment_content_metadata).data
return ContentMetadataForAssignmentSerializer({'assignment': obj, 'content_metadata': content_metadata}).data


class LearnerContentAssignmentWithLearnerAcknowledgedResponseSerializer(
Expand Down
26 changes: 16 additions & 10 deletions enterprise_access/apps/api/v1/tests/test_allocation_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,8 @@ class TestSubsidyAccessPolicyAllocationView(APITestWithMocks):
def setUpTestData(cls):
super().setUpTestData()
cls.enterprise_uuid = TEST_ENTERPRISE_UUID
cls.content_key = 'course-v1:edX+edXPrivacy101+3T2020'
cls.content_key = 'course-v1:edX+Privacy101+3T2020'
cls.parent_content_key = 'edX+Privacy101'
cls.content_title = 'edx: Privacy 101'

# Create a pair of AssignmentConfiguration + SubsidyAccessPolicy for the main test customer.
Expand All @@ -85,6 +86,8 @@ def setUpTestData(cls):
learner_email='alice@foo.com',
lms_user_id=None,
content_key=cls.content_key,
parent_content_key=cls.parent_content_key,
is_assigned_course_run=True,
content_title=cls.content_title,
content_quantity=-123,
state=LearnerContentAssignmentStateChoices.ERRORED,
Expand All @@ -94,6 +97,8 @@ def setUpTestData(cls):
learner_email='bob@foo.com',
lms_user_id=None,
content_key=cls.content_key,
parent_content_key=cls.parent_content_key,
is_assigned_course_run=True,
content_title=cls.content_title,
content_quantity=-456,
state=LearnerContentAssignmentStateChoices.ALLOCATED,
Expand All @@ -103,6 +108,8 @@ def setUpTestData(cls):
learner_email='carol@foo.com',
lms_user_id=None,
content_key=cls.content_key,
parent_content_key=cls.parent_content_key,
is_assigned_course_run=True,
content_title=cls.content_title,
content_quantity=-789,
state=LearnerContentAssignmentStateChoices.ALLOCATED,
Expand Down Expand Up @@ -150,13 +157,13 @@ def setUp(self):
},
{
'learner_emails': ['everything-valid@example.com'],
'content_key': 'course-v1:edX+edXPrivacy101+3T2020', # valid course run key
'content_key': 'course-v1:edX+Privacy101+3T2020', # valid course run key
'content_price_cents': 100,
'error_regex': '',
},
{
'learner_emails': ['everything-valid@example.com'],
'content_key': 'course-v1:edX+edXPrivacy101+3T2020', # valid course run key
'content_key': 'course-v1:edX+Privacy101+3T2020', # valid course run key
'content_price_cents': -100,
'error_regex': 'Ensure this value is greater than or equal to 0',
},
Expand Down Expand Up @@ -221,7 +228,6 @@ def test_allocate_happy_path(self, mock_catalog_client, mock_allocate, mock_subs
}

response = self.client.post(allocate_url, data=allocate_payload)

self.assertEqual(status.HTTP_202_ACCEPTED, response.status_code)
expected_response_payload = {
'updated': [
Expand All @@ -230,8 +236,8 @@ def test_allocate_happy_path(self, mock_catalog_client, mock_allocate, mock_subs
'learner_email': 'alice@foo.com',
'lms_user_id': None,
'content_key': self.content_key,
'parent_content_key': None,
'is_assigned_course_run': False,
'parent_content_key': self.parent_content_key,
'is_assigned_course_run': True,
'content_title': self.content_title,
'content_quantity': -123,
'state': LearnerContentAssignmentStateChoices.ERRORED,
Expand All @@ -252,8 +258,8 @@ def test_allocate_happy_path(self, mock_catalog_client, mock_allocate, mock_subs
'learner_email': 'bob@foo.com',
'lms_user_id': None,
'content_key': self.content_key,
'parent_content_key': None,
'is_assigned_course_run': False,
'parent_content_key': self.parent_content_key,
'is_assigned_course_run': True,
'content_title': self.content_title,
'content_quantity': -456,
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
Expand All @@ -274,8 +280,8 @@ def test_allocate_happy_path(self, mock_catalog_client, mock_allocate, mock_subs
'learner_email': 'carol@foo.com',
'lms_user_id': None,
'content_key': self.content_key,
'parent_content_key': None,
'is_assigned_course_run': False,
'parent_content_key': self.parent_content_key,
'is_assigned_course_run': True,
'content_title': self.content_title,
'content_quantity': -789,
'state': LearnerContentAssignmentStateChoices.ALLOCATED,
Expand Down
8 changes: 8 additions & 0 deletions enterprise_access/apps/api/v1/tests/test_assignment_views.py
Original file line number Diff line number Diff line change
Expand Up @@ -823,6 +823,14 @@ def test_nudge_happy_path(self, mock_send_nudge_email, mock_content_metadata_for
'enroll_by_date': enrollment_end.strftime("%Y-%m-%dT%H:%M:%SZ"),
'content_price': self.content_metadata_one['content_quantity'],
},
'normalized_metadata_by_run': {
self.content_metadata_one['content_key']: {
'start_date': start_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
'end_date': end_date.strftime("%Y-%m-%dT%H:%M:%SZ"),
'enroll_by_date': enrollment_end.strftime("%Y-%m-%dT%H:%M:%SZ"),
'content_price': self.content_metadata_one['content_quantity'],
},
},
'course_type': 'executive-education-2u',
},
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -1097,7 +1097,7 @@ def test_policy_redemption_forbidden_requests(self, role_context_dict, expected_
url = reverse('api:v1:policy-redemption-redeem', kwargs={'policy_uuid': self.redeemable_policy.uuid})
payload = {
'lms_user_id': 1234,
'content_key': 'course-v1:edX+edXPrivacy101+3T2020',
'content_key': 'course-v1:edX+Privacy101+3T2020',
}
response = self.client.post(url, payload)
self.assertEqual(response.status_code, expected_response_code)
Expand All @@ -1116,7 +1116,7 @@ def test_policy_redemption_forbidden_requests(self, role_context_dict, expected_
kwargs={"enterprise_customer_uuid": self.enterprise_uuid},
)
query_params = {
'content_key': ['course-v1:edX+edXPrivacy101+3T2020', 'course-v1:edX+edXPrivacy101+3T2020_2'],
'content_key': ['course-v1:edX+Privacy101+3T2020', 'course-v1:edX+Privacy101+3T2020_2'],
}
response = self.client.get(url, query_params)
self.assertEqual(response.status_code, expected_response_code)
Expand Down Expand Up @@ -1223,7 +1223,7 @@ def test_redeem_policy(self, mock_transactions_cache_for_learner, mock_oauth):
self.redeemable_policy.subsidy_client.create_subsidy_transaction.return_value = mock_transaction_record
payload = {
'lms_user_id': 1234,
'content_key': 'course-v1:edX+edXPrivacy101+3T2020',
'content_key': 'course-v1:edX+Privacy101+3T2020',
}

response = self.client.post(self.subsidy_access_policy_redeem_endpoint, payload)
Expand Down Expand Up @@ -1261,7 +1261,7 @@ def test_redeem_policy_with_metadata(self, mock_transactions_cache_for_learner):
self.redeemable_policy.subsidy_client.create_subsidy_transaction.return_value = mock_transaction_record
payload = {
'lms_user_id': 1234,
'content_key': 'course-v1:edX+edXPrivacy101+3T2020',
'content_key': 'course-v1:edX+Privacy101+3T2020',
'metadata': {
'geag_first_name': 'John'
}
Expand Down Expand Up @@ -1335,7 +1335,7 @@ def test_redeem_policy_redemption_idempotency_key_versions(
self.mock_get_content_metadata.return_value = {'content_price': 5000}

lms_user_id = 1234
content_key = 'course-v1:edX+edXPrivacy101+3T2020'
content_key = 'course-v1:edX+Privacy101+3T2020'
historical_redemption_uuid = str(uuid4())
baseline_idempotency_key = create_idempotency_key_for_transaction(
subsidy_uuid=str(self.redeemable_policy.subsidy_uuid),
Expand Down Expand Up @@ -1559,7 +1559,6 @@ def test_credits_available_endpoint_with_content_assignments(
Verify that SubsidyAccessPolicyViewset credits_available returns learner content assignments for assigned
learner credit access policies.
"""
self.maxDiff = None
parent_content_key = 'edX+DemoX'
content_key = 'course-v1:edX+DemoX+T2024a'
content_title = 'edx: Demo 101'
Expand Down Expand Up @@ -1621,13 +1620,21 @@ def test_credits_available_endpoint_with_content_assignments(
# Mock catalog content metadata results. See LearnerContentAssignmentWithContentMetadataResponseSerializer
# for what we expect to be in the response payload w.r.t. content metadata.
mock_content_metadata = {
'key': content_key,
'key': parent_content_key,
'normalized_metadata': {
'start_date': '2020-01-01T12:00:00Z',
'end_date': '2022-01-01T12:00:00Z',
'enroll_by_date': '2021-01-01T12:00:00Z',
'content_price': content_price_cents,
},
'normalized_metadata_by_run': {
content_key: {
'start_date': '2020-01-01T12:00:00Z',
'end_date': '2022-01-01T12:00:00Z',
'enroll_by_date': '2021-01-01T12:00:00Z',
'content_price': content_price_cents,
},
},
'course_type': 'verified-audit',
'owners': [
{'name': 'Smart Folks', 'logo_image_url': 'http://pictures.yes'},
Expand Down Expand Up @@ -1835,8 +1842,8 @@ def test_can_redeem_policy(self, mock_transactions_cache_for_learner):
'total_quantity': 0,
},
}
test_content_key_1 = "course-v1:edX+edXPrivacy101+3T2020"
test_content_key_2 = "course-v1:edX+edXPrivacy101+3T2020_2"
test_content_key_1 = "course-v1:edX+Privacy101+3T2020"
test_content_key_2 = "course-v1:edX+Privacy101+3T2020_2"
test_content_key_1_metadata_price = 29900
test_content_key_2_metadata_price = 81900
test_content_key_1_usd_price = 299
Expand Down Expand Up @@ -1947,8 +1954,8 @@ def test_can_redeem_policy_none_redeemable(
'unit': 'usd_cents',
'all_transactions': [],
}
test_content_key_1 = "course-v1:edX+edXPrivacy101+3T2020"
test_content_key_2 = "course-v1:edX+edXPrivacy101+3T2020_2"
test_content_key_1 = "course-v1:edX+Privacy101+3T2020"
test_content_key_2 = "course-v1:edX+Privacy101+3T2020_2"
test_content_key_1_metadata_price = 29900
test_content_key_2_metadata_price = 81900

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ def catalog_content_metadata(self, catalog_uuid, content_keys, traverse_paginati
'traverse_pagination': traverse_pagination,
**kwargs,
}
endpoint = self.enterprise_catalog_endpoint + str(catalog_uuid) + '/get_content_metadata/'
endpoint = f'{self.enterprise_catalog_endpoint}{catalog_uuid}/get_content_metadata/'

response = self.client.get(endpoint, params=query_params)
response.raise_for_status()
Expand Down
11 changes: 9 additions & 2 deletions enterprise_access/apps/content_assignments/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,12 @@
)
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 chunks, get_automatic_expiration_date_and_reason, localized_utcnow
from enterprise_access.utils import (
chunks,
get_automatic_expiration_date_and_reason,
get_normalized_metadata_for_assignment,
localized_utcnow
)

from .constants import AssignmentAutomaticExpiredReason, LearnerContentAssignmentStateChoices
from .models import AssignmentConfiguration, LearnerContentAssignment
Expand Down Expand Up @@ -762,7 +767,9 @@ def nudge_assignments(assignments, assignment_configuration_uuid, days_before_co
[assignment],
)
content_metadata = content_metadata_for_assignments.get(assignment.content_key, {})
start_date = content_metadata.get('normalized_metadata', {}).get('start_date')
normalized_metadata = get_normalized_metadata_for_assignment(assignment, content_metadata)

start_date = normalized_metadata.get('start_date')
course_type = content_metadata.get('course_type')

# check if the course_type is an executive-education course
Expand Down
30 changes: 24 additions & 6 deletions enterprise_access/apps/content_assignments/content_metadata_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,26 +17,44 @@
DEFAULT_STRFTIME_PATTERN = '%b %d, %Y'


def _content_metadata_for_assignment(assignment, course_metadata_list):
"""
Given a list of course metadata dictionaries and an assignment,
find the course metadata dictionary that corresponds to the
assignment's content_key (course run) or parent_content_key (course).
"""
return next(
(
course_metadata
for course_metadata in course_metadata_list
if course_metadata.get('key') in (assignment.content_key, assignment.parent_content_key)
),
None
)


def get_content_metadata_for_assignments(enterprise_catalog_uuid, assignments):
"""
Fetches (from cache or enterprise-catalog API call) content metadata
in bulk for the `content_keys` of the given assignments, provided
such metadata is related to the given `enterprise_catalog_uuid`.
Note that the `content_keys` of the provided assignments may be
either course run keys or course keys. Regardless of the type of key,
the content metadata API will return the metadata at the course-level.
Returns:
A dict mapping every content key of the provided assignments
to a content metadata dictionary, or null if no such dictionary
could be found for a given key.
"""
content_keys = sorted({assignment.content_key for assignment in assignments})
content_metadata_list = get_and_cache_catalog_content_metadata(enterprise_catalog_uuid, content_keys)
content_keys = {assignment.content_key for assignment in assignments}
course_metadata_list = get_and_cache_catalog_content_metadata(enterprise_catalog_uuid, content_keys)
metadata_by_key = {
record['key']: record for record in content_metadata_list
}
return {
assignment.content_key: metadata_by_key.get(assignment.content_key)
assignment.content_key: _content_metadata_for_assignment(assignment, course_metadata_list)
for assignment in assignments
}
return metadata_by_key


def get_card_image_url(content_metadata):
Expand Down
Loading

0 comments on commit 16e2f11

Please sign in to comment.