Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 14 additions & 1 deletion enterprise_catalog/apps/api/tasks.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
from celery import shared_task, states
from celery.exceptions import Ignore
from celery_utils.logged_task import LoggedTask
from django.conf import settings
from django.db import IntegrityError
from django.db.models import Prefetch, Q
from django.db.utils import OperationalError
Expand Down Expand Up @@ -43,6 +44,7 @@
VIDEO,
)
from enterprise_catalog.apps.catalog.content_metadata_utils import (
remove_restricted_course_runs,
transform_course_metadata_to_visible,
)
from enterprise_catalog.apps.catalog.models import (
Expand Down Expand Up @@ -71,6 +73,10 @@
# ENT-4980 every batch "shard" record in Algolia should have all of these that pertain to the course
EXPLORE_CATALOG_TITLES = ['A la carte', 'Subscription']

# If enabled, query parameter used to fetch additional restricted course runs
# associated with a course
QUERY_FOR_RESTRICTED_RUNS = {'include_restricted': 'custom-b2b-enterprise'}


def _fetch_courses_by_keys(course_keys):
"""
Expand All @@ -81,7 +87,10 @@ def _fetch_courses_by_keys(course_keys):
Returns:
list of dict: Returns a list of dictionaries where each dictionary represents the course data from discovery.
"""
return DiscoveryApiClient().fetch_courses_by_keys(course_keys)
additional_params = None
if settings.SHOULD_FETCH_RESTRICTED_COURSE_RUNS:
additional_params = QUERY_FOR_RESTRICTED_RUNS
return DiscoveryApiClient().fetch_courses_by_keys(course_keys, additional_params=additional_params)


def _fetch_programs_by_keys(program_keys):
Expand Down Expand Up @@ -285,6 +294,10 @@ def _update_full_content_metadata_course(content_keys, dry_run=False):
logger.error('Could not find ContentMetadata record for content_key %s.', content_key)
continue

# Before we do anything else, we need to prune the restricted
# course runs out of the recently fetch course metadata payload.
remove_restricted_course_runs(course_metadata_dict)

# Merge the full metadata from discovery's /api/v1/courses into the local metadata object.
metadata_record.json_metadata.update(course_metadata_dict)

Expand Down
23 changes: 4 additions & 19 deletions enterprise_catalog/apps/api/v1/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,10 @@
urlunsplit,
)

from enterprise_catalog.apps.catalog.content_metadata_utils import (
is_course_run_active,
)


logger = logging.getLogger(__name__)

Expand Down Expand Up @@ -59,25 +63,6 @@ def get_enterprise_utm_context(enterprise_name):
return utm_context


def is_course_run_active(course_run):
"""
Checks whether a course run is active. That is, whether the course run is published,
enrollable, and marketable.

Arguments:
course_run (dict): The metadata about a course run.

Returns:
bool: True if course run is "active"
"""
course_run_status = course_run.get('status') or ''
is_published = course_run_status.lower() == 'published'
is_enrollable = course_run.get('is_enrollable', False)
is_marketable = course_run.get('is_marketable', False)

return is_published and is_enrollable and is_marketable


def is_any_course_run_active(course_runs):
"""
Iterates over all course runs to check if there any course run that is available for enrollment.
Expand Down
9 changes: 6 additions & 3 deletions enterprise_catalog/apps/api_client/discovery.py
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
DISCOVERY_PROGRAM_KEY_BATCH_SIZE,
)
from enterprise_catalog.apps.catalog.content_metadata_utils import (
tansform_force_included_courses,
transform_force_included_courses,
)
from enterprise_catalog.apps.catalog.utils import batch

Expand Down Expand Up @@ -301,7 +301,7 @@ def get_metadata_by_query(self, catalog_query):
f'attempting to force-include: {forced_aggregation_keys}'
)
forced_courses = self.fetch_courses_by_keys(forced_aggregation_keys)
results += tansform_force_included_courses(forced_courses)
results += transform_force_included_courses(forced_courses)
except Exception as exc:
LOGGER.exception(
f'unable to add unlisted courses for catalog_id: {catalog_query.id}'
Expand Down Expand Up @@ -425,12 +425,14 @@ def get_programs(self, query_params=None):

return programs

def fetch_courses_by_keys(self, course_keys):
def fetch_courses_by_keys(self, course_keys, additional_params=None):
"""
Fetches course data from discovery's /api/v1/courses endpoint for the provided course keys.

