diff --git a/course_discovery/apps/api/serializers.py b/course_discovery/apps/api/serializers.py index 56b8417ae4..963f768c53 100644 --- a/course_discovery/apps/api/serializers.py +++ b/course_discovery/apps/api/serializers.py @@ -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): @@ -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',) @@ -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): @@ -2294,6 +2296,7 @@ class Meta: 'description', 'destination_url', 'pathway_type', + 'course_run_statuses', ) diff --git a/course_discovery/apps/api/tests/test_serializers.py b/course_discovery/apps/api/tests/test_serializers.py index b916675dae..3ed5bee3f6 100644 --- a/course_discovery/apps/api/tests/test_serializers.py +++ b/course_discovery/apps/api/tests/test_serializers.py @@ -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) @@ -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 @@ -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, @@ -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) @@ -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) @@ -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']} @@ -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 @@ -2887,8 +2892,6 @@ class CollaboratorSerializerTests(TestCase): serializer_class = CollaboratorSerializer def test_data(self): - self.maxDiff = None - request = make_request() image_field = StdImageSerializerField() @@ -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) diff --git a/course_discovery/apps/course_metadata/models.py b/course_discovery/apps/course_metadata/models.py index 5a9c88f111..da8ac64678 100644 --- a/course_discovery/apps/course_metadata/models.py +++ b/course_discovery/apps/course_metadata/models.py @@ -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 @@ -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): """ @@ -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} @@ -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. """ diff --git a/course_discovery/apps/course_metadata/search_indexes/documents/program.py b/course_discovery/apps/course_metadata/search_indexes/documents/program.py index 4f879029c8..359949eb83 100644 --- a/course_discovery/apps/course_metadata/search_indexes/documents/program.py +++ b/course_discovery/apps/course_metadata/search_indexes/documents/program.py @@ -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) diff --git a/course_discovery/apps/course_metadata/tests/test_models.py b/course_discovery/apps/course_metadata/tests/test_models.py index 91d5650a76..faf07ef74a 100644 --- a/course_discovery/apps/course_metadata/tests/test_models.py +++ b/course_discovery/apps/course_metadata/tests/test_models.py @@ -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('/'), @@ -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}" diff --git a/course_discovery/apps/course_metadata/utils.py b/course_discovery/apps/course_metadata/utils.py index 5d768cb09a..99bb240ec0 100644 --- a/course_discovery/apps/course_metadata/utils.py +++ b/course_discovery/apps/course_metadata/utils.py @@ -9,6 +9,7 @@ import html2text import markdown +import pytz import requests from bs4 import BeautifulSoup from cairosvg import svg2png @@ -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 @@ -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