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.
  • Loading branch information
adamstankiewicz committed Sep 10, 2024
1 parent 53875bd commit 1fb4272
Show file tree
Hide file tree
Showing 13 changed files with 368 additions and 185 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
20 changes: 13 additions & 7 deletions enterprise_access/apps/api/v1/tests/test_allocation_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,7 @@ def setUpTestData(cls):
super().setUpTestData()
cls.enterprise_uuid = TEST_ENTERPRISE_UUID
cls.content_key = 'course-v1:edX+edXPrivacy101+3T2020'
cls.parent_content_key = 'edX+edXPrivacy101'
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='[email protected]',
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='[email protected]',
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='[email protected]',
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 @@ -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': '[email protected]',
'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': '[email protected]',
'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': '[email protected]',
'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 @@ -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
14 changes: 12 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 @@ -761,8 +766,13 @@ def nudge_assignments(assignments, assignment_configuration_uuid, days_before_co
enterprise_catalog_uuid,
[assignment],
)
print('content_metadata_for_assignments?!?!', content_metadata_for_assignments)
content_metadata = content_metadata_for_assignments.get(assignment.content_key, {})
start_date = content_metadata.get('normalized_metadata', {}).get('start_date')
print('content_metadata?!?!', content_metadata)
normalized_metadata = get_normalized_metadata_for_assignment(assignment, content_metadata)
print('normalized_metadata?!?!', normalized_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
25 changes: 21 additions & 4 deletions enterprise_access/apps/content_assignments/content_metadata_api.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,28 @@ def get_content_metadata_for_assignments(enterprise_catalog_uuid, 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)
metadata_by_key = {
record['key']: record for record in content_metadata_list
content_keys = {
(assignment.content_key, assignment.is_assigned_course_run)
for assignment in assignments
}
content_metadata_list = get_and_cache_catalog_content_metadata(enterprise_catalog_uuid, content_keys)
metadata_by_key = {}
for record in content_metadata_list:
record_key = record.get('key')

# Now, check if the record_key matches either the content_key or parent_content_key in the original content_keys
for content_key, is_assigned_course_run in content_keys:
if is_assigned_course_run:
metadata_by_key.update({
content_key: record
})
break # Stop searching after the match is found
if record_key == content_key:
metadata_by_key.update({
record_key: record
})
break # Stop searching after the match is found

return {
assignment.content_key: metadata_by_key.get(assignment.content_key)
for assignment in assignments
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@
)
from enterprise_access.apps.content_assignments.models import AssignmentConfiguration
from enterprise_access.apps.content_assignments.tasks import send_exec_ed_enrollment_warmer
from enterprise_access.utils import get_normalized_metadata_for_assignment

logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -155,23 +156,20 @@ def handle(self, *args, **options):
)
continue

# Nudge learners based on the start date of the "preferred" course run, NOT the start date from the
# "normalized metadata" derived from the *advertised* course run. That latter assumption caused us
# problems in the past because this script would just follow every new published run and keep
# re-triggering nudge emails.
course_run_metadata = next(
run for run in content_metadata['course_runs']
if run['key'] == assignment.preferred_course_run_key
)
start_date = course_run_metadata.get('start')
normalized_metadata = get_normalized_metadata_for_assignment(assignment, content_metadata)
start_date = normalized_metadata.get('start_date')

# Determine if the date from today + days_before_course_state_date is
# equal to the date of the start date
# If they are equal, then send the nudge email, otherwise continue
datetime_start_date = parse_datetime_string(start_date, set_to_utc=True)
can_send_nudge_notification_in_advance = is_date_n_days_from_now(
target_datetime=datetime_start_date,
num_days=days_before_course_start_date
can_send_nudge_notification_in_advance = (
is_date_n_days_from_now(
target_datetime=datetime_start_date,
num_days=days_before_course_start_date
)
if start_date is not None
else False
)
if not can_send_nudge_notification_in_advance:
logger.info(
Expand Down
Loading

0 comments on commit 1fb4272

Please sign in to comment.