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:
committed by
leangseu-edx
parent
b429e55cac
commit
9530771cad
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user