From 853cd9a0a76e5865e044301a51f7d483c1373f8f Mon Sep 17 00:00:00 2001 From: McKenzie Welter Date: Fri, 8 Dec 2017 12:45:45 -0500 Subject: [PATCH] show programs in which user holds a course entitlement on programs listing page --- common/djangoapps/entitlements/models.py | 4 + .../entitlements/tests/factories.py | 1 + .../djangoapps/programs/tests/test_utils.py | 93 ++++++++++++++++++- openedx/core/djangoapps/programs/utils.py | 21 ++++- 4 files changed, 111 insertions(+), 8 deletions(-) diff --git a/common/djangoapps/entitlements/models.py b/common/djangoapps/entitlements/models.py index 404d3a550b..daa34d48b2 100644 --- a/common/djangoapps/entitlements/models.py +++ b/common/djangoapps/entitlements/models.py @@ -219,3 +219,7 @@ class CourseEntitlement(TimeStampedModel): Fulfills an entitlement by specifying a session. """ cls.objects.filter(id=entitlement.id).update(enrollment_course_run=enrollment) + + @classmethod + def unexpired_entitlements_for_user(cls, user): + return cls.objects.filter(user=user, expired_at=None).select_related('user') diff --git a/common/djangoapps/entitlements/tests/factories.py b/common/djangoapps/entitlements/tests/factories.py index 949078b258..650ac84ef5 100644 --- a/common/djangoapps/entitlements/tests/factories.py +++ b/common/djangoapps/entitlements/tests/factories.py @@ -27,6 +27,7 @@ class CourseEntitlementFactory(factory.django.DjangoModelFactory): uuid = factory.LazyFunction(uuid4) course_uuid = factory.LazyFunction(uuid4) + expired_at = None mode = FuzzyChoice([CourseMode.VERIFIED, CourseMode.PROFESSIONAL]) user = factory.SubFactory(UserFactory) order_number = FuzzyText(prefix='TEXTX', chars=string.digits) diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index bd581cf74c..41de7a3c6c 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -67,6 +67,11 @@ class TestProgramProgressMeter(TestCase): for course_run_id in course_run_ids: CourseEnrollmentFactory(user=self.user, course_id=course_run_id, mode=CourseMode.VERIFIED) + def _create_entitlements(self, *course_uuids): + """ Variadic helper used to create course entitlements. """ + for course_uuid in course_uuids: + CourseEntitlementFactory(user=self.user, course_uuid=course_uuid) + def _assert_progress(self, meter, *progresses): """Variadic helper used to verify progress calculations.""" self.assertEqual(meter.progress(), list(progresses)) @@ -93,8 +98,8 @@ class TestProgramProgressMeter(TestCase): return result - def test_no_enrollments(self, mock_get_programs): - """Verify behavior when programs exist, but no relevant enrollments do.""" + def test_no_enrollments_or_entitlements(self, mock_get_programs): + """Verify behavior when programs exist, but no relevant enrollments or entitlements do.""" data = [ProgramFactory()] mock_get_programs.return_value = data @@ -104,7 +109,7 @@ class TestProgramProgressMeter(TestCase): self._assert_progress(meter) self.assertEqual(meter.completed_programs, []) - def test_no_programs(self, mock_get_programs): + def test_enrollments_but_no_programs(self, mock_get_programs): """Verify behavior when enrollments exist, but no matching programs do.""" mock_get_programs.return_value = [] @@ -116,7 +121,16 @@ class TestProgramProgressMeter(TestCase): self._assert_progress(meter) self.assertEqual(meter.completed_programs, []) - def test_single_program_engagement(self, mock_get_programs): + def test_entitlements_but_no_programs(self, mock_get_programs): + """ Verify engaged_programs is empty when entitlements exist, but no matching programs do. """ + mock_get_programs.return_value = [] + + self._create_entitlements(uuid.uuid4()) + meter = ProgramProgressMeter(self.site, self.user) + + self.assertEqual(meter.engaged_programs, []) + + def test_single_program_enrollment(self, mock_get_programs): """ Verify that correct program is returned when the user is enrolled in a course run appearing in one program. @@ -146,6 +160,25 @@ class TestProgramProgressMeter(TestCase): ) self.assertEqual(meter.completed_programs, []) + def test_single_program_entitlement(self, mock_get_programs): + """ + Verify that the correct program is returned when the user holds an entitlement + to a course appearing in one program. + """ + course_uuid = uuid.uuid4() + data = [ + ProgramFactory(courses=[CourseFactory(uuid=str(course_uuid))]), + ProgramFactory(), + ] + mock_get_programs.return_value = data + + self._create_entitlements(course_uuid) + meter = ProgramProgressMeter(self.site, self.user) + + self._attach_detail_url(data) + program = data[0] + self.assertEqual(meter.engaged_programs, [program]) + def test_course_progress(self, mock_get_programs): """ Verify that the progress meter can represent progress in terms of @@ -259,7 +292,7 @@ class TestProgramProgressMeter(TestCase): self.assertEqual(meter.progress(count_only=True), expected) - def test_mutiple_program_engagement(self, mock_get_programs): + def test_mutiple_program_enrollment(self, mock_get_programs): """ Verify that correct programs are returned in the correct order when the user is enrolled in course runs appearing in programs. @@ -303,6 +336,28 @@ class TestProgramProgressMeter(TestCase): ) self.assertEqual(meter.completed_programs, []) + def test_multiple_program_entitlement(self, mock_get_programs): + """ + Verify that the correct programs are returned in the correct order + when the user holds entitlements to courses appearing in those programs. + """ + newer_course_uuid, older_course_uuid = (uuid.uuid4() for __ in range(2)) + data = [ + ProgramFactory(courses=[CourseFactory(uuid=str(older_course_uuid)), ]), + ProgramFactory(courses=[CourseFactory(uuid=str(newer_course_uuid)), ]), + ProgramFactory(), + ] + mock_get_programs.return_value = data + + # The creation time of the entitlements matters to the test. We want + # the newer_course_uuid to represent the newest entitlement. + self._create_entitlements(older_course_uuid, newer_course_uuid) + meter = ProgramProgressMeter(self.site, self.user) + + self._attach_detail_url(data) + programs = data[:2] + self.assertEqual(meter.engaged_programs, programs) + def test_shared_enrollment_engagement(self, mock_get_programs): """ Verify that correct programs are returned when the user is enrolled in a @@ -354,6 +409,34 @@ class TestProgramProgressMeter(TestCase): ) self.assertEqual(meter.completed_programs, []) + def test_shared_entitlement_engagement(self, mock_get_programs): + """ + Verify that correct programs are returned when the user holds an entitlement + to a single course appearing in multiple programs. + """ + shared_course_uuid, solo_course_uuid = (uuid.uuid4() for __ in range(2)) + + batch = [ + ProgramFactory(courses=[CourseFactory(uuid=str(shared_course_uuid)), ]) + for __ in range(2) + ] + + joint_programs = sorted(batch, key=lambda program: program['title']) + data = joint_programs + [ + ProgramFactory(courses=[CourseFactory(uuid=str(solo_course_uuid)), ]), + ProgramFactory(), + ] + + mock_get_programs.return_value = data + + # Entitlement for the shared course created last (most recently). + self._create_entitlements(shared_course_uuid, solo_course_uuid) + meter = ProgramProgressMeter(self.site, self.user) + + self._attach_detail_url(data) + programs = data[:3] + self.assertEqual(meter.engaged_programs, programs) + @mock.patch(UTILS_MODULE + '.ProgramProgressMeter.completed_course_runs', new_callable=mock.PropertyMock) def test_simulate_progress(self, mock_completed_course_runs, mock_get_programs): """Simulate the entirety of a user's progress through a program.""" diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index 9c428a2ab2..6a1c66c42f 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -19,6 +19,7 @@ from pytz import utc from requests.exceptions import ConnectionError, Timeout from course_modes.models import CourseMode +from entitlements.models import CourseEntitlement from lms.djangoapps.certificates import api as certificate_api from lms.djangoapps.commerce.utils import EcommerceService from lms.djangoapps.courseware.access import has_access @@ -90,6 +91,9 @@ class ProgramProgressMeter(object): # We can't use dict.keys() for this because the course run ids need to be ordered self.course_run_ids.append(enrollment_id) + self.entitlements = list(CourseEntitlement.unexpired_entitlements_for_user(self.user)) + self.course_uuids = [str(entitlement.course_uuid) for entitlement in self.entitlements] + self.course_grade_factory = CourseGradeFactory() if uuid: @@ -100,9 +104,9 @@ class ProgramProgressMeter(object): def invert_programs(self): """Intersect programs and enrollments. - Builds a dictionary of program dict lists keyed by course run ID. The - resulting dictionary is suitable in applications where programs must be - filtered by the course runs they contain (e.g., the student dashboard). + Builds a dictionary of program dict lists keyed by course run ID and by course UUID. + The resulting dictionary is suitable in applications where programs must be + filtered by the course runs or courses they contain (e.g., the student dashboard). Returns: defaultdict, programs keyed by course run ID @@ -111,6 +115,12 @@ class ProgramProgressMeter(object): for program in self.programs: for course in program['courses']: + course_uuid = course['uuid'] + if course_uuid in self.course_uuids: + program_list = inverted_programs[course_uuid] + if program not in program_list: + program_list.append(program) + continue for course_run in course['course_runs']: course_run_id = course_run['key'] if course_run_id in self.course_run_ids: @@ -145,6 +155,11 @@ class ProgramProgressMeter(object): if program not in programs: programs.append(program) + for course_uuid in self.course_uuids: + for program in inverted_programs[course_uuid]: + if program not in programs: + programs.append(program) + return programs def _is_course_in_progress(self, now, course):