Skip to content

Commit

Permalink
feat: adding course run statuses to programs/pathways (#4248)
Browse files Browse the repository at this point in the history
* feat: adding course run statuses to programs/pathways

* fix: test fixes

* fix: add course_run_statuses to programs and pathways

---------

Co-authored-by: Ali Akbar <[email protected]>
  • Loading branch information
kiram15 and Ali-D-Akbar authored Feb 1, 2024
1 parent e873415 commit d98abb7
Show file tree
Hide file tree
Showing 6 changed files with 99 additions and 17 deletions.
5 changes: 4 additions & 1 deletion course_discovery/apps/api/serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -2197,6 +2197,7 @@ class ProgramSerializer(MinimalProgramSerializer):
skill_names = serializers.SerializerMethodField()
skills = serializers.SerializerMethodField()
product_source = SourceSerializer(required=False, read_only=True)
course_run_statuses = serializers.ReadOnlyField()

@classmethod
def prefetch_queryset(cls, partner, queryset=None):
Expand Down Expand Up @@ -2258,7 +2259,7 @@ class Meta(MinimalProgramSerializer.Meta):
'staff', 'credit_redemption_overview', 'applicable_seat_types', 'instructor_ordering',
'enrollment_count', 'topics', 'credit_value', 'enterprise_subscription_inclusion', 'geolocation',
'location_restriction', 'is_2u_degree_program', 'in_year_value', 'skill_names', 'skills',
'product_source', 'excluded_from_search', 'excluded_from_seo'
'product_source', 'excluded_from_search', 'excluded_from_seo', 'course_run_statuses',
)
read_only_fields = ('enterprise_subscription_inclusion', 'product_source',)

Expand All @@ -2273,6 +2274,7 @@ class PathwaySerializer(BaseModelSerializer):
description = serializers.CharField()
destination_url = serializers.CharField()
pathway_type = serializers.CharField()
course_run_statuses = serializers.ReadOnlyField()

@classmethod
def prefetch_queryset(cls, partner):
Expand All @@ -2294,6 +2296,7 @@ class Meta:
'description',
'destination_url',
'pathway_type',
'course_run_statuses',
)


Expand Down
12 changes: 7 additions & 5 deletions course_discovery/apps/api/tests/test_serializers.py
Original file line number Diff line number Diff line change
Expand Up @@ -148,7 +148,6 @@ def get_expected_data(cls, course, course_skill, request): # pylint: disable=un
}

def test_data(self):
self.maxDiff = None
request = make_request()
organizations = OrganizationFactory(partner=self.partner)
course = CourseFactory(authoring_organizations=[organizations], partner=self.partner)
Expand Down Expand Up @@ -1166,6 +1165,7 @@ def get_expected_data(cls, program, request, include_labels=True):
'skills': [],
'subscription_eligible': None,
'subscription_prices': [],
'course_run_statuses': ['published'],
})
return expected

Expand Down Expand Up @@ -1424,6 +1424,7 @@ def test_degree_marketing_data(self):
expected_micromasters_path = url.sub('', degree.micromasters_url)

# Tack in degree data
expected['course_run_statuses'] = []
expected['curricula'] = [expected_curriculum]
expected['degree'] = {
'application_requirements': degree.application_requirements,
Expand Down Expand Up @@ -1488,7 +1489,7 @@ def test_data_with_subscription(self):
self.assertIsNotNone(serializer.data['subscription_prices'])


class PathwaySerialzerTests(TestCase):
class PathwaySerializerTest(TestCase):
def test_data(self):
pathway = PathwayFactory()
serializer = PathwaySerializer(pathway)
Expand All @@ -1503,6 +1504,7 @@ def test_data(self):
'description': pathway.description,
'destination_url': pathway.destination_url,
'pathway_type': pathway.pathway_type,
'course_run_statuses': [],
}
self.assertDictEqual(serializer.data, expected)

