Skip to content

Commit

Permalink
feat: include content_price in indexed course_runs for Course objects
Browse files Browse the repository at this point in the history
  • Loading branch information
adamstankiewicz committed Sep 16, 2024
1 parent 29f5164 commit f362639
Show file tree
Hide file tree
Showing 6 changed files with 115 additions and 86 deletions.
4 changes: 2 additions & 2 deletions enterprise_catalog/apps/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -291,16 +291,16 @@ def _update_full_content_metadata_course(content_keys, dry_run=False):
# Perform more steps to normalize and move keys around
# for more consistency across content types.
normalized_metadata_input = {
'course': metadata_record,
'course_metadata': metadata_record.json_metadata,
}
metadata_record.json_metadata['normalized_metadata'] =\
NormalizedContentMetadataSerializer(normalized_metadata_input).data
metadata_record.json_metadata['normalized_metadata_by_run'] = {}
for run in metadata_record.json_metadata.get('course_runs', []):
metadata_record.json_metadata['normalized_metadata_by_run'].update({
run['key']: NormalizedContentMetadataSerializer({
'course_metadata': metadata_record.json_metadata,
'course_run_metadata': run,
'course': metadata_record,
}).data
})

Expand Down
99 changes: 46 additions & 53 deletions enterprise_catalog/apps/catalog/algolia_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -28,8 +28,12 @@
VIDEO,
)
from enterprise_catalog.apps.catalog.models import ContentMetadata
from enterprise_catalog.apps.catalog.serializers import (
NormalizedContentMetadataSerializer,
)
from enterprise_catalog.apps.catalog.utils import (
batch_by_pk,
get_course_run_by_uuid,
localized_utcnow,
to_timestamp,
)
Expand Down Expand Up @@ -201,7 +205,7 @@ def _should_index_course(course_metadata):
"""
course_json_metadata = course_metadata.json_metadata
advertised_course_run_uuid = course_json_metadata.get('advertised_course_run_uuid')
advertised_course_run = _get_course_run_by_uuid(
advertised_course_run = get_course_run_by_uuid(
course_json_metadata,
advertised_course_run_uuid,
)
Expand Down Expand Up @@ -440,7 +444,7 @@ def get_course_language(course):
Returns:
string: human-readable language name parsed from a language code, or None if language name is not present.
"""
advertised_course_run = _get_course_run_by_uuid(course, course.get('advertised_course_run_uuid'))
advertised_course_run = get_course_run_by_uuid(course, course.get('advertised_course_run_uuid'))
if not advertised_course_run:
return None

Expand All @@ -459,7 +463,7 @@ def get_course_transcript_languages(course):
Returns:
list: a list of available human-readable video transcript languages parsed from a language code.
"""
advertised_course_run = _get_course_run_by_uuid(course, course.get('advertised_course_run_uuid'))
advertised_course_run = get_course_run_by_uuid(course, course.get('advertised_course_run_uuid'))
if not advertised_course_run:
return None

Expand Down Expand Up @@ -1084,31 +1088,41 @@ def get_course_prerequisites(course):
return prerequisites


def _get_course_run(full_course_run):
def _get_course_run(course, course_run):
"""
Transform a full course run into what we'll index.
Transform a course run into what gets indexed in Algolia. Depending on the course type,
some metadata may be derived from the top-level course (e.g., for Exec Ed vs. OCM content).
Date attributes (e.g., `enroll_by`) are recorded as Unix timestamps so Algolia can filter on them.
Arguments:
full_course_run (dict): a dictionary representing a course run
course (dict): a dictionary representing a course
course_run (dict): a dictionary representing a course run
Returns:
dict: a subseted and transformed dictionary from full_course_run
dict: a subseted and transformed dictionary from course_run
"""
if full_course_run is None:
if course is None or course_run is None:
return None
# upgrade_deadline is recorded in EPOCH time

normalized_content_metadata = NormalizedContentMetadataSerializer({
'course_metadata': course,
'course_run_metadata': course_run,
}).data

course_run = {
'key': full_course_run.get('key'),
'pacing_type': full_course_run.get('pacing_type'),
'availability': full_course_run.get('availability'),
'start': full_course_run.get('start'),
'end': full_course_run.get('end'),
'min_effort': full_course_run.get('min_effort'),
'max_effort': full_course_run.get('max_effort'),
'weeks_to_complete': full_course_run.get('weeks_to_complete'),
'upgrade_deadline': _get_verified_upgrade_deadline(full_course_run), # deprecated in favor of `enroll_by`
'enroll_by': _get_course_run_enroll_by_date_timestamp(full_course_run),
'is_active': _get_is_active_course_run(full_course_run),
'key': course_run.get('key'),
'pacing_type': course_run.get('pacing_type'),
'availability': course_run.get('availability'),
'start': course_run.get('start'),
'end': course_run.get('end'),
'min_effort': course_run.get('min_effort'),
'max_effort': course_run.get('max_effort'),
'weeks_to_complete': course_run.get('weeks_to_complete'),
'upgrade_deadline': _get_verified_upgrade_deadline(course_run), # deprecated in favor of `enroll_by`
'enroll_by': _get_course_run_enroll_by_date_timestamp(normalized_content_metadata),
'content_price': normalized_content_metadata.get('content_price'),
'is_active': _get_is_active_course_run(course_run),
}
return course_run

Expand All @@ -1124,10 +1138,10 @@ def get_advertised_course_run(course):
dict: containing key, pacing_type, start, end, and upgrade deadline
for the course_run, or None
"""
full_course_run = _get_course_run_by_uuid(course, course.get('advertised_course_run_uuid'))
full_course_run = get_course_run_by_uuid(course, course.get('advertised_course_run_uuid'))
if full_course_run is None:
return None
return _get_course_run(full_course_run)
return _get_course_run(course, full_course_run)


def get_course_runs(course):
Expand All @@ -1143,7 +1157,7 @@ def get_course_runs(course):
output = []
course_runs = course.get('course_runs') or []
for full_course_run in course_runs:
this_course_run = _get_course_run(full_course_run)
this_course_run = _get_course_run(course, full_course_run)
is_late_enrollment_enroll_by_date_before_now = False
is_end_date_before_now = False
if enroll_by := this_course_run.get('enroll_by'):
Expand Down Expand Up @@ -1182,24 +1196,6 @@ def get_upcoming_course_runs(course):
return len(active_course_runs)


def _get_course_run_by_uuid(course, course_run_uuid):
"""
Find a course_run based on uuid
Arguments:
course (dict): course dict
course_run_uuid (str): uuid to lookup
Returns:
dict: a course_run or None
"""
try:
course_run = [run for run in course.get('course_runs', []) if run.get('uuid') == course_run_uuid][0]
except IndexError:
return None
return course_run


def _get_verified_upgrade_deadline(full_course_run):
"""
Check to see if course has a verified seat option, and if so, return the verified upgrade deadline
Expand Down Expand Up @@ -1248,20 +1244,17 @@ def _get_is_active_course_run(full_course_run):
return is_active


def _get_course_run_enroll_by_date_timestamp(full_course_run):
def _get_course_run_enroll_by_date_timestamp(normalized_content_metadata):
"""
Returns the enroll-by date for Exec-ed or OCM courses
`_get_verified_upgrade_deadline` retrieves OCM related course run end dates
`enrollment_end` corresponds to an Exec-ed related course run end date
Returns a transformed enroll-by date, converted to a Unix timestamp.
If no end date is provided in either field, it returns the ALGOLIA_DEFAULT_TIMESTAMP
since Algolia cannot filter on null values
If no enroll-by date is provided, it returns the ALGOLIA_DEFAULT_TIMESTAMP
since Algolia cannot filter on null values.
"""
upgrade_deadline_timestamp = _get_verified_upgrade_deadline(full_course_run=full_course_run)
enrollment_end_timestamp = full_course_run.get('enrollment_end') or ALGOLIA_DEFAULT_TIMESTAMP
if not isinstance(enrollment_end_timestamp, (int, float)):
enrollment_end_timestamp = to_timestamp(enrollment_end_timestamp)
return min(enrollment_end_timestamp, upgrade_deadline_timestamp)
enroll_by_date = normalized_content_metadata.get('enroll_by_date')
if not enroll_by_date:
return ALGOLIA_DEFAULT_TIMESTAMP
return to_timestamp(enroll_by_date)


def get_course_first_paid_enrollable_seat_price(course):
Expand All @@ -1276,7 +1269,7 @@ def get_course_first_paid_enrollable_seat_price(course):
"""
# Use advertised course run.
# If that fails use one of the other active course runs. (The latter is what Discovery does)
advertised_course_run = _get_course_run_by_uuid(course, course.get('advertised_course_run_uuid'))
advertised_course_run = get_course_run_by_uuid(course, course.get('advertised_course_run_uuid'))
if advertised_course_run and advertised_course_run.get('first_enrollable_paid_seat_price'):
return advertised_course_run.get('first_enrollable_paid_seat_price')

