feat: implement related programs for learner home

chore: simplify lms/djangoapps/learner_home/serializers.py

Co-authored-by: Nathan Sprenkle <nsprenkle@users.noreply.github.com>

chore: safety for authoring_organizations
This commit is contained in:
Leangseu Kim
2022-09-08 20:48:04 -04:00
committed by leangseu-edx
parent b429e55cac
commit 9530771cad
4 changed files with 191 additions and 46 deletions

View File

@@ -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):
"""

View File

@@ -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"
)

View File

@@ -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

View File

@@ -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: {
<course_id>: {
"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(