From a8150a51d2173502825618acf4f597b9790359b5 Mon Sep 17 00:00:00 2001 From: Renzo Lucioni Date: Fri, 27 May 2016 16:50:47 -0400 Subject: [PATCH] Supplement program data with course and enrollment data The supplemented data is passed through to the program details page. Part of ECOM-4415. --- common/djangoapps/terrain/stubs/programs.py | 16 +++- common/test/acceptance/fixtures/programs.py | 38 +++------ common/test/acceptance/pages/lms/programs.py | 3 +- .../acceptance/tests/lms/test_programs.py | 61 ++++++++++---- .../tests/studio/test_studio_home.py | 27 ++++-- .../learner_dashboard/tests/test_programs.py | 18 ++-- lms/djangoapps/learner_dashboard/views.py | 11 +-- .../djangoapps/programs/tests/test_utils.py | 83 +++++++++++++++++++ openedx/core/djangoapps/programs/utils.py | 43 ++++++++++ 9 files changed, 234 insertions(+), 66 deletions(-) diff --git a/common/djangoapps/terrain/stubs/programs.py b/common/djangoapps/terrain/stubs/programs.py index dd92ca048c..5631b8de03 100644 --- a/common/djangoapps/terrain/stubs/programs.py +++ b/common/djangoapps/terrain/stubs/programs.py @@ -1,9 +1,9 @@ """ Stub implementation of programs service for acceptance tests """ - import re import urlparse + from .http import StubHttpRequestHandler, StubHttpService @@ -11,10 +11,13 @@ class StubProgramsServiceHandler(StubHttpRequestHandler): # pylint: disable=mis def do_GET(self): # pylint: disable=invalid-name, missing-docstring pattern_handlers = { - "/api/v1/programs/$": self.get_programs_list, + r'/api/v1/programs/$': self.get_programs_list, + r'/api/v1/programs/(\d+)/$': self.get_program_details, } + if self.match_pattern(pattern_handlers): return + self.send_response(404, content="404 Not Found") def match_pattern(self, pattern_handlers): @@ -25,7 +28,7 @@ class StubProgramsServiceHandler(StubHttpRequestHandler): # pylint: disable=mis for pattern in pattern_handlers: match = re.match(pattern, path) if match: - pattern_handlers[pattern](**match.groupdict()) + pattern_handlers[pattern](*match.groups()) return True return None @@ -36,6 +39,13 @@ class StubProgramsServiceHandler(StubHttpRequestHandler): # pylint: disable=mis programs = self.server.config.get('programs', []) self.send_json_response(programs) + def get_program_details(self, program_id): + """ + Stubs a program details endpoint. + """ + program = self.server.config.get('programs.{}'.format(program_id), []) + self.send_json_response(program) + class StubProgramsService(StubHttpService): # pylint: disable=missing-docstring HANDLER_CLASS = StubProgramsServiceHandler diff --git a/common/test/acceptance/fixtures/programs.py b/common/test/acceptance/fixtures/programs.py index 242b0a7714..59a2409b6c 100644 --- a/common/test/acceptance/fixtures/programs.py +++ b/common/test/acceptance/fixtures/programs.py @@ -1,49 +1,31 @@ """ Tools to create programs-related data for use in bok choy tests. """ -from collections import namedtuple import json import requests from . import PROGRAMS_STUB_URL from .config import ConfigModelFixture -from openedx.core.djangoapps.programs.tests import factories - - -FakeProgram = namedtuple('FakeProgram', ['name', 'status', 'org_key', 'course_id']) class ProgramsFixture(object): """ Interface to set up mock responses from the Programs stub server. """ - - def install_programs(self, fake_programs): - """ - Sets the response data for the programs list endpoint. - - At present, `fake_programs` must be a iterable of FakeProgram named tuples. - """ - programs = [] - for program in fake_programs: - run_mode = factories.RunMode(course_key=program.course_id) - course_code = factories.CourseCode(run_modes=[run_mode]) - org = factories.Organization(key=program.org_key) - - program = factories.Program( - name=program.name, - status=program.status, - organizations=[org], - course_codes=[course_code] - ) - programs.append(program) - - api_result = {'results': programs} + def install_programs(self, programs, is_list=True): + """Sets the response data for Programs API endpoints.""" + if is_list: + key = 'programs' + api_result = {'results': programs} + else: + program = programs[0] + key = 'programs.{}'.format(program['id']) + api_result = program requests.put( '{}/set_config'.format(PROGRAMS_STUB_URL), - data={'programs': json.dumps(api_result)}, + data={key: json.dumps(api_result)}, ) diff --git a/common/test/acceptance/pages/lms/programs.py b/common/test/acceptance/pages/lms/programs.py index 4b6b72528d..b20c670dd8 100644 --- a/common/test/acceptance/pages/lms/programs.py +++ b/common/test/acceptance/pages/lms/programs.py @@ -24,7 +24,8 @@ class ProgramListingPage(PageObject): class ProgramDetailsPage(PageObject): """Program details page.""" - url = BASE_URL + '/dashboard/programs/123/program-name/' + program_id = 123 + url = BASE_URL + '/dashboard/programs/{}/program-name/'.format(program_id) def is_browser_on_page(self): return self.q(css='.js-program-details-wrapper').present diff --git a/common/test/acceptance/tests/lms/test_programs.py b/common/test/acceptance/tests/lms/test_programs.py index 6a12e60054..4695b583fc 100644 --- a/common/test/acceptance/tests/lms/test_programs.py +++ b/common/test/acceptance/tests/lms/test_programs.py @@ -1,11 +1,12 @@ """Acceptance tests for LMS-hosted Programs pages""" from nose.plugins.attrib import attr -from ...fixtures.programs import FakeProgram, ProgramsFixture, ProgramsConfigMixin +from ...fixtures.programs import ProgramsFixture, ProgramsConfigMixin from ...fixtures.course import CourseFixture from ..helpers import UniqueCourseTest from ...pages.lms.auto_auth import AutoAuthPage from ...pages.lms.programs import ProgramListingPage, ProgramDetailsPage +from openedx.core.djangoapps.programs.tests import factories class ProgramPageBase(ProgramsConfigMixin, UniqueCourseTest): @@ -15,16 +16,33 @@ class ProgramPageBase(ProgramsConfigMixin, UniqueCourseTest): self.set_programs_api_configuration(is_enabled=True) - def stub_api(self, course_id=None): - """Stub out the programs API with fake data.""" - name = 'Fake Program' - status = 'active' - org_key = self.course_info['org'] + def create_program(self, program_id=None, course_id=None): + """DRY helper for creating test program data.""" course_id = course_id if course_id else self.course_id - ProgramsFixture().install_programs([ - FakeProgram(name=name, status=status, org_key=org_key, course_id=course_id), - ]) + run_mode = factories.RunMode(course_key=course_id) + course_code = factories.CourseCode(run_modes=[run_mode]) + org = factories.Organization(key=self.course_info['org']) + + if program_id: + program = factories.Program( + id=program_id, + status='active', + organizations=[org], + course_codes=[course_code] + ) + else: + program = factories.Program( + status='active', + organizations=[org], + course_codes=[course_code] + ) + + return program + + def stub_api(self, programs, is_list=True): + """Stub out the programs API with fake data.""" + ProgramsFixture().install_programs(programs, is_list=is_list) def auth(self, enroll=True): """Authenticate, enrolling the user in the configured course if requested.""" @@ -43,8 +61,10 @@ class ProgramListingPageTest(ProgramPageBase): def test_no_enrollments(self): """Verify that no cards appear when the user has no enrollments.""" - self.stub_api() + program = self.create_program() + self.stub_api([program]) self.auth(enroll=False) + self.listing_page.visit() self.assertTrue(self.listing_page.is_sidebar_present) @@ -59,8 +79,11 @@ class ProgramListingPageTest(ProgramPageBase): self.course_info['run'], 'other_run' ) - self.stub_api(course_id=course_id) + + program = self.create_program(course_id=course_id) + self.stub_api([program]) self.auth() + self.listing_page.visit() self.assertTrue(self.listing_page.is_sidebar_present) @@ -71,8 +94,10 @@ class ProgramListingPageTest(ProgramPageBase): Verify that cards appear when the user has enrollments which are included in at least one active program. """ - self.stub_api() + program = self.create_program() + self.stub_api([program]) self.auth() + self.listing_page.visit() self.assertTrue(self.listing_page.is_sidebar_present) @@ -87,9 +112,11 @@ class ProgramListingPageA11yTest(ProgramPageBase): self.listing_page = ProgramListingPage(self.browser) + program = self.create_program() + self.stub_api([program]) + def test_empty_a11y(self): """Test a11y of the page's empty state.""" - self.stub_api() self.auth(enroll=False) self.listing_page.visit() @@ -100,7 +127,6 @@ class ProgramListingPageA11yTest(ProgramPageBase): def test_cards_a11y(self): """Test a11y when program cards are present.""" - self.stub_api() self.auth() self.listing_page.visit() @@ -118,9 +144,12 @@ class ProgramDetailsPageA11yTest(ProgramPageBase): self.details_page = ProgramDetailsPage(self.browser) + program = self.create_program(program_id=self.details_page.program_id) + self.stub_api([program], is_list=False) + def test_a11y(self): - """Test a11y of the page's state.""" - self.auth(enroll=False) + """Test the page's a11y compliance.""" + self.auth() self.details_page.visit() self.details_page.a11y_audit.check_for_accessibility_errors() diff --git a/common/test/acceptance/tests/studio/test_studio_home.py b/common/test/acceptance/tests/studio/test_studio_home.py index ddd58d4aed..9ad7b45f3f 100644 --- a/common/test/acceptance/tests/studio/test_studio_home.py +++ b/common/test/acceptance/tests/studio/test_studio_home.py @@ -8,7 +8,7 @@ from uuid import uuid4 from ...fixtures import PROGRAMS_STUB_URL from ...fixtures.config import ConfigModelFixture -from ...fixtures.programs import FakeProgram, ProgramsFixture, ProgramsConfigMixin +from ...fixtures.programs import ProgramsFixture, ProgramsConfigMixin from ...pages.studio.auto_auth import AutoAuthPage from ...pages.studio.library import LibraryEditPage from ...pages.studio.index import DashboardPage, DashboardPageWithPrograms @@ -17,6 +17,7 @@ from ..helpers import ( select_option_by_text, get_selected_option_text ) +from openedx.core.djangoapps.programs.tests import factories class CreateLibraryTest(WebAppTest): @@ -111,11 +112,24 @@ class DashboardProgramsTabTest(ProgramsConfigMixin, WebAppTest): via config, and the results of the program list should display when the list is nonempty. """ - test_program_values = [ - FakeProgram(name='first program', status='unpublished', org_key='org1', course_id='foo/bar/baz'), - FakeProgram(name='second program', status='unpublished', org_key='org2', course_id='qux/quux/corge'), + test_program_values = [('first program', 'org1'), ('second program', 'org2')] + + programs = [ + factories.Program( + name=name, + organizations=[ + factories.Organization(key=org), + ], + course_codes=[ + factories.CourseCode(run_modes=[ + factories.RunMode(), + ]), + ] + ) + for name, org in test_program_values ] - ProgramsFixture().install_programs(test_program_values) + + ProgramsFixture().install_programs(programs) self.set_programs_api_configuration(True) @@ -126,8 +140,7 @@ class DashboardProgramsTabTest(ProgramsConfigMixin, WebAppTest): self.assertFalse(self.dashboard_page.is_empty_list_create_button_present()) results = self.dashboard_page.get_program_list() - expected = [(p.name, p.org_key) for p in test_program_values] - self.assertEqual(results, expected) + self.assertEqual(results, test_program_values) def test_tab_requires_staff(self): """ diff --git a/lms/djangoapps/learner_dashboard/tests/test_programs.py b/lms/djangoapps/learner_dashboard/tests/test_programs.py index bfbf6737e9..161874ba4b 100644 --- a/lms/djangoapps/learner_dashboard/tests/test_programs.py +++ b/lms/djangoapps/learner_dashboard/tests/test_programs.py @@ -24,7 +24,7 @@ from openedx.core.djangoapps.programs.tests.mixins import ( ProgramsDataMixin) from student.models import CourseEnrollment from student.tests.factories import UserFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, SharedModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -231,13 +231,18 @@ class TestProgramListing( @httpretty.activate @override_settings(MKTG_URLS={'ROOT': 'http://edx.org'}) @unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') -class TestProgramDetails(ProgramsApiConfigMixin, TestCase): +class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase): """ Unit tests for the program details page """ program_id = 123 password = 'test' + @classmethod + def setUpClass(cls): + super(TestProgramDetails, cls).setUpClass() + cls.course = CourseFactory() + def setUp(self): super(TestProgramDetails, self).setUp() @@ -248,11 +253,12 @@ class TestProgramDetails(ProgramsApiConfigMixin, TestCase): ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL) + self.organization = factories.Organization() + self.run_mode = factories.RunMode(course_key=unicode(self.course.id)) # pylint: disable=no-member + self.course_code = factories.CourseCode(run_modes=[self.run_mode]) self.data = factories.Program( - organizations=[factories.Organization()], - course_codes=[ - factories.CourseCode(run_modes=[factories.RunMode()]), - ] + organizations=[self.organization], + course_codes=[self.course_code] ) def _mock_programs_api(self): diff --git a/lms/djangoapps/learner_dashboard/views.py b/lms/djangoapps/learner_dashboard/views.py index 0a9a6482fe..017ca8955d 100644 --- a/lms/djangoapps/learner_dashboard/views.py +++ b/lms/djangoapps/learner_dashboard/views.py @@ -3,13 +3,13 @@ from urlparse import urljoin from django.conf import settings from django.contrib.auth.decorators import login_required -from django.views.decorators.http import require_GET from django.http import Http404 +from django.views.decorators.http import require_GET from edxmako.shortcuts import render_to_response from openedx.core.djangoapps.credentials.utils import get_programs_credentials from openedx.core.djangoapps.programs.models import ProgramsApiConfig -from openedx.core.djangoapps.programs.utils import ProgramProgressMeter, get_programs, get_display_category +from openedx.core.djangoapps.programs import utils from student.views import get_course_enrollments @@ -22,13 +22,13 @@ def view_programs(request): raise Http404 enrollments = list(get_course_enrollments(request.user, None, [])) - meter = ProgramProgressMeter(request.user, enrollments) + meter = utils.ProgramProgressMeter(request.user, enrollments) programs = meter.engaged_programs # TODO: Pull 'xseries' string from configuration model. marketing_root = urljoin(settings.MKTG_URLS.get('ROOT'), 'xseries').strip('/') for program in programs: - program['display_category'] = get_display_category(program) + program['display_category'] = utils.get_display_category(program) program['marketing_url'] = '{root}/{slug}'.format( root=marketing_root, slug=program['marketing_slug'] @@ -56,7 +56,8 @@ def program_details(request, program_id): if not show_program_details: raise Http404 - program_data = get_programs(request.user, program_id=program_id) + program_data = utils.get_programs(request.user, program_id=program_id) + program_data = utils.supplement_program_data(program_data, request.user) context = { 'program_data': program_data, diff --git a/openedx/core/djangoapps/programs/tests/test_utils.py b/openedx/core/djangoapps/programs/tests/test_utils.py index 6edd3769a5..acaa5d5115 100644 --- a/openedx/core/djangoapps/programs/tests/test_utils.py +++ b/openedx/core/djangoapps/programs/tests/test_utils.py @@ -1,10 +1,15 @@ """Tests covering Programs utilities.""" +import copy +import datetime import json from unittest import skipUnless +import ddt from django.conf import settings from django.core.cache import cache +from django.core.urlresolvers import reverse from django.test import TestCase +from django.utils import timezone import httpretty import mock from nose.plugins.attrib import attr @@ -12,6 +17,7 @@ from edx_oauth2_provider.tests.factories import ClientFactory from provider.constants import CONFIDENTIAL from lms.djangoapps.certificates.api import MODES +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.credentials.tests import factories as credentials_factories from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin from openedx.core.djangoapps.programs import utils @@ -20,6 +26,8 @@ from openedx.core.djangoapps.programs.tests import factories from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from student.tests.factories import UserFactory, CourseEnrollmentFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory UTILS_MODULE = 'openedx.core.djangoapps.programs.utils' @@ -597,3 +605,78 @@ class TestProgramProgressMeter(ProgramsApiConfigMixin, TestCase): meter, factories.Progress(id=program['id'], completed=self._extract_names(program, 0)) ) + + +@ddt.ddt +@skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') +class TestSupplementProgramData(ProgramsApiConfigMixin, ModuleStoreTestCase): + """Tests of the utility function used to supplement program data.""" + password = 'test' + human_friendly_format = '%x' + maxDiff = None + + def setUp(self): + super(TestSupplementProgramData, self).setUp() + + self.user = UserFactory() + self.client.login(username=self.user.username, password=self.password) + + ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL) + + self.course = CourseFactory() + self.course.start = timezone.now() - datetime.timedelta(days=1) + self.course.end = timezone.now() + datetime.timedelta(days=1) + self.course = self.update_course(self.course, self.user.id) # pylint: disable=no-member + + self.organization = factories.Organization() + self.run_mode = factories.RunMode(course_key=unicode(self.course.id)) # pylint: disable=no-member + self.course_code = factories.CourseCode(run_modes=[self.run_mode]) + self.program = factories.Program( + organizations=[self.organization], + course_codes=[self.course_code] + ) + + def _assert_supplemented(self, actual, is_enrolled=False, is_enrollment_open=True): + """DRY helper used to verify that program data is extended correctly.""" + course_overview = CourseOverview.get_from_id(self.course.id) # pylint: disable=no-member + + run_mode = factories.RunMode( + course_key=unicode(self.course.id), # pylint: disable=no-member + course_url=reverse('course_root', args=[self.course.id]), # pylint: disable=no-member + course_image_url=course_overview.course_image_url, + start_date=self.course.start.strftime(self.human_friendly_format), + end_date=self.course.end.strftime(self.human_friendly_format), + is_enrolled=is_enrolled, + is_enrollment_open=is_enrollment_open, + marketing_url='', + ) + course_code = factories.CourseCode(display_name=self.course_code['display_name'], run_modes=[run_mode]) + expected = copy.deepcopy(self.program) + expected['course_codes'] = [course_code] + + self.assertEqual(actual, expected) + + @ddt.data(True, False) + def test_student_enrollment_status(self, is_enrolled): + """Verify that program data is supplemented correctly.""" + if is_enrolled: + CourseEnrollmentFactory(user=self.user, course_id=self.course.id) # pylint: disable=no-member + + data = utils.supplement_program_data(self.program, self.user) + + self._assert_supplemented(data, is_enrolled=is_enrolled) + + @ddt.data( + [1, 1, False], + [1, -1, True], + ) + @ddt.unpack + def test_course_enrollment_status(self, start_offset, end_offset, is_enrollment_open): + """Verify that course enrollment status is reflected correctly.""" + self.course.enrollment_start = timezone.now() - datetime.timedelta(days=start_offset) + self.course.enrollment_end = timezone.now() - datetime.timedelta(days=end_offset) + self.course = self.update_course(self.course, self.user.id) # pylint: disable=no-member + + data = utils.supplement_program_data(self.program, self.user) + + self._assert_supplemented(data, is_enrollment_open=is_enrollment_open) diff --git a/openedx/core/djangoapps/programs/utils.py b/openedx/core/djangoapps/programs/utils.py index afb4a27ad5..cf58feeecc 100644 --- a/openedx/core/djangoapps/programs/utils.py +++ b/openedx/core/djangoapps/programs/utils.py @@ -1,10 +1,19 @@ # -*- coding: utf-8 -*- """Helper functions for working with Programs.""" +import datetime import logging +from django.core.urlresolvers import reverse +from django.utils import timezone +from opaque_keys.edx.keys import CourseKey +import pytz + from lms.djangoapps.certificates.api import get_certificates_for_user, is_passing_status +from openedx.core.djangoapps.content.course_overviews.models import CourseOverview from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.lib.edx_api_utils import get_edx_api_data +from student.models import CourseEnrollment +from xmodule.course_metadata_utils import DEFAULT_START_DATE log = logging.getLogger(__name__) @@ -275,3 +284,37 @@ class ProgramProgressMeter(object): } return parsed + + +def supplement_program_data(program_data, user): + """Supplement program course codes with CourseOverview and CourseEnrollment data. + + Arguments: + program_data (dict): Representation of a program. + user (User): The user whose enrollments to inspect. + """ + for course_code in program_data['course_codes']: + for run_mode in course_code['run_modes']: + course_key = CourseKey.from_string(run_mode['course_key']) + course_overview = CourseOverview.get_from_id(course_key) + + run_mode['course_url'] = reverse('course_root', args=[course_key]) + run_mode['course_image_url'] = course_overview.course_image_url + + human_friendly_format = '%x' + start_date = course_overview.start or DEFAULT_START_DATE + end_date = course_overview.end or datetime.datetime.max.replace(tzinfo=pytz.UTC) + run_mode['start_date'] = start_date.strftime(human_friendly_format) + run_mode['end_date'] = end_date.strftime(human_friendly_format) + + run_mode['is_enrolled'] = CourseEnrollment.is_enrolled(user, course_key) + + enrollment_start = course_overview.enrollment_start or datetime.datetime.min.replace(tzinfo=pytz.UTC) + enrollment_end = course_overview.enrollment_end or datetime.datetime.max.replace(tzinfo=pytz.UTC) + is_enrollment_open = enrollment_start <= timezone.now() < enrollment_end + run_mode['is_enrollment_open'] = is_enrollment_open + + # TODO: Currently unavailable on LMS. + run_mode['marketing_url'] = '' + + return program_data