From 9530771cad5b096abd2ed1e27b809e2857f5528b Mon Sep 17 00:00:00 2001 From: Leangseu Kim Date: Thu, 8 Sep 2022 20:48:04 -0400 Subject: [PATCH] feat: implement related programs for learner home chore: simplify lms/djangoapps/learner_home/serializers.py Co-authored-by: Nathan Sprenkle chore: safety for authoring_organizations --- lms/djangoapps/learner_home/serializers.py | 51 ++++++++++-- .../learner_home/test_serializers.py | 79 +++++++++++------- lms/djangoapps/learner_home/test_views.py | 83 ++++++++++++++++++- lms/djangoapps/learner_home/views.py | 24 +++++- 4 files changed, 191 insertions(+), 46 deletions(-) diff --git a/lms/djangoapps/learner_home/serializers.py b/lms/djangoapps/learner_home/serializers.py index d17bb2590f..baa215ff25 100644 --- a/lms/djangoapps/learner_home/serializers.py +++ b/lms/djangoapps/learner_home/serializers.py @@ -341,15 +341,42 @@ class EntitlementSerializer(serializers.Serializer): class RelatedProgramSerializer(serializers.Serializer): """Related programs information""" - bannerUrl = serializers.URLField() - estimatedNumberOfWeeks = serializers.IntegerField() - logoUrl = serializers.URLField() - numberOfCourses = serializers.IntegerField() - programType = serializers.CharField() - programUrl = serializers.URLField() - provider = serializers.CharField() + bannerUrl = serializers.URLField(source="banner_image.small.url") + logoUrl = serializers.SerializerMethodField() + numberOfCourses = serializers.SerializerMethodField() + programType = serializers.CharField(source="type") + programUrl = serializers.SerializerMethodField() + provider = serializers.SerializerMethodField() title = serializers.CharField() + def get_numberOfCourses(self, instance): + return len(instance["courses"]) + + def get_logoUrl(self, instance): + return ( + instance["authoring_organizations"][0].get("logo_image_url") + if instance.get("authoring_organizations") + else None + ) + + def get_provider(self, instance): + return ( + instance["authoring_organizations"][0].get("name") + if instance.get("authoring_organizations") + else None + ) + + def get_programUrl(self, instance): + return urljoin( + settings.LMS_ROOT_URL, + instance.get( + "detail_url", + reverse( + "program_details_view", kwargs={"program_uuid": instance["uuid"]} + ), + ), + ) + class ProgramsSerializer(serializers.Serializer): """Programs information""" @@ -374,9 +401,10 @@ class LearnerEnrollmentSerializer(serializers.Serializer): certificate = CertificateSerializer(source="*") entitlement = serializers.SerializerMethodField() gradeData = GradeDataSerializer(source="*") + programs = serializers.SerializerMethodField() # TODO - remove "allow_null" as each of these are implemented, temp for testing. - programs = ProgramsSerializer(allow_null=True) + courseProvider = CourseProviderSerializer(allow_null=True) def get_entitlement(self, instance): """ @@ -390,6 +418,13 @@ class LearnerEnrollmentSerializer(serializers.Serializer): else: return {} + def get_programs(self, instance): + """ + If this enrollment is part of a program, include information about the program and related programs + """ + programs = self.context['programs'].get(str(instance.course_id), []) + return ProgramsSerializer({"relatedPrograms": programs}, context=self.context).data + class UnfulfilledEntitlementSerializer(serializers.Serializer): """ diff --git a/lms/djangoapps/learner_home/test_serializers.py b/lms/djangoapps/learner_home/test_serializers.py index d05ddb84de..445e6047a6 100644 --- a/lms/djangoapps/learner_home/test_serializers.py +++ b/lms/djangoapps/learner_home/test_serializers.py @@ -7,6 +7,7 @@ from unittest import mock from uuid import uuid4 from django.conf import settings +from django.urls import reverse from django.test import TestCase import ddt from opaque_keys.edx.keys import CourseKey @@ -19,9 +20,7 @@ from common.djangoapps.student.tests.factories import ( CourseEnrollmentFactory, UserFactory, ) -from openedx.core.djangoapps.catalog.tests.factories import ( - CourseRunFactory as CatalogCourseRunFactory, -) +from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory as CatalogCourseRunFactory, ProgramFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import ( CourseOverviewFactory, ) @@ -40,9 +39,11 @@ from lms.djangoapps.learner_home.serializers import ( PlatformSettingsSerializer, ProgramsSerializer, LearnerDashboardSerializer, + RelatedProgramSerializer, SuggestedCourseSerializer, UnfulfilledEntitlementSerializer, ) + from lms.djangoapps.learner_home.test_utils import ( datetime_to_django_format, random_bool, @@ -678,46 +679,50 @@ class TestProgramsSerializer(TestCase): @classmethod def generate_test_related_program(cls): """Generate a program with random test data""" - return { - "bannerUrl": random_url(), - "estimatedNumberOfWeeks": randint(0, 45), - "logoUrl": random_url(), - "numberOfCourses": randint(0, 100), - "programType": f"{uuid4()}", - "programUrl": random_url(), - "provider": f"{uuid4()} Inc.", - "title": f"{uuid4()}", - } + return ProgramFactory() @classmethod def generate_test_programs_info(cls): """Util to generate test programs info""" return { "relatedPrograms": [ - cls.generate_test_related_program() for _ in range(randint(0, 3)) + cls.generate_test_related_program() for _ in range(3) ], } - def test_happy_path(self): + def test_related_program_serializer(self): + """Test the RelatedProgramSerializer""" + # Given a program + input_data = self.generate_test_related_program() + # When I serialize it + output_data = RelatedProgramSerializer(input_data).data + # Then the output should map with the input + self.assertEqual(output_data, { + 'bannerUrl': input_data['banner_image']['small']['url'], + 'logoUrl': input_data['authoring_organizations'][0]['logo_image_url'], + 'numberOfCourses': len(input_data['courses']), + 'programType': input_data['type'], + 'programUrl': settings.LMS_ROOT_URL + reverse( + 'program_details_view', kwargs={'program_uuid': input_data['uuid']} + ), + 'provider': input_data['authoring_organizations'][0]['name'], + 'title': input_data['title'], + }) + + def test_programs_serializer(self): + """Test the ProgramsSerializer""" + # Given a program with random test data input_data = self.generate_test_programs_info() + + # When I serialize the program output_data = ProgramsSerializer(input_data).data - related_programs = output_data.pop("relatedPrograms") - - for i, related_program in enumerate(related_programs): - input_program = input_data["relatedPrograms"][i] - assert related_program == { - "bannerUrl": input_program["bannerUrl"], - "estimatedNumberOfWeeks": input_program["estimatedNumberOfWeeks"], - "logoUrl": input_program["logoUrl"], - "numberOfCourses": input_program["numberOfCourses"], - "programType": input_program["programType"], - "programUrl": input_program["programUrl"], - "provider": input_program["provider"], - "title": input_program["title"], - } - - self.assertDictEqual(output_data, {}) + # Test the output + assert output_data['relatedPrograms'] + assert len(output_data['relatedPrograms']) == len(input_data['relatedPrograms']) + self.assertEqual(output_data, { + "relatedPrograms": RelatedProgramSerializer(input_data["relatedPrograms"], many=True).data + }) def test_empty_sessions(self): input_data = {"relatedPrograms": []} @@ -746,6 +751,7 @@ class TestLearnerEnrollmentsSerializer(LearnerDashboardBaseTest): }, "fulfilled_entitlements": {}, "unfulfilled_entitlement_pseudo_sessions": {}, + "programs": {}, } output_data = LearnerEnrollmentSerializer( @@ -972,6 +978,7 @@ class TestLearnerDashboardSerializer(LearnerDashboardBaseTest): enrollments=None, enrollments_with_entitlements=None, unfulfilled_entitlements=None, + has_programs=False ): """ Given enrollments and entitlements, generate a matching serializer context @@ -1021,6 +1028,10 @@ class TestLearnerDashboardSerializer(LearnerDashboardBaseTest): course_overview = CourseOverviewFactory.create(id=course_key) pseudo_session_course_overviews[course_key] = course_overview + programs = { + str(enrollment.course.id): ProgramFactory.create_batch(3) + for enrollment in enrollments + } if has_programs else {} input_context = { "resume_course_urls": resume_course_urls, @@ -1030,6 +1041,7 @@ class TestLearnerDashboardSerializer(LearnerDashboardBaseTest): "unfulfilled_entitlement_pseudo_sessions": unfulfilled_entitlement_pseudo_sessions, "course_entitlement_available_sessions": course_entitlement_available_sessions, "pseudo_session_course_overviews": pseudo_session_course_overviews, + "programs": programs, } return input_context @@ -1090,6 +1102,7 @@ class TestLearnerDashboardSerializer(LearnerDashboardBaseTest): enrollments=enrollments, enrollments_with_entitlements=[enrollments[1]], unfulfilled_entitlements=unfulfilled_entitlements, + has_programs=True, ) input_data = { @@ -1116,6 +1129,10 @@ class TestLearnerDashboardSerializer(LearnerDashboardBaseTest): assert unfulfilled_entitlement assert fulfilled_entitlement.keys() == unfulfilled_entitlement.keys() + # test programs + assert courses[0]['programs'] + assert len(courses[0]['programs']['relatedPrograms']) == 3 + @mock.patch( "lms.djangoapps.learner_home.serializers.SuggestedCourseSerializer.to_representation" ) diff --git a/lms/djangoapps/learner_home/test_views.py b/lms/djangoapps/learner_home/test_views.py index 424d2c7a19..87e1730a8e 100644 --- a/lms/djangoapps/learner_home/test_views.py +++ b/lms/djangoapps/learner_home/test_views.py @@ -23,6 +23,7 @@ from lms.djangoapps.bulk_email.models import Optout from lms.djangoapps.learner_home.test_utils import create_test_enrollment from lms.djangoapps.learner_home.views import ( get_course_overviews_for_pseudo_sessions, + get_course_programs, get_email_settings_info, get_enrollments, get_platform_settings, @@ -30,17 +31,17 @@ from lms.djangoapps.learner_home.views import ( get_entitlements, ) from lms.djangoapps.learner_home.test_serializers import random_url -from openedx.core.djangoapps.catalog.tests.factories import ( - CourseRunFactory as CatalogCourseRunFactory, -) +from openedx.core.djangoapps.catalog.tests.factories import CourseRunFactory as CatalogCourseRunFactory, ProgramFactory from openedx.core.djangoapps.content.course_overviews.tests.factories import ( CourseOverviewFactory, ) +from openedx.core.djangoapps.site_configuration.tests.factories import SiteFactory from xmodule.modulestore.tests.django_utils import ( TEST_DATA_SPLIT_MODULESTORE, SharedModuleStoreTestCase, ) from xmodule.modulestore.tests.factories import CourseFactory +from openedx.core.djangoapps.catalog.tests.factories import CourseFactory as CatalogCourseFactory ENTERPRISE_ENABLED = "ENABLE_ENTERPRISE_INTEGRATION" @@ -363,6 +364,7 @@ class TestDashboardView(SharedModuleStoreTestCase, APITestCase): cls.username = "alan" cls.password = "enigma" cls.user = UserFactory(username=cls.username, password=cls.password) + cls.site = SiteFactory() def log_in(self): """Log in as a test user""" @@ -372,6 +374,33 @@ class TestDashboardView(SharedModuleStoreTestCase, APITestCase): super().setUp() self.log_in() + def _create_course_programs(self, course_uuid=None): + """ + Create a program with entitlements + """ + course_uuid = course_uuid or str(uuid4()) + + program = ProgramFactory(courses=[CatalogCourseFactory(uuid=str(course_uuid))]) + + enrollment = CourseEnrollmentFactory( + user=self.user, + mode=CourseMode.VERIFIED, + is_active=False + ) + + entitlement = CourseEntitlementFactory.create( + user=self.user, + course_uuid=course_uuid, + mode=CourseMode.VERIFIED, + enrollment_course_run=enrollment + ) + + return ( + program, + enrollment, + entitlement + ) + @patch.dict(settings.FEATURES, ENTERPRISE_ENABLED=False) def test_response_structure(self): """Basic test for correct response structure""" @@ -467,3 +496,51 @@ class TestDashboardView(SharedModuleStoreTestCase, APITestCase): "certPreviewUrl": mock_cert_info["cert_web_view_url"], }, ) + + @patch.dict(settings.FEATURES, ENTERPRISE_ENABLED=False) + @patch("openedx.core.djangoapps.programs.utils.get_programs") + def test_get_for_one_of_course_programs(self, mock_get_programs): + """Test that course programs get loaded correctly""" + + # Given I am logged in + self.log_in() + + course_uuid = str(uuid4()) + program, enrollment, _ = self._create_course_programs(course_uuid=course_uuid) + + data = [ + program, + ProgramFactory(), + ] + mock_get_programs.return_value = data + + programs = get_course_programs(self.user, [enrollment], self.site) + + assert len(programs) == 1 + assert programs[course_uuid][0] == program + assert len(data) > len(programs) + + @patch.dict(settings.FEATURES, ENTERPRISE_ENABLED=False) + @patch("openedx.core.djangoapps.programs.utils.get_programs") + def test_get_multiple_course_programs(self, mock_get_programs): + """Test that course programs get loaded correctly""" + + # Given I am logged in + self.log_in() + + course_uuid = str(uuid4()) + course_uuid2 = str(uuid4()) + program, enrollment, _ = self._create_course_programs(course_uuid=course_uuid) + program2, enrollment2, _ = self._create_course_programs(course_uuid=course_uuid2) + + data = [ + program, + program2, + ] + mock_get_programs.return_value = data + + programs = get_course_programs(self.user, [enrollment, enrollment2], self.site) + + assert len(data) == len(programs) + assert programs[course_uuid][0] == program + assert programs[course_uuid2][0] == program2 diff --git a/lms/djangoapps/learner_home/views.py b/lms/djangoapps/learner_home/views.py index 01cf948b5a..e7d9313e80 100644 --- a/lms/djangoapps/learner_home/views.py +++ b/lms/djangoapps/learner_home/views.py @@ -29,9 +29,8 @@ from lms.djangoapps.courseware.access_utils import ( from lms.djangoapps.learner_home.serializers import LearnerDashboardSerializer from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers -from openedx.features.enterprise_support.api import ( - enterprise_customer_from_session_or_learner_data, -) +from openedx.core.djangoapps.programs.utils import ProgramProgressMeter +from openedx.features.enterprise_support.api import enterprise_customer_from_session_or_learner_data def get_platform_settings(): @@ -237,12 +236,27 @@ def check_course_access(user, course_enrollments): return course_access_dict +def get_course_programs(user, course_enrollments, site): + """ + Get programs related to the courses the user is enrolled in. + + Returns: { + : { + "programs": [list of programs] + } + } + """ + meter = ProgramProgressMeter(site, user, enrollments=course_enrollments) + return meter.invert_programs() + + class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument """List of courses a user is enrolled in or entitled to""" def get(self, request, *args, **kwargs): # pylint: disable=unused-argument # Get user, determine if user needs to confirm email account user = request.user + site = request.site email_confirmation = get_user_account_confirmation_info(user) # Gather info for enterprise dashboard @@ -278,7 +292,8 @@ class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument # Determine view access for course, (for showing courseware link) involves: course_access_checks = check_course_access(user, course_enrollments) - # TODO - Get related programs + # Get programs related to the courses the user is enrolled in + programs = get_course_programs(user, course_enrollments, site) # e-commerce info ecommerce_payment_page = get_ecommerce_payment_page(user) @@ -307,6 +322,7 @@ class InitializeView(RetrieveAPIView): # pylint: disable=unused-argument "course_entitlement_available_sessions": course_entitlement_available_sessions, "unfulfilled_entitlement_pseudo_sessions": unfulfilled_entitlement_pseudo_sessions, "pseudo_session_course_overviews": pseudo_session_course_overviews, + "programs": programs, } response_data = LearnerDashboardSerializer(