Expand Down
19 changes: 9 additions & 10 deletions enterprise_catalog/apps/catalog/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from rest_framework import serializers

from enterprise_catalog.apps.api.constants import CourseMode

from .algolia_utils import _get_course_run_by_uuid
from enterprise_catalog.apps.catalog.constants import EXEC_ED_2U_COURSE_TYPE
from enterprise_catalog.apps.catalog.utils import get_course_run_by_uuid


logger = logging.getLogger(__name__)
Expand Down Expand Up @@ -99,20 +99,19 @@ class NormalizedContentMetadataSerializer(ReadOnlySerializer):
content_price = serializers.SerializerMethodField(help_text='The price of a course in USD')

@cached_property
def course(self):
return self.instance.get('course')
def course_metadata(self):
return self.instance.get('course_metadata')

@cached_property
def course_metadata(self):
return self.course.json_metadata
def is_exec_ed_2u_course(self):
return self.course_metadata.get('course_type') == EXEC_ED_2U_COURSE_TYPE

@cached_property
def course_run_metadata(self):
run_metadata = self.instance.get('course_run_metadata')
if run_metadata:
if run_metadata := self.instance.get('course_run_metadata'):
return run_metadata
advertised_course_run_uuid = self.course_metadata.get('advertised_course_run_uuid')
return _get_course_run_by_uuid(self.course_metadata, advertised_course_run_uuid)
return get_course_run_by_uuid(self.course_metadata, advertised_course_run_uuid)

@extend_schema_field(serializers.DateTimeField)
def get_start_date(self, obj) -> str: # pylint: disable=unused-argument
Expand All @@ -139,7 +138,7 @@ def get_enroll_by_date(self, obj) -> str: # pylint: disable=unused-argument

@extend_schema_field(serializers.FloatField)
def get_content_price(self, obj) -> float: # pylint: disable=unused-argument
if self.course.is_exec_ed_2u_course:
if self.is_exec_ed_2u_course is True:
for entitlement in self.course_metadata.get('entitlements', []):
if entitlement.get('mode') == CourseMode.PAID_EXECUTIVE_EDUCATION:
return entitlement.get('price') or DEFAULT_NORMALIZED_PRICE
Expand Down
40 changes: 30 additions & 10 deletions enterprise_catalog/apps/catalog/tests/test_algolia_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,7 +16,7 @@
from enterprise_catalog.apps.catalog.tests.factories import (
ContentMetadataFactory,
)
from enterprise_catalog.apps.catalog.utils import localized_utcnow
from enterprise_catalog.apps.catalog.utils import localized_utcnow, to_timestamp


ADVERTISED_COURSE_RUN_UUID = uuid4()
Expand All @@ -25,11 +25,15 @@
PAST_COURSE_RUN_UUID_1 = uuid4()


def _fake_upgrade_deadline(days_from_now=0):
def _days_from_now(days_from_now=0):
deadline = localized_utcnow() + timedelta(days=days_from_now)
return deadline.strftime('%Y-%m-%dT%H:%M:%SZ')


def _days_from_now_timestamp(days_from_now):
return to_timestamp(_days_from_now(days_from_now))