Args:
course_keys (list of str): Content keys for Course ContentMetadata objects.
additional_params (dict): Optional dict of additional query parameters
to send in request for course metadata.
Returns:
list of dict: Returns a list of dictionaries where each dictionary represents the course
data from discovery.
Expand All @@ -442,6 +444,7 @@ def fetch_courses_by_keys(self, course_keys):
for course_keys_chunk in batched_course_keys:
# Discovery expects the keys param to be in the format ?keys=course1,course2,...
query_params = {'keys': ','.join(course_keys_chunk)}
query_params.update(additional_params or {})
courses.extend(self.get_courses(query_params=query_params))

return courses
Expand Down
46 changes: 16 additions & 30 deletions enterprise_catalog/apps/catalog/algolia_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@
from django.utils.translation import gettext as _
from pytz import UTC

from enterprise_catalog.apps.api.v1.utils import is_course_run_active
from enterprise_catalog.apps.api_client.algolia import AlgoliaSearchClient
from enterprise_catalog.apps.api_client.constants import (
COURSE_REVIEW_BASE_AVG_REVIEW_SCORE,
Expand All @@ -27,13 +26,17 @@
PROGRAM_TYPES_MAP,
VIDEO,
)
from enterprise_catalog.apps.catalog.content_metadata_utils import (
get_course_first_paid_enrollable_seat_price,
get_course_run_by_uuid,
is_course_run_active,
)
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 @@ -1277,33 +1280,6 @@ def _get_course_run_enroll_by_date_timestamp(normalized_content_metadata):
return to_timestamp(enroll_by_date)


def get_course_first_paid_enrollable_seat_price(course):
"""
Gets the appropriate image to use for course cards.

Arguments:
course (dict): a dictionary representing a course

Returns:
str: the url for the course card image
"""
# 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'))
if advertised_course_run and advertised_course_run.get('first_enrollable_paid_seat_price'):
return advertised_course_run.get('first_enrollable_paid_seat_price')

course_runs = course.get('course_runs') or []
active_course_runs = [run for run in course_runs if is_course_run_active(run)]
for course_run in sorted(
active_course_runs,
key=lambda active_course_run: active_course_run['key'].lower(),
):
if 'first_enrollable_paid_seat_price' in course_run:
return course_run['first_enrollable_paid_seat_price']
return None


def get_learning_type(content):
"""
Gets the content's learning type, checking and returning if the content
Expand Down Expand Up @@ -1473,6 +1449,16 @@ def get_video_duration(video):
return video.json_metadata.get('duration')


def _first_enrollable_paid_seat_price(course_record):
"""
Returns the course-level first_enrollable_paid_seat_price,
or computes it based on the course runs.
"""
if course_value := course_record.get('first_enrollable_paid_seat_price'):
return course_value
return get_course_first_paid_enrollable_seat_price(course_record)


def _algolia_object_from_product(product, algolia_fields):
"""
Transforms a course or program into an Algolia object.
Expand All @@ -1499,7 +1485,7 @@ def _algolia_object_from_product(product, algolia_fields):
'upcoming_course_runs': get_upcoming_course_runs(searchable_product),
'skill_names': get_course_skill_names(searchable_product),
'skills': get_course_skills(searchable_product),
'first_enrollable_paid_seat_price': get_course_first_paid_enrollable_seat_price(searchable_product),
'first_enrollable_paid_seat_price': _first_enrollable_paid_seat_price(searchable_product),
'original_image_url': get_course_original_image_url(searchable_product),
'marketing_url': get_course_marketing_url(searchable_product),
'outcome': get_course_outcome(searchable_product),
Expand Down
2 changes: 2 additions & 0 deletions enterprise_catalog/apps/catalog/constants.py
Original file line number Diff line number Diff line change
Expand Up @@ -121,6 +121,8 @@

COURSE_RUN_KEY_PREFIX = 'course-v1:'

COURSE_RUN_RESTRICTION_TYPE_KEY = 'restriction_type'