Expand Down Expand Up @@ -2691,6 +2693,8 @@ def test_data_with_languages(self):
program = ProgramFactory(courses=[course_run.course])
serializer = self.serialize_program(program, self.request)
expected = self.get_expected_data(program, self.request)
if serializer.data.get('course_run_statuses'):
expected['course_run_statuses'] = ['published']
assert serializer.data == expected
if 'language' in expected:
assert {'English', 'Chinese - Mandarin'} == {*expected['language']}
Expand All @@ -2706,6 +2710,7 @@ def get_expected_data(cls, program, request):
expected = ProgramSerializerTests.get_expected_data(program, request, include_labels=False)
expected.update({'content_type': 'program'})
expected.update({'marketing_hook': program.marketing_hook})
expected.update({'course_run_statuses': []})
return expected


Expand Down Expand Up @@ -2887,8 +2892,6 @@ class CollaboratorSerializerTests(TestCase):
serializer_class = CollaboratorSerializer

def test_data(self):
self.maxDiff = None

request = make_request()

image_field = StdImageSerializerField()
Expand Down Expand Up @@ -2953,7 +2956,6 @@ def get_expected_data(cls, course, course_skill, request):
}

def test_course_with_recommendations(self):
self.maxDiff = None
request = make_request()
context = {'request': request}
organization = OrganizationFactory(partner=self.partner)
Expand Down
43 changes: 32 additions & 11 deletions course_discovery/apps/course_metadata/models.py
Original file line number Diff line number Diff line change
Expand Up @@ -60,8 +60,8 @@
)
from course_discovery.apps.course_metadata.utils import (
UploadToFieldNamePath, clean_query, clear_slug_request_cache_for_course, custom_render_variations,
get_slug_for_course, is_ocm_course, push_to_ecommerce_for_course_run, push_tracks_to_lms_for_course_run,
set_official_state, subtract_deadline_delta
get_course_run_statuses, get_slug_for_course, is_ocm_course, push_to_ecommerce_for_course_run,
push_tracks_to_lms_for_course_run, set_official_state, subtract_deadline_delta
)
from course_discovery.apps.ietf_language_tags.models import LanguageTag
from course_discovery.apps.publisher.utils import VALID_CHARS_IN_COURSE_NUM_AND_ORG_KEY
Expand Down Expand Up @@ -1705,15 +1705,7 @@ def course_run_statuses(self):
invalidates the prefetch on API level.
"""
statuses = set()
now = datetime.datetime.now(pytz.UTC)
for course_run in self.course_runs.all():
if course_run.hidden:
continue
if course_run.end and course_run.end < now and course_run.status == CourseRunStatus.Unpublished:
statuses.add('archived')
else:
statuses.add(course_run.status)
return sorted(list(statuses))
return sorted(list(get_course_run_statuses(statuses, self.course_runs.all())))

def unpublish_inactive_runs(self, published_runs=None):
"""
Expand Down Expand Up @@ -3385,6 +3377,20 @@ def canonical_course_runs(self):
if canonical_course_run and canonical_course_run.id not in excluded_course_run_ids:
yield canonical_course_run

@property
def course_run_statuses(self):
"""
Returns all unique course run status values inside the courses in this program.
Note that it skips hidden courses - this list is typically used for presentational purposes.
The code performs the filtering on Python level instead of ORM/SQL because filtering on course_runs
invalidates the prefetch on API level.
"""
statuses = set()
for course in self.courses.all():
get_course_run_statuses(statuses, course.course_runs.all())
return sorted(list(statuses))

@property
def languages(self):
return {course_run.language for course_run in self.course_runs if course_run.language is not None}
Expand Down Expand Up @@ -4121,6 +4127,21 @@ def validate_partner_programs(cls, partner, programs):
msg = _('These programs are for a different partner than the pathway itself: {}')
raise ValidationError(msg.format(', '.join(bad_programs)))

@property
def course_run_statuses(self):
"""
Returns all unique course run status values inside the programs in this pathway.
Note that it skips hidden courses - this list is typically used for presentational purposes.
The code performs the filtering on Python level instead of ORM/SQL because filtering on course_runs
invalidates the prefetch on API level.
"""
statuses = set()
for program in self.programs.all():
for course in program.courses.all():
get_course_run_statuses(statuses, course.course_runs.all())
return sorted(list(statuses))


class PersonSocialNetwork(TimeStampedModel):
""" Person Social Network model. """
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ class ProgramDocument(BaseDocument, OrganizationsMixin):
is_2u_degree_program = fields.BooleanField()
excluded_from_seo = fields.BooleanField()
excluded_from_search = fields.BooleanField()
course_run_statuses = fields.KeywordField(multi=True)