@ddt.ddt
class AlgoliaUtilsTests(TestCase):
"""
Expand All @@ -48,19 +52,19 @@ class AlgoliaUtilsTests(TestCase):
{
'expected_result': True,
'seats': [
{'type': 'verified', 'upgrade_deadline': _fake_upgrade_deadline(100)}
{'type': 'verified', 'upgrade_deadline': _days_from_now(100)}
],
},
{
'expected_result': True,
'seats': [
{'type': 'something-else', 'upgrade_deadline': _fake_upgrade_deadline(-100)}
{'type': 'something-else', 'upgrade_deadline': _days_from_now(-100)}
],
},
{
'expected_result': False,
'seats': [
{'type': 'verified', 'upgrade_deadline': _fake_upgrade_deadline(-1)}
{'type': 'verified', 'upgrade_deadline': _days_from_now(-1)}
],
},
{
Expand Down Expand Up @@ -326,6 +330,7 @@ def test_get_course_subjects(self, course_metadata, expected_subjects):
'upgrade_deadline': 32503680000.0,
'enroll_by': 32503680000.0,
'is_active': True,
'content_price': 0.0,
},
),
(
Expand Down Expand Up @@ -358,7 +363,9 @@ def test_get_course_subjects(self, course_metadata, expected_subjects):
}, {
'type': 'verified',
'upgrade_deadline': '2015-01-04T15:52:00Z',
'price': '50.00',
}],
'first_enrollable_paid_seat_price': 50,
}],
'advertised_course_run_uuid': ADVERTISED_COURSE_RUN_UUID
},
Expand All @@ -374,6 +381,7 @@ def test_get_course_subjects(self, course_metadata, expected_subjects):
'upgrade_deadline': 1420386720.0,
'enroll_by': 1420386720.0,
'is_active': True,
'content_price': 50.0,
}
),
(
Expand All @@ -394,7 +402,9 @@ def test_get_course_subjects(self, course_metadata, expected_subjects):
'seats': [{
'type': 'verified',
'upgrade_deadline': None,
'price': '50.00',
}],
'first_enrollable_paid_seat_price': 50,
}],
'advertised_course_run_uuid': ADVERTISED_COURSE_RUN_UUID
},
Expand All @@ -410,6 +420,7 @@ def test_get_course_subjects(self, course_metadata, expected_subjects):
'upgrade_deadline': 32503680000.0,
'enroll_by': 32503680000.0,
'is_active': True,
'content_price': 50.0,
}
)
)
Expand Down Expand Up @@ -497,6 +508,12 @@ def test_get_upcoming_course_runs(self, searchable_course, expected_course_runs)
'min_effort': 2,
'max_effort': 6,
'weeks_to_complete': 6,
'seats': [{
'type': 'verified',
'upgrade_deadline': _days_from_now(10),
'price': '50.00',
}],
'first_enrollable_paid_seat_price': 50,
},
{
'key': 'course-v1:org+course+1T3000',
Expand Down Expand Up @@ -539,9 +556,10 @@ def test_get_upcoming_course_runs(self, searchable_course, expected_course_runs)
'min_effort': 2,
'max_effort': 6,
'weeks_to_complete': 6,
'upgrade_deadline': 32503680000.0,
'enroll_by': 32503680000.0,
'upgrade_deadline': _days_from_now_timestamp(10),
'enroll_by': _days_from_now_timestamp(10),
'is_active': True,
'content_price': 50.0,
},
{
'key': 'course-v1:org+course+1T3000',
Expand All @@ -555,6 +573,7 @@ def test_get_upcoming_course_runs(self, searchable_course, expected_course_runs)
'upgrade_deadline': 32503680000.0,
'enroll_by': 32503680000.0,
'is_active': True,
'content_price': 0.0,
},
{
'key': 'course-v1:org+course+1T3022',
Expand All @@ -568,17 +587,18 @@ def test_get_upcoming_course_runs(self, searchable_course, expected_course_runs)
'upgrade_deadline': 32503680000.0,
'enroll_by': 32503680000.0,
'is_active': False,
'content_price': 0.0,
}
],
),
)
@ddt.unpack
def test_get_course_runs(self, searchable_course, expected_course_runs):
"""
Assert get_advertised_course_runs fetches just enough info about advertised course run
Assert get_course_runs returns the expected course runs.
"""
upcoming_course_runs = utils.get_course_runs(searchable_course)
assert upcoming_course_runs == expected_course_runs
actual_course_runs = utils.get_course_runs(searchable_course)
assert actual_course_runs == expected_course_runs

@ddt.data(
(
Expand Down
Loading

0 comments on commit f362639

Please sign in to comment.