def json_serialized_course_modes():
"""
Expand Down
128 changes: 126 additions & 2 deletions enterprise_catalog/apps/catalog/content_metadata_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,13 +6,16 @@

from enterprise_catalog.apps.catalog.utils import get_content_key

from .constants import FORCE_INCLUSION_METADATA_TAG_KEY
from .constants import (
COURSE_RUN_RESTRICTION_TYPE_KEY,
FORCE_INCLUSION_METADATA_TAG_KEY,
)


LOGGER = getLogger(__name__)


def tansform_force_included_courses(courses):
def transform_force_included_courses(courses):
"""
Transform a list of forced/unlisted course metadata
ENT-8212
Expand Down Expand Up @@ -41,3 +44,124 @@ def transform_course_metadata_to_visible(course_metadata):
course_run_statuses.append(course_run.get('status'))
course_metadata['course_run_statuses'] = course_run_statuses
return course_metadata


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 is_course_run_active(course_run):
"""
Checks whether a course run is active. That is, whether the course run is published,
enrollable, and marketable.

Arguments:
course_run (dict): The metadata about a course run.

Returns:
bool: True if course run is "active"
"""
course_run_status = course_run.get('status') or ''
is_published = course_run_status.lower() == 'published'
is_enrollable = course_run.get('is_enrollable', False)
is_marketable = course_run.get('is_marketable', False)

return is_published and is_enrollable and is_marketable


def get_course_first_paid_enrollable_seat_price(course):
"""
Arguments:
course (dict): a dictionary representing a course

Returns:
The first enrollable paid seat price for the 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'))
if advertised_course_run and advertised_course_run.get('first_enrollable_paid_seat_price'):
return advertised_course_run.get('first_enrollable_paid_seat_price')

course_runs = course.get('course_runs') or []
active_course_runs = [run for run in course_runs if is_course_run_active(run)]
for course_run in sorted(
active_course_runs,
key=lambda active_course_run: active_course_run['key'].lower(),
):
if 'first_enrollable_paid_seat_price' in course_run:
return course_run['first_enrollable_paid_seat_price']
return None


def find_restricted_course_runs(course_json_metadata):
"""
Filter to find and enumerate any restricted runs present in a dictionary
of course JSON metadata.
"""
found_restricted_runs = []
for course_run in course_json_metadata.get('course_runs', []):
if course_run.get(COURSE_RUN_RESTRICTION_TYPE_KEY):
found_restricted_runs.append(course_run)
return found_restricted_runs


def remove_restricted_course_runs(course_json_metadata):
"""
Prevents restricted runs from being written to ContentMetadata.json_metadata before saving.
This includes removing the run from all run-based json keys:
* ContentMetadata.json_metadata["course_runs"]
* ContentMetadata.json_metadata["course_run_keys"]
* ContentMetadata.json_metadata["course_run_statuses"]

It also updates the `first_enrollable_paid_seat_price` of the course after restricted runs
are removed.
"""
found_restricted_runs = find_restricted_course_runs(course_json_metadata)
if not found_restricted_runs:
return

restricted_keys = {run['key'] for run in found_restricted_runs}
LOGGER.info(
'[restricted runs] Course %s has restricted runs %s that will be removed.',
course_json_metadata['key'],
restricted_keys,
)

non_restricted_keys = []
non_restricted_runs = []
non_restricted_statuses = set()

for course_run in course_json_metadata['course_runs']:
if course_run['key'] not in restricted_keys:
non_restricted_keys.append(course_run['key'])
non_restricted_runs.append(course_run)
non_restricted_statuses.add(course_run['status'])

course_json_metadata['course_runs'] = non_restricted_runs
course_json_metadata['course_run_keys'] = non_restricted_keys
course_json_metadata['course_run_statuses'] = sorted(list(non_restricted_statuses))

# also recompute the first enrollable paid seat price for the course
# now that we've removed any restricted runs
course_json_metadata['first_enrollable_paid_seat_price'] = get_course_first_paid_enrollable_seat_price(
course_json_metadata,
)
Comment thread
iloveagent57 marked this conversation as resolved.
4 changes: 3 additions & 1 deletion enterprise_catalog/apps/catalog/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,9 @@

from enterprise_catalog.apps.api.constants import CourseMode
from enterprise_catalog.apps.catalog.constants import EXEC_ED_2U_COURSE_TYPE
from enterprise_catalog.apps.catalog.utils import get_course_run_by_uuid
from enterprise_catalog.apps.catalog.content_metadata_utils import (
get_course_run_by_uuid,
)


logger = logging.getLogger(__name__)
Expand Down
Loading