def prepare_aggregation_key(self, obj):
return 'program:{}'.format(obj.uuid)
Expand Down
38 changes: 38 additions & 0 deletions course_discovery/apps/course_metadata/tests/test_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -1057,6 +1057,24 @@ def test_external_course_availability(self, product_status, expected_availabilit
)
assert course_run.availability == expected_availability

def test_course_run_statuses(self):
""" Verify that we are looping through all runs to get statuses. """

course = factories.CourseFactory()
course_run = factories.CourseRunFactory(course=course, status=CourseRunStatus.Published)
course_run_2 = factories.CourseRunFactory(course=course, status=CourseRunStatus.Unpublished)
start_date_past = datetime.datetime.now(pytz.UTC) - relativedelta(months=2)
end_date_past = datetime.datetime.now(pytz.UTC) - relativedelta(months=1)
course_run_3 = factories.CourseRunFactory(
course=course,
status=CourseRunStatus.Unpublished,
start=start_date_past,
end=end_date_past)
course_run.save()
course_run_2.save()
course_run_3.save()
assert course.course_run_statuses == ['archived', 'published', 'unpublished']

def test_marketing_url(self):
""" Verify the property constructs a marketing URL based on the marketing slug. """
expected = '{root}/course/{slug}'.format(root=self.partner.marketing_site_url_root.strip('/'),
Expand Down Expand Up @@ -2796,6 +2814,26 @@ def test_one_click_purchase_ineligible(self):
program_type=program_type,
)

def test_course_run_statuses(self):
""" Verify that we are looping through all runs to get statuses. """

course = factories.CourseFactory()
course_run = factories.CourseRunFactory(course=course, status=CourseRunStatus.Published)
course_2 = factories.CourseFactory()
course_run_2 = factories.CourseRunFactory(course=course_2, status=CourseRunStatus.Unpublished)
start_date_past = datetime.datetime.now(pytz.UTC) - relativedelta(months=2)
end_date_past = datetime.datetime.now(pytz.UTC) - relativedelta(months=1)
course_run_3 = factories.CourseRunFactory(
course=course_2,
status=CourseRunStatus.Unpublished,
start=start_date_past,
end=end_date_past)
course_run.save()
course_run_2.save()
course_run_3.save()
program = factories.ProgramFactory(courses=[course, course_2])
assert program.course_run_statuses == ['archived', 'published', 'unpublished']

def test_str(self):
"""Verify that a program is properly converted to a str."""
assert str(self.program) == f"{self.program.title} - {self.program.marketing_slug}"
Expand Down
17 changes: 17 additions & 0 deletions course_discovery/apps/course_metadata/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@

import html2text
import markdown
import pytz
import requests
from bs4 import BeautifulSoup
from cairosvg import svg2png
Expand All @@ -27,6 +28,7 @@

from course_discovery.apps.core.models import SalesforceConfiguration
from course_discovery.apps.core.utils import serialize_datetime
from course_discovery.apps.course_metadata.choices import CourseRunStatus
from course_discovery.apps.course_metadata.constants import (
DEFAULT_SLUG_FORMAT_ERROR_MSG, HTML_TAGS_ATTRIBUTE_WHITELIST, IMAGE_TYPES, SLUG_FORMAT_REGEX,
SUBDIRECTORY_SLUG_FORMAT_REGEX
Expand Down Expand Up @@ -1239,3 +1241,18 @@ def get_product_skill_names(product_identifier, product_type):
"""
product_skills = get_whitelisted_serialized_skills(product_identifier, product_type=product_type)
return list({product_skill['name'] for product_skill in product_skills})


def get_course_run_statuses(statuses, course_runs):
"""
Util method to get course run statuses based on the course_runs
"""
now = datetime.datetime.now(pytz.UTC)
for course_run in course_runs:
if course_run.hidden:
continue
if course_run.end and course_run.end < now and course_run.status == CourseRunStatus.Unpublished:
statuses.add('archived')
else:
statuses.add(course_run.status)
return statuses

0 comments on commit d98abb7

Please sign in to comment.