Commit 9be31918 by Renzo Lucioni Committed by GitHub

Merge pull request #14462 from edx/renzo/programs-from-catalog

Load all programs from the catalog
parents 54e4bcfa e7771148
...@@ -14,7 +14,6 @@ from django.contrib.auth.models import User, AnonymousUser ...@@ -14,7 +14,6 @@ from django.contrib.auth.models import User, AnonymousUser
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.test import TestCase, override_settings from django.test import TestCase, override_settings
from django.test.client import Client from django.test.client import Client
from edx_oauth2_provider.tests.factories import ClientFactory
import httpretty import httpretty
from markupsafe import escape from markupsafe import escape
from mock import Mock, patch from mock import Mock, patch
...@@ -30,11 +29,15 @@ from certificates.tests.factories import GeneratedCertificateFactory # pylint: ...@@ -30,11 +29,15 @@ from certificates.tests.factories import GeneratedCertificateFactory # pylint:
from config_models.models import cache from config_models.models import cache
from course_modes.models import CourseMode from course_modes.models import CourseMode
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase from openedx.core.djangoapps.catalog.tests.factories import (
from openedx.core.djangoapps.programs.models import ProgramsApiConfig generate_course_run_key,
from openedx.core.djangoapps.programs.tests import factories as programs_factories ProgramFactory,
CourseFactory as CatalogCourseFactory,
CourseRunFactory,
)
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin
from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin from openedx.core.djangoapps.site_configuration.tests.mixins import SiteMixin
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
import shoppingcart # pylint: disable=import-error import shoppingcart # pylint: disable=import-error
from student.models import ( from student.models import (
anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment, anonymous_id_for_user, user_by_anonymous_id, CourseEnrollment,
...@@ -988,11 +991,10 @@ class AnonymousLookupTable(ModuleStoreTestCase): ...@@ -988,11 +991,10 @@ class AnonymousLookupTable(ModuleStoreTestCase):
@attr(shard=3) @attr(shard=3)
@httpretty.activate @skip_unless_lms
@unittest.skipUnless(settings.ROOT_URLCONF == 'lms.urls', 'Test only valid in lms') @patch('openedx.core.djangoapps.programs.utils.get_programs')
class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
"""Tests verifying that related programs appear on the course dashboard.""" """Tests verifying that related programs appear on the course dashboard."""
url = None
maxDiff = None maxDiff = None
password = 'test' password = 'test'
related_programs_preface = 'Related Programs' related_programs_preface = 'Related Programs'
...@@ -1004,18 +1006,6 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): ...@@ -1004,18 +1006,6 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
cls.user = UserFactory() cls.user = UserFactory()
cls.course = CourseFactory() cls.course = CourseFactory()
cls.enrollment = CourseEnrollmentFactory(user=cls.user, course_id=cls.course.id) # pylint: disable=no-member cls.enrollment = CourseEnrollmentFactory(user=cls.user, course_id=cls.course.id) # pylint: disable=no-member
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
cls.organization = programs_factories.Organization()
run_mode = programs_factories.RunMode(course_key=unicode(cls.course.id)) # pylint: disable=no-member
course_code = programs_factories.CourseCode(run_modes=[run_mode])
cls.programs = [
programs_factories.Program(
organizations=[cls.organization],
course_codes=[course_code]
) for __ in range(2)
]
def setUp(self): def setUp(self):
super(RelatedProgramsTests, self).setUp() super(RelatedProgramsTests, self).setUp()
...@@ -1025,14 +1015,9 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): ...@@ -1025,14 +1015,9 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
self.create_programs_config() self.create_programs_config()
self.client.login(username=self.user.username, password=self.password) self.client.login(username=self.user.username, password=self.password)
def mock_programs_api(self, data): course_run = CourseRunFactory(key=unicode(self.course.id)) # pylint: disable=no-member
"""Helper for mocking out Programs API URLs.""" course = CatalogCourseFactory(course_runs=[course_run])
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.') self.programs = [ProgramFactory(courses=[course]) for __ in range(2)]
url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/'
body = json.dumps({'results': data})
httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json')
def assert_related_programs(self, response, are_programs_present=True): def assert_related_programs(self, response, are_programs_present=True):
"""Assertion for verifying response contents.""" """Assertion for verifying response contents."""
...@@ -1045,42 +1030,40 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase): ...@@ -1045,42 +1030,40 @@ class RelatedProgramsTests(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
def expected_link_text(self, program): def expected_link_text(self, program):
"""Construct expected dashboard link text.""" """Construct expected dashboard link text."""
return u'{name} {category}'.format(name=program['name'], category=program['category']) return u'{title} {type}'.format(title=program['title'], type=program['type'])
def test_related_programs_listed(self): def test_related_programs_listed(self, mock_get_programs):
"""Verify that related programs are listed when the programs API returns data.""" """Verify that related programs are listed when available."""
self.mock_programs_api(self.programs) mock_get_programs.return_value = self.programs
response = self.client.get(self.url) response = self.client.get(self.url)
self.assert_related_programs(response) self.assert_related_programs(response)
def test_no_data_no_programs(self): def test_no_data_no_programs(self, mock_get_programs):
"""Verify that related programs aren't listed if the programs API returns no data.""" """Verify that related programs aren't listed when none are available."""
self.mock_programs_api([]) mock_get_programs.return_value = []
response = self.client.get(self.url) response = self.client.get(self.url)
self.assert_related_programs(response, are_programs_present=False) self.assert_related_programs(response, are_programs_present=False)
def test_unrelated_program_not_listed(self): def test_unrelated_program_not_listed(self, mock_get_programs):
"""Verify that unrelated programs don't appear in the listing.""" """Verify that unrelated programs don't appear in the listing."""
run_mode = programs_factories.RunMode(course_key='some/nonexistent/run') nonexistent_course_run_id = generate_course_run_key()
course_code = programs_factories.CourseCode(run_modes=[run_mode])
unrelated_program = programs_factories.Program( course_run = CourseRunFactory(key=nonexistent_course_run_id)
organizations=[self.organization], course = CatalogCourseFactory(course_runs=[course_run])
course_codes=[course_code] unrelated_program = ProgramFactory(courses=[course])
)
self.mock_programs_api(self.programs + [unrelated_program]) mock_get_programs.return_value = self.programs + [unrelated_program]
response = self.client.get(self.url) response = self.client.get(self.url)
self.assert_related_programs(response) self.assert_related_programs(response)
self.assertNotContains(response, unrelated_program['name']) self.assertNotContains(response, unrelated_program['title'])
def test_program_title_unicode(self): def test_program_title_unicode(self, mock_get_programs):
"""Verify that the dashboard can deal with programs whose titles contain Unicode.""" """Verify that the dashboard can deal with programs whose titles contain Unicode."""
self.programs[0]['name'] = u'Bases matemáticas para estudiar ingeniería' self.programs[0]['title'] = u'Bases matemáticas para estudiar ingeniería'
self.mock_programs_api(self.programs) mock_get_programs.return_value = self.programs
response = self.client.get(self.url) response = self.client.get(self.url)
self.assert_related_programs(response) self.assert_related_programs(response)
......
...@@ -13,7 +13,6 @@ from django.views.generic import TemplateView ...@@ -13,7 +13,6 @@ from django.views.generic import TemplateView
from pytz import UTC from pytz import UTC
from requests import HTTPError from requests import HTTPError
from ipware.ip import get_ip from ipware.ip import get_ip
import waffle
import edx_oauth2_provider import edx_oauth2_provider
from django.conf import settings from django.conf import settings
...@@ -125,12 +124,13 @@ from notification_prefs.views import enable_notifications ...@@ -125,12 +124,13 @@ from notification_prefs.views import enable_notifications
from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings from openedx.core.djangoapps.credit.email_utils import get_credit_provider_display_names, make_providers_strings
from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY from openedx.core.djangoapps.lang_pref import LANGUAGE_KEY
from openedx.core.djangoapps.programs import utils as programs_utils from openedx.core.djangoapps.catalog.utils import munge_catalog_program
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.utils import ProgramProgressMeter
from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers from openedx.core.djangoapps.site_configuration import helpers as configuration_helpers
from openedx.core.djangoapps.theming import helpers as theming_helpers from openedx.core.djangoapps.theming import helpers as theming_helpers
from openedx.core.djangoapps.user_api.preferences import api as preferences_api from openedx.core.djangoapps.user_api.preferences import api as preferences_api
from openedx.core.djangoapps.catalog.utils import get_programs_data from openedx.core.djangoapps.catalog.utils import get_programs_with_type_logo
log = logging.getLogger("edx.student") log = logging.getLogger("edx.student")
...@@ -217,7 +217,7 @@ def index(request, extra_context=None, user=AnonymousUser()): ...@@ -217,7 +217,7 @@ def index(request, extra_context=None, user=AnonymousUser()):
# for edx-pattern-library is added. # for edx-pattern-library is added.
if configuration_helpers.get_value("DISPLAY_PROGRAMS_ON_MARKETING_PAGES", if configuration_helpers.get_value("DISPLAY_PROGRAMS_ON_MARKETING_PAGES",
settings.FEATURES.get("DISPLAY_PROGRAMS_ON_MARKETING_PAGES")): settings.FEATURES.get("DISPLAY_PROGRAMS_ON_MARKETING_PAGES")):
programs_list = get_programs_data(user) programs_list = get_programs_with_type_logo()
context["programs_list"] = programs_list context["programs_list"] = programs_list
...@@ -664,12 +664,14 @@ def dashboard(request): ...@@ -664,12 +664,14 @@ def dashboard(request):
and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview) and has_access(request.user, 'view_courseware_with_prerequisites', enrollment.course_overview)
) )
# Find programs associated with courses being displayed. This information # Find programs associated with course runs being displayed. This information
# is passed in the template context to allow rendering of program-related # is passed in the template context to allow rendering of program-related
# information on the dashboard. # information on the dashboard.
use_catalog = waffle.switch_is_active('get_programs_from_catalog') meter = ProgramProgressMeter(user, enrollments=course_enrollments)
meter = programs_utils.ProgramProgressMeter(user, enrollments=course_enrollments, use_catalog=use_catalog) inverted_programs = meter.invert_programs()
programs_by_run = meter.engaged_programs(by_run=True)
for program_list in inverted_programs.itervalues():
program_list[:] = [munge_catalog_program(program) for program in program_list]
# Construct a dictionary of course mode information # Construct a dictionary of course mode information
# used to render the course list. We re-use the course modes dict # used to render the course list. We re-use the course modes dict
...@@ -793,7 +795,7 @@ def dashboard(request): ...@@ -793,7 +795,7 @@ def dashboard(request):
'order_history_list': order_history_list, 'order_history_list': order_history_list,
'courses_requirements_not_met': courses_requirements_not_met, 'courses_requirements_not_met': courses_requirements_not_met,
'nav_hidden': True, 'nav_hidden': True,
'programs_by_run': programs_by_run, 'programs_by_run': inverted_programs,
'show_program_listing': ProgramsApiConfig.current().show_program_listing, 'show_program_listing': ProgramsApiConfig.current().show_program_listing,
'disable_courseware_js': True, 'disable_courseware_js': True,
'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard, 'display_course_modes_on_dashboard': enable_verified_certificates and display_course_modes_on_dashboard,
......
""" """
Stub implementation of catalog service for acceptance tests Stub implementation of catalog service for acceptance tests
""" """
# pylint: disable=invalid-name, missing-docstring
import re import re
import urlparse import urlparse
from .http import StubHttpRequestHandler, StubHttpService from .http import StubHttpRequestHandler, StubHttpService
class StubCatalogServiceHandler(StubHttpRequestHandler): # pylint: disable=missing-docstring class StubCatalogServiceHandler(StubHttpRequestHandler):
def do_GET(self): # pylint: disable=invalid-name, missing-docstring def do_GET(self):
pattern_handlers = { pattern_handlers = {
r'/api/v1/programs/$': self.get_programs, r'/api/v1/programs/$': self.program_list,
r'/api/v1/course_runs/(?P<course_id>[^/+]+(/|\+)[^/+]+(/|\+)[^/?]+)/$': self.get_course_run, r'/api/v1/programs/([0-9a-f-]+)/$': self.program_detail,
} }
if self.match_pattern(pattern_handlers): if self.match_pattern(pattern_handlers):
return return
self.send_response(404, content="404 Not Found") self.send_response(404, content='404 Not Found')
def match_pattern(self, pattern_handlers): def match_pattern(self, pattern_handlers):
""" """
Find the correct handler method given the path info from the HTTP request. Find the correct handler method given the path info from the HTTP request.
""" """
path = urlparse.urlparse(self.path).path path = urlparse.urlparse(self.path).path
for pattern in pattern_handlers: for pattern, handler in pattern_handlers.items():
match = re.match(pattern, path) match = re.match(pattern, path)
if match: if match:
pattern_handlers[pattern](*match.groups()) handler(*match.groups())
return True return True
return None
def get_programs(self): def program_list(self):
""" """Stub the catalog's program list endpoint."""
Stubs the catalog's programs endpoint.
"""
programs = self.server.config.get('catalog.programs', []) programs = self.server.config.get('catalog.programs', [])
self.send_json_response(programs) self.send_json_response(programs)
def get_course_run(self, course_id): def program_detail(self, program_uuid):
""" """Stub the catalog's program detail endpoint."""
Stubs the catalog's course run endpoint. program = self.server.config.get('catalog.programs.' + program_uuid)
""" self.send_json_response(program)
course_run = self.server.config.get('course_run.{}'.format(course_id), [])
self.send_json_response(course_run)
class StubCatalogService(StubHttpService): # pylint: disable=missing-docstring class StubCatalogService(StubHttpService):
HANDLER_CLASS = StubCatalogServiceHandler HANDLER_CLASS = StubCatalogServiceHandler
"""
Stub implementation of programs service for acceptance tests
"""
import re
import urlparse
from .http import StubHttpRequestHandler, StubHttpService
class StubProgramsServiceHandler(StubHttpRequestHandler): # pylint: disable=missing-docstring
def do_GET(self): # pylint: disable=invalid-name, missing-docstring
pattern_handlers = {
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):
"""
Find the correct handler method given the path info from the HTTP request.
"""
path = urlparse.urlparse(self.path).path
for pattern in pattern_handlers:
match = re.match(pattern, path)
if match:
pattern_handlers[pattern](*match.groups())
return True
return None
def get_programs_list(self):
"""
Stubs the programs list endpoint.
"""
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
...@@ -12,7 +12,6 @@ from .youtube import StubYouTubeService ...@@ -12,7 +12,6 @@ from .youtube import StubYouTubeService
from .lti import StubLtiService from .lti import StubLtiService
from .video_source import VideoSourceHttpService from .video_source import VideoSourceHttpService
from .edxnotes import StubEdxNotesService from .edxnotes import StubEdxNotesService
from .programs import StubProgramsService
from .catalog import StubCatalogService from .catalog import StubCatalogService
...@@ -25,7 +24,6 @@ SERVICES = { ...@@ -25,7 +24,6 @@ SERVICES = {
'lti': StubLtiService, 'lti': StubLtiService,
'video': VideoSourceHttpService, 'video': VideoSourceHttpService,
'edxnotes': StubEdxNotesService, 'edxnotes': StubEdxNotesService,
'programs': StubProgramsService,
'ecommerce': StubEcommerceService, 'ecommerce': StubEcommerceService,
'catalog': StubCatalogService, 'catalog': StubCatalogService,
} }
......
...@@ -19,8 +19,5 @@ COMMENTS_STUB_URL = os.environ.get('comments_url', 'http://localhost:4567') ...@@ -19,8 +19,5 @@ COMMENTS_STUB_URL = os.environ.get('comments_url', 'http://localhost:4567')
# Get the URL of the EdxNotes service stub used in the test # Get the URL of the EdxNotes service stub used in the test
EDXNOTES_STUB_URL = os.environ.get('edxnotes_url', 'http://localhost:8042') EDXNOTES_STUB_URL = os.environ.get('edxnotes_url', 'http://localhost:8042')
# Get the URL of the Programs service stub used in the test
PROGRAMS_STUB_URL = os.environ.get('programs_url', 'http://localhost:8090')
# Get the URL of the Catalog service stub used in the test # Get the URL of the Catalog service stub used in the test
CATALOG_STUB_URL = os.environ.get('catalog_url', 'http://localhost:8091') CATALOG_STUB_URL = os.environ.get('catalog_url', 'http://localhost:8091')
...@@ -13,31 +13,31 @@ class CatalogFixture(object): ...@@ -13,31 +13,31 @@ class CatalogFixture(object):
""" """
Interface to set up mock responses from the Catalog stub server. Interface to set up mock responses from the Catalog stub server.
""" """
def install_programs(self, programs): def install_programs(self, data):
"""Set response data for the catalog's course run API.""" """Set response data for the catalog's course run API."""
key = 'catalog.programs' key = 'catalog.programs'
requests.put( if isinstance(data, dict):
'{}/set_config'.format(CATALOG_STUB_URL), key += '.' + data['uuid']
data={key: json.dumps(programs)},
)
def install_course_run(self, course_run): requests.put(
"""Set response data for the catalog's course run API.""" '{}/set_config'.format(CATALOG_STUB_URL),
key = 'catalog.{}'.format(course_run['key']) data={key: json.dumps(data)},
)
requests.put( else:
'{}/set_config'.format(CATALOG_STUB_URL), requests.put(
data={key: json.dumps(course_run)}, '{}/set_config'.format(CATALOG_STUB_URL),
) data={key: json.dumps({'results': data})},
)
class CatalogConfigMixin(object): class CatalogIntegrationMixin(object):
"""Mixin providing a method used to configure the catalog integration.""" """Mixin providing a method used to configure the catalog integration."""
def set_catalog_configuration(self, is_enabled=False, service_url=CATALOG_STUB_URL): def set_catalog_integration(self, is_enabled=False, service_username=None):
"""Dynamically adjusts the catalog config model during tests.""" """Use this to change the catalog integration config model during tests."""
ConfigModelFixture('/config/catalog', { ConfigModelFixture('/config/catalog', {
'enabled': is_enabled, 'enabled': is_enabled,
'internal_api_url': '{}/api/v1/'.format(service_url), 'internal_api_url': '{}/api/v1/'.format(CATALOG_STUB_URL),
'cache_ttl': 0, 'cache_ttl': 0,
'service_username': service_username,
}).install() }).install()
""" """
Tools to create programs-related data for use in bok choy tests. Tools to create programs-related data for use in bok choy tests.
""" """
import json
import requests
from common.test.acceptance.fixtures import PROGRAMS_STUB_URL
from common.test.acceptance.fixtures.config import ConfigModelFixture from common.test.acceptance.fixtures.config import ConfigModelFixture
class ProgramsFixture(object):
"""
Interface to set up mock responses from the Programs stub server.
"""
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={key: json.dumps(api_result)},
)
class ProgramsConfigMixin(object): class ProgramsConfigMixin(object):
"""Mixin providing a method used to configure the programs feature.""" """Mixin providing a method used to configure the programs feature."""
def set_programs_api_configuration(self, is_enabled=False, api_version=1, api_url=PROGRAMS_STUB_URL): def set_programs_api_configuration(self, is_enabled=False, api_version=1):
"""Dynamically adjusts the Programs config model during tests.""" """Dynamically adjusts the Programs config model during tests."""
ConfigModelFixture('/config/programs', { ConfigModelFixture('/config/programs', {
'enabled': is_enabled, 'enabled': is_enabled,
'api_version_number': api_version, 'api_version_number': api_version,
'internal_service_url': api_url,
'public_service_url': api_url,
'cache_ttl': 0, 'cache_ttl': 0,
'marketing_path': '/foo',
'enable_student_dashboard': is_enabled, 'enable_student_dashboard': is_enabled,
'enable_studio_tab': is_enabled,
'enable_certification': is_enabled, 'enable_certification': is_enabled,
'program_listing_enabled': is_enabled, 'program_listing_enabled': is_enabled,
'program_details_enabled': is_enabled, 'program_details_enabled': is_enabled,
......
"""LMS-hosted Programs pages""" """LMS-hosted Programs pages"""
from uuid import uuid4
from bok_choy.page_object import PageObject from bok_choy.page_object import PageObject
from common.test.acceptance.pages.lms import BASE_URL from common.test.acceptance.pages.lms import BASE_URL
...@@ -24,8 +26,8 @@ class ProgramListingPage(PageObject): ...@@ -24,8 +26,8 @@ class ProgramListingPage(PageObject):
class ProgramDetailsPage(PageObject): class ProgramDetailsPage(PageObject):
"""Program details page.""" """Program details page."""
program_id = 123 program_uuid = str(uuid4())
url = BASE_URL + '/dashboard/programs/{}/program-name/'.format(program_id) url = '{base}/dashboard/programs/{program_uuid}/'.format(base=BASE_URL, program_uuid=program_uuid)
def is_browser_on_page(self): def is_browser_on_page(self):
return self.q(css='.js-program-details-wrapper').present return self.q(css='.js-program-details-wrapper').present
"""Acceptance tests for LMS-hosted Programs pages""" """Acceptance tests for LMS-hosted Programs pages"""
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from common.test.acceptance.fixtures.catalog import CatalogFixture, CatalogConfigMixin from common.test.acceptance.fixtures.catalog import CatalogFixture, CatalogIntegrationMixin
from common.test.acceptance.fixtures.programs import ProgramsFixture, ProgramsConfigMixin from common.test.acceptance.fixtures.programs import ProgramsConfigMixin
from common.test.acceptance.fixtures.course import CourseFixture from common.test.acceptance.fixtures.course import CourseFixture
from common.test.acceptance.tests.helpers import UniqueCourseTest from common.test.acceptance.tests.helpers import UniqueCourseTest
from common.test.acceptance.pages.lms.auto_auth import AutoAuthPage from common.test.acceptance.pages.lms.auto_auth import AutoAuthPage
from common.test.acceptance.pages.lms.programs import ProgramListingPage, ProgramDetailsPage from common.test.acceptance.pages.lms.programs import ProgramListingPage, ProgramDetailsPage
from openedx.core.djangoapps.catalog.tests import factories as catalog_factories from openedx.core.djangoapps.catalog.tests.factories import (
from openedx.core.djangoapps.programs.tests import factories as program_factories ProgramFactory, CourseFactory, CourseRunFactory
)
class ProgramPageBase(ProgramsConfigMixin, CatalogConfigMixin, UniqueCourseTest): class ProgramPageBase(ProgramsConfigMixin, CatalogIntegrationMixin, UniqueCourseTest):
"""Base class used for program listing page tests.""" """Base class used for program listing page tests."""
def setUp(self): def setUp(self):
super(ProgramPageBase, self).setUp() super(ProgramPageBase, self).setUp()
self.set_programs_api_configuration(is_enabled=True) self.set_programs_api_configuration(is_enabled=True)
self.programs = [catalog_factories.Program() for __ in range(3)] self.programs = ProgramFactory.create_batch(3)
self.course_run = catalog_factories.CourseRun(key=self.course_id) self.username = None
self.stub_catalog_api()
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
run_mode = program_factories.RunMode(course_key=course_id)
course_code = program_factories.CourseCode(run_modes=[run_mode])
org = program_factories.Organization(key=self.course_info['org'])
if program_id:
program = program_factories.Program(
id=program_id,
status='active',
organizations=[org],
course_codes=[course_code]
)
else:
program = program_factories.Program(
status='active',
organizations=[org],
course_codes=[course_code]
)
return program
def stub_programs_api(self, programs, is_list=True):
"""Stub out the programs API with fake data."""
ProgramsFixture().install_programs(programs, is_list=is_list)
def stub_catalog_api(self):
"""Stub out the catalog API's program and course run endpoints."""
self.set_catalog_configuration(is_enabled=True)
CatalogFixture().install_programs(self.programs)
CatalogFixture().install_course_run(self.course_run)
def auth(self, enroll=True): def auth(self, enroll=True):
"""Authenticate, enrolling the user in the configured course if requested.""" """Authenticate, enrolling the user in the configured course if requested."""
CourseFixture(**self.course_info).install() CourseFixture(**self.course_info).install()
course_id = self.course_id if enroll else None course_id = self.course_id if enroll else None
AutoAuthPage(self.browser, course_id=course_id).visit() auth_page = AutoAuthPage(self.browser, course_id=course_id)
auth_page.visit()
self.username = auth_page.user_info['username']
def create_program(self):
"""DRY helper for creating test program data."""
course_run = CourseRunFactory(key=self.course_id)
course = CourseFactory(course_runs=[course_run])
return ProgramFactory(courses=[course])
def stub_catalog_api(self, data=None):
"""Stub out the catalog API's program and course run endpoints."""
self.set_catalog_integration(is_enabled=True, service_username=self.username)
CatalogFixture().install_programs(data or self.programs)
class ProgramListingPageTest(ProgramPageBase): class ProgramListingPageTest(ProgramPageBase):
...@@ -73,9 +54,8 @@ class ProgramListingPageTest(ProgramPageBase): ...@@ -73,9 +54,8 @@ class ProgramListingPageTest(ProgramPageBase):
def test_no_enrollments(self): def test_no_enrollments(self):
"""Verify that no cards appear when the user has no enrollments.""" """Verify that no cards appear when the user has no enrollments."""
program = self.create_program()
self.stub_programs_api([program])
self.auth(enroll=False) self.auth(enroll=False)
self.stub_catalog_api()
self.listing_page.visit() self.listing_page.visit()
...@@ -87,14 +67,8 @@ class ProgramListingPageTest(ProgramPageBase): ...@@ -87,14 +67,8 @@ class ProgramListingPageTest(ProgramPageBase):
Verify that no cards appear when the user has enrollments Verify that no cards appear when the user has enrollments
but none are included in an active program. but none are included in an active program.
""" """
course_id = self.course_id.replace(
self.course_info['run'],
'other_run'
)
program = self.create_program(course_id=course_id)
self.stub_programs_api([program])
self.auth() self.auth()
self.stub_catalog_api()
self.listing_page.visit() self.listing_page.visit()
...@@ -106,10 +80,11 @@ class ProgramListingPageTest(ProgramPageBase): ...@@ -106,10 +80,11 @@ class ProgramListingPageTest(ProgramPageBase):
Verify that cards appear when the user has enrollments Verify that cards appear when the user has enrollments
which are included in at least one active program. which are included in at least one active program.
""" """
program = self.create_program()
self.stub_programs_api([program])
self.auth() self.auth()
program = self.create_program()
self.stub_catalog_api(data=[program])
self.listing_page.visit() self.listing_page.visit()
self.assertTrue(self.listing_page.is_sidebar_present) self.assertTrue(self.listing_page.is_sidebar_present)
...@@ -124,12 +99,13 @@ class ProgramListingPageA11yTest(ProgramPageBase): ...@@ -124,12 +99,13 @@ class ProgramListingPageA11yTest(ProgramPageBase):
self.listing_page = ProgramListingPage(self.browser) self.listing_page = ProgramListingPage(self.browser)
program = self.create_program() self.program = self.create_program()
self.stub_programs_api([program])
def test_empty_a11y(self): def test_empty_a11y(self):
"""Test a11y of the page's empty state.""" """Test a11y of the page's empty state."""
self.auth(enroll=False) self.auth(enroll=False)
self.stub_catalog_api(data=[self.program])
self.listing_page.visit() self.listing_page.visit()
self.assertTrue(self.listing_page.is_sidebar_present) self.assertTrue(self.listing_page.is_sidebar_present)
...@@ -139,6 +115,8 @@ class ProgramListingPageA11yTest(ProgramPageBase): ...@@ -139,6 +115,8 @@ class ProgramListingPageA11yTest(ProgramPageBase):
def test_cards_a11y(self): def test_cards_a11y(self):
"""Test a11y when program cards are present.""" """Test a11y when program cards are present."""
self.auth() self.auth()
self.stub_catalog_api(data=[self.program])
self.listing_page.visit() self.listing_page.visit()
self.assertTrue(self.listing_page.is_sidebar_present) self.assertTrue(self.listing_page.is_sidebar_present)
...@@ -154,11 +132,14 @@ class ProgramDetailsPageA11yTest(ProgramPageBase): ...@@ -154,11 +132,14 @@ class ProgramDetailsPageA11yTest(ProgramPageBase):
self.details_page = ProgramDetailsPage(self.browser) self.details_page = ProgramDetailsPage(self.browser)
program = self.create_program(program_id=self.details_page.program_id) self.program = self.create_program()
self.stub_programs_api([program], is_list=False) self.program['uuid'] = self.details_page.program_uuid
def test_a11y(self): def test_a11y(self):
"""Test the page's a11y compliance.""" """Test the page's a11y compliance."""
self.auth() self.auth()
self.stub_catalog_api(data=self.program)
self.details_page.visit() self.details_page.visit()
self.details_page.a11y_audit.check_for_accessibility_errors() self.details_page.a11y_audit.check_for_accessibility_errors()
...@@ -486,7 +486,7 @@ and the Automated Accessibility Tests `openedx Confluence page ...@@ -486,7 +486,7 @@ and the Automated Accessibility Tests `openedx Confluence page
**Prerequisites**: **Prerequisites**:
These prerequisites are all automatically installed and available in `Devstack These prerequisites are all automatically installed and available in `Devstack
<https://github.com/edx/configuration/wiki/edX-Developer-Stack>`__ (since the Cypress release), the supported development enviornment for the edX Platform. <https://github.com/edx/configuration/wiki/edX-Developer-Stack>`__ (since the Cypress release), the supported development environment for the edX Platform.
* Mongo * Mongo
......
""" """
Tests for branding page Tests for branding page
""" """
import mock
import datetime import datetime
import ddt
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import AnonymousUser from django.contrib.auth.models import AnonymousUser
from django.core.urlresolvers import reverse
from django.http import HttpResponseRedirect from django.http import HttpResponseRedirect
from django.test.utils import override_settings from django.test.utils import override_settings
from django.test.client import RequestFactory from django.test.client import RequestFactory
from pytz import UTC
from mock import patch, Mock from mock import patch, Mock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from pytz import UTC
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from branding.views import index from branding.views import index
from courseware.tests.helpers import LoginEnrollmentTestCase
from milestones.tests.utils import MilestonesTestCaseMixin
from util.milestones_helpers import set_prerequisite_courses
from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase
from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.factories import CourseFactory
from django.core.urlresolvers import reverse
from courseware.tests.helpers import LoginEnrollmentTestCase
from util.milestones_helpers import set_prerequisite_courses
from milestones.tests.utils import MilestonesTestCaseMixin
FEATURES_WITH_STARTDATE = settings.FEATURES.copy() FEATURES_WITH_STARTDATE = settings.FEATURES.copy()
FEATURES_WITH_STARTDATE['DISABLE_START_DATES'] = False FEATURES_WITH_STARTDATE['DISABLE_START_DATES'] = False
...@@ -290,35 +287,31 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase): ...@@ -290,35 +287,31 @@ class IndexPageCourseCardsSortingTests(ModuleStoreTestCase):
self.assertEqual(context['courses'][2].id, self.course_with_default_start_date.id) self.assertEqual(context['courses'][2].id, self.course_with_default_start_date.id)
@ddt.ddt
@attr(shard=1) @attr(shard=1)
class IndexPageProgramsTests(ModuleStoreTestCase): class IndexPageProgramsTests(ModuleStoreTestCase):
""" """
Tests for Programs List in Marketing Pages. Tests for Programs List in Marketing Pages.
""" """
@patch.dict('django.conf.settings.FEATURES', {'DISPLAY_PROGRAMS_ON_MARKETING_PAGES': False})
def test_get_programs_not_called(self): def setUp(self):
with mock.patch("student.views.get_programs_data") as patched_get_programs_data: super(IndexPageProgramsTests, self).setUp()
# check the /dashboard self.client.login(username=self.user.username, password=self.user_password)
response = self.client.get('/')
self.assertEqual(response.status_code, 200) @ddt.data(True, False)
self.assertEqual(patched_get_programs_data.call_count, 0) def test_programs_with_type_logo_called(self, display_programs):
with patch.dict('django.conf.settings.FEATURES', {'DISPLAY_PROGRAMS_ON_MARKETING_PAGES': display_programs}):
with mock.patch("courseware.views.views.get_programs_data") as patched_get_programs_data: views = [
# check the /courses view (reverse('dashboard'), 'student.views.get_programs_with_type_logo'),
response = self.client.get(reverse('branding.views.courses')) (reverse('branding.views.courses'), 'courseware.views.views.get_programs_with_type_logo'),
self.assertEqual(response.status_code, 200) ]
self.assertEqual(patched_get_programs_data.call_count, 0)
for url, dotted_path in views:
@patch.dict('django.conf.settings.FEATURES', {'DISPLAY_PROGRAMS_ON_MARKETING_PAGES': True}) with patch(dotted_path) as mock_get_programs_with_type_logo:
def test_get_programs_called(self): response = self.client.get(url)
with mock.patch("student.views.get_programs_data") as patched_get_programs_data: self.assertEqual(response.status_code, 200)
# check the /dashboard
response = self.client.get('/') if display_programs:
self.assertEqual(response.status_code, 200) mock_get_programs_with_type_logo.assert_called_once()
self.assertEqual(patched_get_programs_data.call_count, 1) else:
mock_get_programs_with_type_logo.assert_not_called_()
with mock.patch("courseware.views.views.get_programs_data") as patched_get_programs_data:
# check the /courses view
response = self.client.get(reverse('branding.views.courses'))
self.assertEqual(response.status_code, 200)
self.assertEqual(patched_get_programs_data.call_count, 1)
...@@ -40,7 +40,7 @@ from lms.djangoapps.instructor.enrollment import uses_shib ...@@ -40,7 +40,7 @@ from lms.djangoapps.instructor.enrollment import uses_shib
from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification from lms.djangoapps.verify_student.models import SoftwareSecurePhotoVerification
from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException from lms.djangoapps.ccx.custom_exception import CCXLocatorValidationException
from openedx.core.djangoapps.catalog.utils import get_programs_data from openedx.core.djangoapps.catalog.utils import get_programs_with_type_logo
import shoppingcart import shoppingcart
import survey.utils import survey.utils
import survey.views import survey.views
...@@ -153,7 +153,7 @@ def courses(request): ...@@ -153,7 +153,7 @@ def courses(request):
# for edx-pattern-library is added. # for edx-pattern-library is added.
if configuration_helpers.get_value("DISPLAY_PROGRAMS_ON_MARKETING_PAGES", if configuration_helpers.get_value("DISPLAY_PROGRAMS_ON_MARKETING_PAGES",
settings.FEATURES.get("DISPLAY_PROGRAMS_ON_MARKETING_PAGES")): settings.FEATURES.get("DISPLAY_PROGRAMS_ON_MARKETING_PAGES")):
programs_list = get_programs_data(request.user) programs_list = get_programs_with_type_logo()
return render_to_response( return render_to_response(
"courseware/courses.html", "courseware/courses.html",
......
...@@ -6,7 +6,5 @@ from . import views ...@@ -6,7 +6,5 @@ from . import views
urlpatterns = [ urlpatterns = [
url(r'^programs/$', views.program_listing, name='program_listing_view'), url(r'^programs/$', views.program_listing, name='program_listing_view'),
# Matches paths like 'programs/123/' and 'programs/123/foo/', but not 'programs/123/foo/bar/'. url(r'^programs/(?P<program_uuid>[0-9a-f-]+)/$', views.program_details, name='program_details_view'),
# Also accepts strings that look like UUIDs, to support retrieval of catalog-based MicroMasters.
url(r'^programs/(?P<program_id>[0-9a-f-]+)/[\w\-]*/?$', views.program_details, name='program_details_view'),
] ]
"""Learner dashboard views""" """Learner dashboard views"""
import uuid
from django.contrib.auth.decorators import login_required from django.contrib.auth.decorators import login_required
from django.core.urlresolvers import reverse from django.core.urlresolvers import reverse
from django.http import Http404 from django.http import Http404
...@@ -8,12 +6,16 @@ from django.views.decorators.http import require_GET ...@@ -8,12 +6,16 @@ from django.views.decorators.http import require_GET
from edxmako.shortcuts import render_to_response from edxmako.shortcuts import render_to_response
from lms.djangoapps.learner_dashboard.utils import strip_course_id, FAKE_COURSE_KEY from lms.djangoapps.learner_dashboard.utils import strip_course_id, FAKE_COURSE_KEY
from openedx.core.djangoapps.catalog.utils import get_programs as get_catalog_programs, munge_catalog_program from openedx.core.djangoapps.catalog.utils import get_programs, munge_catalog_program
from openedx.core.djangoapps.credentials.utils import get_programs_credentials from openedx.core.djangoapps.credentials.utils import get_programs_credentials
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs import utils from openedx.core.djangoapps.programs.utils import (
get_program_marketing_url,
munge_progress_map,
ProgramProgressMeter,
ProgramDataExtender,
)
from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences from openedx.core.djangoapps.user_api.preferences.api import get_user_preferences
import waffle
@login_required @login_required
...@@ -24,16 +26,17 @@ def program_listing(request): ...@@ -24,16 +26,17 @@ def program_listing(request):
if not programs_config.show_program_listing: if not programs_config.show_program_listing:
raise Http404 raise Http404
use_catalog = waffle.switch_is_active('get_programs_from_catalog') meter = ProgramProgressMeter(request.user)
meter = utils.ProgramProgressMeter(request.user, use_catalog=use_catalog) engaged_programs = [munge_catalog_program(program) for program in meter.engaged_programs]
progress = [munge_progress_map(progress_map) for progress_map in meter.progress]
context = { context = {
'credentials': get_programs_credentials(request.user), 'credentials': get_programs_credentials(request.user),
'disable_courseware_js': True, 'disable_courseware_js': True,
'marketing_url': utils.get_program_marketing_url(programs_config), 'marketing_url': get_program_marketing_url(programs_config),
'nav_hidden': True, 'nav_hidden': True,
'programs': meter.engaged_programs(), 'programs': engaged_programs,
'progress': meter.progress, 'progress': progress,
'show_program_listing': programs_config.show_program_listing, 'show_program_listing': programs_config.show_program_listing,
'uses_pattern_library': True, 'uses_pattern_library': True,
} }
...@@ -43,26 +46,18 @@ def program_listing(request): ...@@ -43,26 +46,18 @@ def program_listing(request):
@login_required @login_required
@require_GET @require_GET
def program_details(request, program_id): def program_details(request, program_uuid):
"""View details about a specific program.""" """View details about a specific program."""
programs_config = ProgramsApiConfig.current() programs_config = ProgramsApiConfig.current()
if not programs_config.show_program_details: if not programs_config.show_program_details:
raise Http404 raise Http404
try: program_data = get_programs(uuid=program_uuid)
# If the ID is a UUID, the requested program resides in the catalog.
uuid.UUID(program_id)
program_data = get_catalog_programs(request.user, uuid=program_id)
if program_data:
program_data = munge_catalog_program(program_data)
except ValueError:
program_data = utils.get_programs(request.user, program_id=program_id)
if not program_data: if not program_data:
raise Http404 raise Http404
program_data = utils.ProgramDataExtender(program_data, request.user).extend() program_data = munge_catalog_program(program_data)
program_data = ProgramDataExtender(program_data, request.user).extend()
urls = { urls = {
'program_listing_url': reverse('program_listing_view'), 'program_listing_url': reverse('program_listing_view'),
......
"""Factories for generating fake catalog data.""" """Factories for generating fake catalog data."""
# pylint: disable=missing-docstring, invalid-name # pylint: disable=missing-docstring, invalid-name
from random import randint from functools import partial
import factory import factory
from faker import Faker from faker import Faker
...@@ -9,6 +9,14 @@ from faker import Faker ...@@ -9,6 +9,14 @@ from faker import Faker
fake = Faker() fake = Faker()
def generate_instances(factory_class, count=3):
"""
Use this to populate fields with values derived from other factories. If
the array is used directly, the same value will be used repeatedly.
"""
return factory_class.create_batch(count)
def generate_course_key(): def generate_course_key():
return '+'.join(fake.words(2)) return '+'.join(fake.words(2))
...@@ -26,17 +34,17 @@ def generate_zulu_datetime(): ...@@ -26,17 +34,17 @@ def generate_zulu_datetime():
return fake.date_time().isoformat() + 'Z' return fake.date_time().isoformat() + 'Z'
class DictFactory(factory.Factory): class DictFactoryBase(factory.Factory):
class Meta(object): class Meta(object):
model = dict model = dict
class ImageFactory(DictFactory): class ImageFactoryBase(DictFactoryBase):
height = factory.Faker('random_int') height = factory.Faker('random_int')
width = factory.Faker('random_int') width = factory.Faker('random_int')
class Image(ImageFactory): class ImageFactory(ImageFactoryBase):
""" """
For constructing dicts mirroring the catalog's serialized representation of ImageFields. For constructing dicts mirroring the catalog's serialized representation of ImageFields.
...@@ -46,7 +54,7 @@ class Image(ImageFactory): ...@@ -46,7 +54,7 @@ class Image(ImageFactory):
src = factory.Faker('image_url') src = factory.Faker('image_url')
class StdImage(ImageFactory): class StdImageFactory(ImageFactoryBase):
""" """
For constructing dicts mirroring the catalog's serialized representation of StdImageFields. For constructing dicts mirroring the catalog's serialized representation of StdImageFields.
...@@ -57,21 +65,21 @@ class StdImage(ImageFactory): ...@@ -57,21 +65,21 @@ class StdImage(ImageFactory):
def generate_sized_stdimage(): def generate_sized_stdimage():
return { return {
size: StdImage() for size in ['large', 'medium', 'small', 'x-small'] size: StdImageFactory() for size in ['large', 'medium', 'small', 'x-small']
} }
class Organization(DictFactory): class OrganizationFactory(DictFactoryBase):
key = factory.Faker('word') key = factory.Faker('word')
name = factory.Faker('company') name = factory.Faker('company')
uuid = factory.Faker('uuid4') uuid = factory.Faker('uuid4')
class CourseRun(DictFactory): class CourseRunFactory(DictFactoryBase):
end = factory.LazyFunction(generate_zulu_datetime) end = factory.LazyFunction(generate_zulu_datetime)
enrollment_end = factory.LazyFunction(generate_zulu_datetime) enrollment_end = factory.LazyFunction(generate_zulu_datetime)
enrollment_start = factory.LazyFunction(generate_zulu_datetime) enrollment_start = factory.LazyFunction(generate_zulu_datetime)
image = Image() image = ImageFactory()
key = factory.LazyFunction(generate_course_run_key) key = factory.LazyFunction(generate_course_run_key)
marketing_url = factory.Faker('url') marketing_url = factory.Faker('url')
pacing_type = 'self_paced' pacing_type = 'self_paced'
...@@ -82,20 +90,20 @@ class CourseRun(DictFactory): ...@@ -82,20 +90,20 @@ class CourseRun(DictFactory):
uuid = factory.Faker('uuid4') uuid = factory.Faker('uuid4')
class Course(DictFactory): class CourseFactory(DictFactoryBase):
course_runs = [CourseRun() for __ in range(randint(3, 5))] course_runs = factory.LazyFunction(partial(generate_instances, CourseRunFactory))
image = Image() image = ImageFactory()
key = factory.LazyFunction(generate_course_key) key = factory.LazyFunction(generate_course_key)
owners = [Organization()] owners = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1))
title = factory.Faker('catch_phrase') title = factory.Faker('catch_phrase')
uuid = factory.Faker('uuid4') uuid = factory.Faker('uuid4')
class Program(DictFactory): class ProgramFactory(DictFactoryBase):
authoring_organizations = [Organization()] authoring_organizations = factory.LazyFunction(partial(generate_instances, OrganizationFactory, count=1))
banner_image = factory.LazyFunction(generate_sized_stdimage) banner_image = factory.LazyFunction(generate_sized_stdimage)
card_image_url = factory.Faker('image_url') card_image_url = factory.Faker('image_url')
courses = [Course() for __ in range(randint(3, 5))] courses = factory.LazyFunction(partial(generate_instances, CourseFactory))
marketing_slug = factory.Faker('slug') marketing_slug = factory.Faker('slug')
marketing_url = factory.Faker('url') marketing_url = factory.Faker('url')
status = 'active' status = 'active'
...@@ -105,6 +113,6 @@ class Program(DictFactory): ...@@ -105,6 +113,6 @@ class Program(DictFactory):
uuid = factory.Faker('uuid4') uuid = factory.Faker('uuid4')
class ProgramType(DictFactory): class ProgramTypeFactory(DictFactoryBase):
name = factory.Faker('word') name = factory.Faker('word')
logo_image = factory.LazyFunction(generate_sized_stdimage) logo_image = factory.LazyFunction(generate_sized_stdimage)
...@@ -5,16 +5,19 @@ from openedx.core.djangoapps.catalog.models import CatalogIntegration ...@@ -5,16 +5,19 @@ from openedx.core.djangoapps.catalog.models import CatalogIntegration
class CatalogIntegrationMixin(object): class CatalogIntegrationMixin(object):
"""Utility for working with the catalog service during testing.""" """Utility for working with the catalog service during testing."""
DEFAULTS = { catalog_integration_defaults = {
'enabled': True, 'enabled': True,
'internal_api_url': 'https://catalog-internal.example.com/api/v1/', 'internal_api_url': 'https://catalog-internal.example.com/api/v1/',
'cache_ttl': 0, 'cache_ttl': 0,
'service_username': 'lms_catalog_service_user' 'service_username': 'lms_catalog_service_user',
} }
def create_catalog_integration(self, **kwargs): def create_catalog_integration(self, **kwargs):
"""Creates a new CatalogIntegration with DEFAULTS, updated with any provided overrides.""" """
fields = dict(self.DEFAULTS, **kwargs) Creates a new CatalogIntegration with catalog_integration_defaults,
updated with any provided overrides.
"""
fields = dict(self.catalog_integration_defaults, **kwargs)
CatalogIntegration(**fields).save() CatalogIntegration(**fields).save()
return CatalogIntegration.current() return CatalogIntegration.current()
"""Helper functions for working with the catalog service.""" """Helper functions for working with the catalog service."""
from django.conf import settings from django.conf import settings
from django.contrib.auth.models import User from django.contrib.auth import get_user_model
from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.client import EdxRestApiClient
from opaque_keys.edx.keys import CourseKey from opaque_keys.edx.keys import CourseKey
...@@ -9,6 +9,9 @@ from openedx.core.lib.edx_api_utils import get_edx_api_data ...@@ -9,6 +9,9 @@ from openedx.core.lib.edx_api_utils import get_edx_api_data
from openedx.core.lib.token_utils import JwtBuilder from openedx.core.lib.token_utils import JwtBuilder
User = get_user_model() # pylint: disable=invalid-name
def create_catalog_api_client(user, catalog_integration): def create_catalog_api_client(user, catalog_integration):
"""Returns an API client which can be used to make catalog API requests.""" """Returns an API client which can be used to make catalog API requests."""
scopes = ['email', 'profile'] scopes = ['email', 'profile']
...@@ -18,20 +21,7 @@ def create_catalog_api_client(user, catalog_integration): ...@@ -18,20 +21,7 @@ def create_catalog_api_client(user, catalog_integration):
return EdxRestApiClient(catalog_integration.internal_api_url, jwt=jwt) return EdxRestApiClient(catalog_integration.internal_api_url, jwt=jwt)
def _get_service_user(user, service_username): def get_programs(uuid=None, type=None): # pylint: disable=redefined-builtin
"""
Retrieve and return the Catalog Integration Service User Object
if the passed user is None or anonymous
"""
if not user or user.is_anonymous():
try:
user = User.objects.get(username=service_username)
except User.DoesNotExist:
user = None
return user
def get_programs(user=None, uuid=None, type=None): # pylint: disable=redefined-builtin
"""Retrieve marketable programs from the catalog service. """Retrieve marketable programs from the catalog service.
Keyword Arguments: Keyword Arguments:
...@@ -44,8 +34,9 @@ def get_programs(user=None, uuid=None, type=None): # pylint: disable=redefined- ...@@ -44,8 +34,9 @@ def get_programs(user=None, uuid=None, type=None): # pylint: disable=redefined-
""" """
catalog_integration = CatalogIntegration.current() catalog_integration = CatalogIntegration.current()
if catalog_integration.enabled: if catalog_integration.enabled:
user = _get_service_user(user, catalog_integration.service_username) try:
if not user: user = User.objects.get(username=catalog_integration.service_username)
except User.DoesNotExist:
return [] return []
api = create_catalog_api_client(user, catalog_integration) api = create_catalog_api_client(user, catalog_integration)
...@@ -75,54 +66,15 @@ def get_programs(user=None, uuid=None, type=None): # pylint: disable=redefined- ...@@ -75,54 +66,15 @@ def get_programs(user=None, uuid=None, type=None): # pylint: disable=redefined-
return [] return []
def get_program_types(user=None): # pylint: disable=redefined-builtin def munge_catalog_program(catalog_program):
"""Retrieve all program types from the catalog service.
Returns:
list of dict, representing program types.
""" """
catalog_integration = CatalogIntegration.current() Make a program from the catalog service look like it came from the programs service.
if catalog_integration.enabled:
user = _get_service_user(user, catalog_integration.service_username)
if not user:
return []
api = create_catalog_api_client(user, catalog_integration)
cache_key = '{base}.program_types'.format(base=catalog_integration.CACHE_KEY)
return get_edx_api_data(
catalog_integration,
user,
'program_types',
cache_key=cache_key if catalog_integration.is_cache_enabled else None,
api=api
)
else:
return []
def get_programs_data(user=None):
"""Return the list of Programs after adding the ProgramType Logo Image"""
programs_list = get_programs(user) We want to display programs from the catalog service on the LMS. The LMS
program_types = get_program_types(user) originally retrieved all program data from the deprecated programs service.
This temporary utility is here to help incrementally swap out the backend.
program_types_lookup_dict = {program_type["name"]: program_type for program_type in program_types} Clean up of this debt is tracked by ECOM-4418.
for program in programs_list:
program["logo_image"] = program_types_lookup_dict[program["type"]]["logo_image"]
return programs_list
def munge_catalog_program(catalog_program):
"""Make a program from the catalog service look like it came from the programs service.
Catalog-based MicroMasters need to be displayed in the LMS. However, the LMS
currently retrieves all program data from the soon-to-be-retired programs service.
Consuming program data exclusively from the catalog service would have taken more time
than we had prior to the MicroMasters launch. This is a functional middle ground
introduced by ECOM-5460. Cleaning up this debt is tracked by ECOM-4418.
Arguments: Arguments:
catalog_program (dict): The catalog service's representation of a program. catalog_program (dict): The catalog service's representation of a program.
...@@ -153,10 +105,11 @@ def munge_catalog_program(catalog_program): ...@@ -153,10 +105,11 @@ def munge_catalog_program(catalog_program):
} if course['owners'] else {}, } if course['owners'] else {},
'run_modes': [ 'run_modes': [
{ {
'course_key': run['key'], 'course_key': course_run['key'],
'run_key': CourseKey.from_string(run['key']).run, 'run_key': CourseKey.from_string(course_run['key']).run,
'mode_slug': 'verified' 'mode_slug': course_run['type'],
} for run in course['course_runs'] 'marketing_url': course_run['marketing_url'],
} for course_run in course['course_runs']
], ],
} for course in catalog_program['courses'] } for course in catalog_program['courses']
], ],
...@@ -166,48 +119,48 @@ def munge_catalog_program(catalog_program): ...@@ -166,48 +119,48 @@ def munge_catalog_program(catalog_program):
'w435h145': catalog_program['banner_image']['small']['url'], 'w435h145': catalog_program['banner_image']['small']['url'],
'w348h116': catalog_program['banner_image']['x-small']['url'], 'w348h116': catalog_program['banner_image']['x-small']['url'],
}, },
# If a detail URL has been added, we don't want to lose it.
'detail_url': catalog_program.get('detail_url'),
} }
def get_course_run(course_key, user): def get_program_types():
"""Get a course run's data from the course catalog service. """Retrieve all program types from the catalog service.
Arguments:
course_key (CourseKey): Course key object identifying the run whose data we want.
user (User): The user to authenticate as when making requests to the catalog service.
Returns: Returns:
dict, empty if no data could be retrieved. list of dict, representing program types.
""" """
catalog_integration = CatalogIntegration.current() catalog_integration = CatalogIntegration.current()
if catalog_integration.enabled: if catalog_integration.enabled:
try:
user = User.objects.get(username=catalog_integration.service_username)
except User.DoesNotExist:
return []
api = create_catalog_api_client(user, catalog_integration) api = create_catalog_api_client(user, catalog_integration)
cache_key = '{base}.program_types'.format(base=catalog_integration.CACHE_KEY)
data = get_edx_api_data( return get_edx_api_data(
catalog_integration, catalog_integration,
user, user,
'course_runs', 'program_types',
resource_id=unicode(course_key), cache_key=cache_key if catalog_integration.is_cache_enabled else None,
cache_key=catalog_integration.CACHE_KEY if catalog_integration.is_cache_enabled else None, api=api
api=api,
querystring={'exclude_utm': 1},
) )
return data if data else {}
else: else:
return {} return []
def get_run_marketing_url(course_key, user): def get_programs_with_type_logo():
"""Get a course run's marketing URL from the course catalog service. """
Join program type logos with programs of corresponding type.
"""
programs_list = get_programs()
program_types = get_program_types()
Arguments: type_logo_map = {program_type['name']: program_type['logo_image'] for program_type in program_types}
course_key (CourseKey): Course key object identifying the run whose marketing URL we want.
user (User): The user to authenticate as when making requests to the catalog service.
Returns: for program in programs_list:
string, the marketing URL, or None if no URL is available. program['logo_image'] = type_logo_map[program['type']]
"""
course_run = get_course_run(course_key, user) return programs_list
return course_run.get('marketing_url')
...@@ -8,7 +8,7 @@ import mock ...@@ -8,7 +8,7 @@ import mock
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from provider.constants import CONFIDENTIAL from provider.constants import CONFIDENTIAL
from openedx.core.djangoapps.catalog.tests import factories as catalog_factories from openedx.core.djangoapps.catalog.tests.factories import ProgramFactory
from openedx.core.djangoapps.credentials.models import CredentialsApiConfig from openedx.core.djangoapps.credentials.models import CredentialsApiConfig
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin, CredentialsDataMixin
from openedx.core.djangoapps.credentials.utils import ( from openedx.core.djangoapps.credentials.utils import (
...@@ -18,8 +18,6 @@ from openedx.core.djangoapps.credentials.utils import ( ...@@ -18,8 +18,6 @@ from openedx.core.djangoapps.credentials.utils import (
get_programs_for_credentials get_programs_for_credentials
) )
from openedx.core.djangoapps.credentials.tests import factories from openedx.core.djangoapps.credentials.tests import factories
from openedx.core.djangoapps.programs.tests.mixins import ProgramsApiConfigMixin, ProgramsDataMixin
from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
...@@ -37,7 +35,6 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ...@@ -37,7 +35,6 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin,
super(TestCredentialsRetrieval, self).setUp() super(TestCredentialsRetrieval, self).setUp()
ClientFactory(name=CredentialsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL) ClientFactory(name=CredentialsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
ClientFactory(name=ProgramsApiConfig.OAUTH2_CLIENT_NAME, client_type=CONFIDENTIAL)
self.user = UserFactory() self.user = UserFactory()
self.primary_uuid = str(uuid.uuid4()) self.primary_uuid = str(uuid.uuid4())
self.alternate_uuid = str(uuid.uuid4()) self.alternate_uuid = str(uuid.uuid4())
...@@ -129,7 +126,7 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ...@@ -129,7 +126,7 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin,
} }
self.mock_credentials_api(self.user, data=credentials_api_response, reset_url=False) self.mock_credentials_api(self.user, data=credentials_api_response, reset_url=False)
programs = [ programs = [
catalog_factories.Program(uuid=primary_uuid), catalog_factories.Program(uuid=alternate_uuid) ProgramFactory(uuid=primary_uuid), ProgramFactory(uuid=alternate_uuid)
] ]
with mock.patch("openedx.core.djangoapps.credentials.utils.get_programs_for_credentials") as mock_get_programs: with mock.patch("openedx.core.djangoapps.credentials.utils.get_programs_for_credentials") as mock_get_programs:
...@@ -165,7 +162,7 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ...@@ -165,7 +162,7 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin,
} }
self.mock_credentials_api(self.user, data=credentials_api_response, reset_url=False) self.mock_credentials_api(self.user, data=credentials_api_response, reset_url=False)
programs = [ programs = [
catalog_factories.Program(uuid=primary_uuid), catalog_factories.Program(uuid=alternate_uuid) ProgramFactory(uuid=primary_uuid), ProgramFactory(uuid=alternate_uuid)
] ]
with mock.patch("openedx.core.djangoapps.credentials.utils.get_programs") as mock_get_programs: with mock.patch("openedx.core.djangoapps.credentials.utils.get_programs") as mock_get_programs:
...@@ -199,14 +196,14 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ...@@ -199,14 +196,14 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin,
def test_get_program_for_certificates(self): def test_get_program_for_certificates(self):
"""Verify programs data can be retrieved and parsed correctly for certificates.""" """Verify programs data can be retrieved and parsed correctly for certificates."""
programs = [ programs = [
catalog_factories.Program(uuid=self.primary_uuid), ProgramFactory(uuid=self.primary_uuid),
catalog_factories.Program(uuid=self.alternate_uuid) ProgramFactory(uuid=self.alternate_uuid)
] ]
program_credentials_data = self._expected_program_credentials_data() program_credentials_data = self._expected_program_credentials_data()
with mock.patch("openedx.core.djangoapps.credentials.utils.get_programs") as patched_get_programs: with mock.patch("openedx.core.djangoapps.credentials.utils.get_programs") as patched_get_programs:
patched_get_programs.return_value = programs patched_get_programs.return_value = programs
actual = get_programs_for_credentials(self.user, program_credentials_data) actual = get_programs_for_credentials(program_credentials_data)
self.assertEqual(len(actual), 2) self.assertEqual(len(actual), 2)
self.assertEqual(actual, programs) self.assertEqual(actual, programs)
...@@ -216,6 +213,6 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin, ...@@ -216,6 +213,6 @@ class TestCredentialsRetrieval(CredentialsApiConfigMixin, CredentialsDataMixin,
program_credentials_data = self._expected_program_credentials_data() program_credentials_data = self._expected_program_credentials_data()
with mock.patch("openedx.core.djangoapps.credentials.utils.get_programs") as patched_get_programs: with mock.patch("openedx.core.djangoapps.credentials.utils.get_programs") as patched_get_programs:
patched_get_programs.return_value = [] patched_get_programs.return_value = []
actual = get_programs_for_credentials(self.user, program_credentials_data) actual = get_programs_for_credentials(program_credentials_data)
self.assertEqual(actual, []) self.assertEqual(actual, [])
...@@ -31,12 +31,11 @@ def get_user_credentials(user): ...@@ -31,12 +31,11 @@ def get_user_credentials(user):
return credentials return credentials
def get_programs_for_credentials(user, programs_credentials): def get_programs_for_credentials(programs_credentials):
""" Given a user and an iterable of credentials, get corresponding programs """ Given a user and an iterable of credentials, get corresponding programs
data and return it as a list of dictionaries. data and return it as a list of dictionaries.
Arguments: Arguments:
user (User): The user to authenticate as for requesting programs.
programs_credentials (list): List of credentials awarded to the user programs_credentials (list): List of credentials awarded to the user
for completion of a program. for completion of a program.
...@@ -44,7 +43,7 @@ def get_programs_for_credentials(user, programs_credentials): ...@@ -44,7 +43,7 @@ def get_programs_for_credentials(user, programs_credentials):
list, containing programs dictionaries. list, containing programs dictionaries.
""" """
certified_programs = [] certified_programs = []
programs = get_programs(user) programs = get_programs()
for program in programs: for program in programs:
for credential in programs_credentials: for credential in programs_credentials:
if program['uuid'] == credential['credential']['program_uuid']: if program['uuid'] == credential['credential']['program_uuid']:
...@@ -84,7 +83,7 @@ def get_user_program_credentials(user): ...@@ -84,7 +83,7 @@ def get_user_program_credentials(user):
log.exception('Invalid credential structure: %r', credential) log.exception('Invalid credential structure: %r', credential)
if programs_credentials: if programs_credentials:
programs_credentials_data = get_programs_for_credentials(user, programs_credentials) programs_credentials_data = get_programs_for_credentials(programs_credentials)
return programs_credentials_data return programs_credentials_data
......
...@@ -80,10 +80,10 @@ class Command(BaseCommand): ...@@ -80,10 +80,10 @@ class Command(BaseCommand):
course_runs = set() course_runs = set()
for program in programs: for program in programs:
for course in program['courses']: for course in program['courses']:
for run in course['course_runs']: for course_run in course['course_runs']:
key = CourseKey.from_string(run['key']) key = CourseKey.from_string(course_run['key'])
course_runs.add( course_runs.add(
CourseRun(key, run['type']) CourseRun(key, course_run['type'])
) )
return course_runs return course_runs
...@@ -97,14 +97,7 @@ class Command(BaseCommand): ...@@ -97,14 +97,7 @@ class Command(BaseCommand):
status_query = Q(status__in=CertificateStatuses.PASSED_STATUSES) status_query = Q(status__in=CertificateStatuses.PASSED_STATUSES)
course_run_query = reduce( course_run_query = reduce(
lambda x, y: x | y, lambda x, y: x | y,
# A course run's type is assumed to indicate which mode must be [Q(course_id=course_run.key, mode=course_run.type) for course_run in self.course_runs]
# completed in order for the run to count towards program completion.
# This supports the same flexible program construction allowed by the
# old programs service (e.g., completion of an old honor-only run may
# count towards completion of a course in a program). This may change
# in the future to make use of the more rigid set of "applicable seat
# types" associated with each program type in the catalog.
[Q(course_id=run.key, mode=run.type) for run in self.course_runs]
) )
query = status_query & course_run_query query = status_query & course_run_query
......
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('programs', '0009_programsapiconfig_marketing_path'),
]
operations = [
migrations.AlterField(
model_name='programsapiconfig',
name='internal_service_url',
field=models.URLField(verbose_name='Internal Service URL', blank=True),
),
migrations.AlterField(
model_name='programsapiconfig',
name='public_service_url',
field=models.URLField(verbose_name='Public Service URL', blank=True),
),
]
...@@ -19,8 +19,8 @@ class ProgramsApiConfig(ConfigurationModel): ...@@ -19,8 +19,8 @@ class ProgramsApiConfig(ConfigurationModel):
api_version_number = models.IntegerField(verbose_name=_("API Version")) api_version_number = models.IntegerField(verbose_name=_("API Version"))
internal_service_url = models.URLField(verbose_name=_("Internal Service URL")) internal_service_url = models.URLField(verbose_name=_("Internal Service URL"), blank=True)
public_service_url = models.URLField(verbose_name=_("Public Service URL")) public_service_url = models.URLField(verbose_name=_("Public Service URL"), blank=True)
marketing_path = models.CharField( marketing_path = models.CharField(
max_length=255, max_length=255,
......
...@@ -65,7 +65,7 @@ def get_completed_programs(student): ...@@ -65,7 +65,7 @@ def get_completed_programs(student):
list of program UUIDs list of program UUIDs
""" """
meter = ProgramProgressMeter(student, use_catalog=True) meter = ProgramProgressMeter(student)
return meter.completed_programs return meter.completed_programs
......
...@@ -6,24 +6,20 @@ import json ...@@ -6,24 +6,20 @@ import json
from celery.exceptions import MaxRetriesExceededError from celery.exceptions import MaxRetriesExceededError
import ddt import ddt
from django.conf import settings from django.conf import settings
from django.core.cache import cache
from django.test import override_settings, TestCase from django.test import override_settings, TestCase
from edx_rest_api_client.client import EdxRestApiClient from edx_rest_api_client.client import EdxRestApiClient
from edx_oauth2_provider.tests.factories import ClientFactory from edx_oauth2_provider.tests.factories import ClientFactory
import httpretty import httpretty
import mock import mock
from provider.constants import CONFIDENTIAL
from lms.djangoapps.certificates.api import MODES from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.catalog.tests import factories, mixins
from openedx.core.djangoapps.catalog.utils import munge_catalog_program
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangoapps.programs.tasks.v1 import tasks from openedx.core.djangoapps.programs.tasks.v1 import tasks
from openedx.core.djangolib.testing.utils import CacheIsolationTestCase, skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
from student.tests.factories import UserFactory from student.tests.factories import UserFactory
TASKS_MODULE = 'openedx.core.djangoapps.programs.tasks.v1.tasks' TASKS_MODULE = 'openedx.core.djangoapps.programs.tasks.v1.tasks'
UTILS_MODULE = 'openedx.core.djangoapps.programs.utils'
@skip_unless_lms @skip_unless_lms
...@@ -49,57 +45,6 @@ class GetApiClientTestCase(CredentialsApiConfigMixin, TestCase): ...@@ -49,57 +45,6 @@ class GetApiClientTestCase(CredentialsApiConfigMixin, TestCase):
self.assertEqual(api_client._store['session'].auth.token, 'test-token') # pylint: disable=protected-access self.assertEqual(api_client._store['session'].auth.token, 'test-token') # pylint: disable=protected-access
@httpretty.activate
@skip_unless_lms
class GetCompletedProgramsTestCase(mixins.CatalogIntegrationMixin, CacheIsolationTestCase):
"""
Test the get_completed_programs function
"""
ENABLED_CACHES = ['default']
def setUp(self):
super(GetCompletedProgramsTestCase, self).setUp()
self.user = UserFactory()
self.catalog_integration = self.create_catalog_integration(cache_ttl=1)
def _mock_programs_api(self, data):
"""Helper for mocking out Programs API URLs."""
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock API calls.')
url = self.catalog_integration.internal_api_url.strip('/') + '/programs/'
body = json.dumps({'results': data})
httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json')
def _assert_num_requests(self, count):
"""DRY helper for verifying request counts."""
self.assertEqual(len(httpretty.httpretty.latest_requests), count)
@mock.patch(UTILS_MODULE + '.get_completed_courses')
def test_get_completed_programs(self, mock_get_completed_courses):
"""
Verify that completed programs are found, using the cache when possible.
"""
data = [
factories.Program(),
]
self._mock_programs_api(data)
munged_program = munge_catalog_program(data[0])
course_codes = munged_program['course_codes']
mock_get_completed_courses.return_value = [
{'course_id': run_mode['course_key'], 'mode': run_mode['mode_slug']}
for run_mode in course_codes[0]['run_modes']
]
for _ in range(2):
result = tasks.get_completed_programs(self.user)
self.assertEqual(result[0], munged_program['id'])
# Verify that only one request to the catalog was made (i.e., the cache was hit).
self._assert_num_requests(1)
@skip_unless_lms @skip_unless_lms
class GetAwardedCertificateProgramsTestCase(TestCase): class GetAwardedCertificateProgramsTestCase(TestCase):
""" """
...@@ -175,7 +120,7 @@ class AwardProgramCertificateTestCase(TestCase): ...@@ -175,7 +120,7 @@ class AwardProgramCertificateTestCase(TestCase):
@mock.patch(TASKS_MODULE + '.get_certified_programs') @mock.patch(TASKS_MODULE + '.get_certified_programs')
@mock.patch(TASKS_MODULE + '.get_completed_programs') @mock.patch(TASKS_MODULE + '.get_completed_programs')
@override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username') @override_settings(CREDENTIALS_SERVICE_USERNAME='test-service-username')
class AwardProgramCertificatesTestCase(mixins.CatalogIntegrationMixin, CredentialsApiConfigMixin, TestCase): class AwardProgramCertificatesTestCase(CatalogIntegrationMixin, CredentialsApiConfigMixin, TestCase):
""" """
Tests for the 'award_program_certificates' celery task. Tests for the 'award_program_certificates' celery task.
""" """
......
"""Factories for generating fake program-related data.""" """Factories for generating fake program-related data."""
# pylint: disable=missing-docstring, invalid-name
import factory import factory
from factory.fuzzy import FuzzyText from faker import Faker
class Program(factory.Factory): fake = Faker()
"""
Factory for stubbing program resources from the Programs API (v1).
"""
class Meta(object):
model = dict
id = factory.Sequence(lambda n: n) # pylint: disable=invalid-name
name = FuzzyText(prefix='Program ')
subtitle = FuzzyText(prefix='Subtitle ')
category = 'FooBar'
status = 'active'
marketing_slug = FuzzyText(prefix='slug_')
organizations = []
course_codes = []
banner_image_urls = {}
class Organization(factory.Factory):
"""
Factory for stubbing nested organization resources from the Programs API (v1).
"""
class Meta(object):
model = dict
key = FuzzyText(prefix='org_')
display_name = FuzzyText(prefix='Display Name ')
class CourseCode(factory.Factory):
"""
Factory for stubbing nested course code resources from the Programs API (v1).
"""
class Meta(object):
model = dict
display_name = FuzzyText(prefix='Display Name ')
run_modes = []
class RunMode(factory.Factory):
"""
Factory for stubbing nested run mode resources from the Programs API (v1).
"""
class Meta(object):
model = dict
course_key = FuzzyText(prefix='org/', suffix='/run')
mode_slug = 'verified'
class Progress(factory.Factory): class ProgressFactory(factory.Factory):
"""
Factory for stubbing program progress dicts.
"""
class Meta(object): class Meta(object):
model = dict model = dict
id = factory.Sequence(lambda n: n) # pylint: disable=invalid-name uuid = factory.Faker('uuid4')
completed = [] completed = []
in_progress = [] in_progress = []
not_started = [] not_started = []
"""Mixins for use during testing.""" """Mixins for use during testing."""
import json
import httpretty
from openedx.core.djangoapps.programs.models import ProgramsApiConfig from openedx.core.djangoapps.programs.models import ProgramsApiConfig
from openedx.core.djangoapps.programs.tests import factories
class ProgramsApiConfigMixin(object): class ProgramsApiConfigMixin(object):
...@@ -29,87 +24,3 @@ class ProgramsApiConfigMixin(object): ...@@ -29,87 +24,3 @@ class ProgramsApiConfigMixin(object):
ProgramsApiConfig(**fields).save() ProgramsApiConfig(**fields).save()
return ProgramsApiConfig.current() return ProgramsApiConfig.current()
class ProgramsDataMixin(object):
"""Mixin mocking Programs API URLs and providing fake data for testing.
NOTE: This mixin is DEPRECATED. Tests should create and manage their own data.
"""
PROGRAM_NAMES = [
'Test Program A',
'Test Program B',
'Test Program C',
]
COURSE_KEYS = [
'organization-a/course-a/fall',
'organization-a/course-a/winter',
'organization-a/course-b/fall',
'organization-a/course-b/winter',
'organization-b/course-c/fall',
'organization-b/course-c/winter',
'organization-b/course-d/fall',
'organization-b/course-d/winter',
]
PROGRAMS_API_RESPONSE = {
'results': [
factories.Program(
id=1,
name=PROGRAM_NAMES[0],
organizations=[factories.Organization()],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=COURSE_KEYS[0]),
factories.RunMode(course_key=COURSE_KEYS[1]),
]),
factories.CourseCode(run_modes=[
factories.RunMode(course_key=COURSE_KEYS[2]),
factories.RunMode(course_key=COURSE_KEYS[3]),
]),
]
),
factories.Program(
id=2,
name=PROGRAM_NAMES[1],
organizations=[factories.Organization()],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=COURSE_KEYS[4]),
factories.RunMode(course_key=COURSE_KEYS[5]),
]),
factories.CourseCode(run_modes=[
factories.RunMode(course_key=COURSE_KEYS[6]),
factories.RunMode(course_key=COURSE_KEYS[7]),
]),
]
),
factories.Program(
id=3,
name=PROGRAM_NAMES[2],
organizations=[factories.Organization()],
course_codes=[
factories.CourseCode(run_modes=[
factories.RunMode(course_key=COURSE_KEYS[7]),
]),
]
),
]
}
def mock_programs_api(self, data=None, program_id='', status_code=200):
"""Utility for mocking out Programs API URLs."""
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
url = ProgramsApiConfig.current().internal_api_url.strip('/') + '/programs/'
if program_id:
url += '{}/'.format(str(program_id))
if data is None:
data = self.PROGRAMS_API_RESPONSE
body = json.dumps(data)
httpretty.reset()
httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json', status=status_code)
...@@ -7,7 +7,12 @@ import mock ...@@ -7,7 +7,12 @@ import mock
from certificates.models import CertificateStatuses # pylint: disable=import-error from certificates.models import CertificateStatuses # pylint: disable=import-error
from lms.djangoapps.certificates.api import MODES from lms.djangoapps.certificates.api import MODES
from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory from lms.djangoapps.certificates.tests.factories import GeneratedCertificateFactory
from openedx.core.djangoapps.catalog.tests import factories from openedx.core.djangoapps.catalog.tests.factories import (
generate_course_run_key,
ProgramFactory,
CourseFactory,
CourseRunFactory,
)
from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin from openedx.core.djangoapps.catalog.tests.mixins import CatalogIntegrationMixin
from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin from openedx.core.djangoapps.credentials.tests.mixins import CredentialsApiConfigMixin
from openedx.core.djangolib.testing.utils import skip_unless_lms from openedx.core.djangolib.testing.utils import skip_unless_lms
...@@ -23,7 +28,7 @@ COMMAND_MODULE = 'openedx.core.djangoapps.programs.management.commands.backpopul ...@@ -23,7 +28,7 @@ COMMAND_MODULE = 'openedx.core.djangoapps.programs.management.commands.backpopul
@skip_unless_lms @skip_unless_lms
class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsApiConfigMixin, TestCase): class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsApiConfigMixin, TestCase):
"""Tests for the backpopulate_program_credentials management command.""" """Tests for the backpopulate_program_credentials management command."""
course_run_key, alternate_course_run_key = (factories.generate_course_run_key() for __ in range(2)) course_run_key, alternate_course_run_key = (generate_course_run_key() for __ in range(2))
def setUp(self): def setUp(self):
super(BackpopulateProgramCredentialsTests, self).setUp() super(BackpopulateProgramCredentialsTests, self).setUp()
...@@ -36,8 +41,8 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp ...@@ -36,8 +41,8 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp
# skewing mock call counts. # skewing mock call counts.
self.create_credentials_config(enable_learner_issuance=False) self.create_credentials_config(enable_learner_issuance=False)
self.catalog_integration = self.create_catalog_integration() catalog_integration = self.create_catalog_integration()
self.service_user = UserFactory(username=self.catalog_integration.service_username) UserFactory(username=catalog_integration.service_username)
@ddt.data(True, False) @ddt.data(True, False)
def test_handle(self, commit, mock_task, mock_get_programs): def test_handle(self, commit, mock_task, mock_get_programs):
...@@ -45,10 +50,10 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp ...@@ -45,10 +50,10 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp
Verify that relevant tasks are only enqueued when the commit option is passed. Verify that relevant tasks are only enqueued when the commit option is passed.
""" """
data = [ data = [
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=self.course_run_key), CourseRunFactory(key=self.course_run_key),
]), ]),
] ]
), ),
...@@ -78,39 +83,39 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp ...@@ -78,39 +83,39 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp
@ddt.data( @ddt.data(
[ [
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=course_run_key), CourseRunFactory(key=course_run_key),
]), ]),
] ]
), ),
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=alternate_course_run_key), CourseRunFactory(key=alternate_course_run_key),
]), ]),
] ]
), ),
], ],
[ [
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=course_run_key), CourseRunFactory(key=course_run_key),
]), ]),
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=alternate_course_run_key), CourseRunFactory(key=alternate_course_run_key),
]), ]),
] ]
), ),
], ],
[ [
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=course_run_key), CourseRunFactory(key=course_run_key),
factories.CourseRun(key=alternate_course_run_key), CourseRunFactory(key=alternate_course_run_key),
]), ]),
] ]
), ),
...@@ -148,11 +153,11 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp ...@@ -148,11 +153,11 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp
course run certificates. course run certificates.
""" """
data = [ data = [
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=self.course_run_key), CourseRunFactory(key=self.course_run_key),
factories.CourseRun(key=self.alternate_course_run_key), CourseRunFactory(key=self.alternate_course_run_key),
]), ]),
] ]
), ),
...@@ -183,10 +188,10 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp ...@@ -183,10 +188,10 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp
qualifying course run certificates. qualifying course run certificates.
""" """
data = [ data = [
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=self.course_run_key, type='honor'), CourseRunFactory(key=self.course_run_key, type='honor'),
]), ]),
] ]
), ),
...@@ -216,10 +221,10 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp ...@@ -216,10 +221,10 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp
Verify that only course run certificates with a passing status are selected. Verify that only course run certificates with a passing status are selected.
""" """
data = [ data = [
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=self.course_run_key), CourseRunFactory(key=self.course_run_key),
]), ]),
] ]
), ),
...@@ -261,10 +266,10 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp ...@@ -261,10 +266,10 @@ class BackpopulateProgramCredentialsTests(CatalogIntegrationMixin, CredentialsAp
mock_task.side_effect = side_effect mock_task.side_effect = side_effect
data = [ data = [
factories.Program( ProgramFactory(
courses=[ courses=[
factories.Course(course_runs=[ CourseFactory(course_runs=[
factories.CourseRun(key=self.course_run_key), CourseRunFactory(key=self.course_run_key),
]), ]),
] ]
), ),
......
...@@ -111,11 +111,6 @@ class Env(object): ...@@ -111,11 +111,6 @@ class Env(object):
'log': BOK_CHOY_LOG_DIR / "bok_choy_ecommerce.log", 'log': BOK_CHOY_LOG_DIR / "bok_choy_ecommerce.log",
}, },
'programs': {
'port': 8090,
'log': BOK_CHOY_LOG_DIR / "bok_choy_programs.log",
},
'catalog': { 'catalog': {
'port': 8091, 'port': 8091,
'log': BOK_CHOY_LOG_DIR / "bok_choy_catalog.log", 'log': BOK_CHOY_LOG_DIR / "bok_choy_catalog.log",
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment