Commit 0a84b65d by Ibrahim Ahmed

Merge branch 'master' into ibrahimahmed443/MAYN-280-explore-new-courses-button-fix

parents 8c9ad0d3 920fba50
...@@ -94,7 +94,8 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -94,7 +94,8 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
url = reverse('course_modes_choose', args=[unicode(self.course.id)]) url = reverse('course_modes_choose', args=[unicode(self.course.id)])
response = self.client.get(url) response = self.client.get(url)
# Check whether we were correctly redirected # Check whether we were correctly redirected
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) purchase_workflow = "?purchase_workflow=single"
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) + purchase_workflow
self.assertRedirects(response, start_flow_url) self.assertRedirects(response, start_flow_url)
def test_no_id_redirect_otto(self): def test_no_id_redirect_otto(self):
...@@ -194,7 +195,8 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase): ...@@ -194,7 +195,8 @@ class CourseModeViewTest(UrlResetMixin, ModuleStoreTestCase):
# Since the only available track is professional ed, expect that # Since the only available track is professional ed, expect that
# we're redirected immediately to the start of the payment flow. # we're redirected immediately to the start of the payment flow.
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) purchase_workflow = "?purchase_workflow=single"
start_flow_url = reverse('verify_student_start_flow', args=[unicode(self.course.id)]) + purchase_workflow
self.assertRedirects(response, start_flow_url) self.assertRedirects(response, start_flow_url)
# Now enroll in the course # Now enroll in the course
......
...@@ -88,12 +88,15 @@ class ChooseModeView(View): ...@@ -88,12 +88,15 @@ class ChooseModeView(View):
# If there are both modes, default to non-id-professional. # If there are both modes, default to non-id-professional.
has_enrolled_professional = (CourseMode.is_professional_slug(enrollment_mode) and is_active) has_enrolled_professional = (CourseMode.is_professional_slug(enrollment_mode) and is_active)
if CourseMode.has_professional_mode(modes) and not has_enrolled_professional: if CourseMode.has_professional_mode(modes) and not has_enrolled_professional:
redirect_url = reverse('verify_student_start_flow', kwargs={'course_id': unicode(course_key)}) purchase_workflow = request.GET.get("purchase_workflow", "single")
verify_url = reverse('verify_student_start_flow', kwargs={'course_id': unicode(course_key)})
redirect_url = "{url}?purchase_workflow={workflow}".format(url=verify_url, workflow=purchase_workflow)
if ecommerce_service.is_enabled(request.user): if ecommerce_service.is_enabled(request.user):
professional_mode = modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE) or modes.get(CourseMode.PROFESSIONAL) professional_mode = modes.get(CourseMode.NO_ID_PROFESSIONAL_MODE) or modes.get(CourseMode.PROFESSIONAL)
if professional_mode.sku: if purchase_workflow == "single" and professional_mode.sku:
redirect_url = ecommerce_service.checkout_page_url(professional_mode.sku) redirect_url = ecommerce_service.checkout_page_url(professional_mode.sku)
if purchase_workflow == "bulk" and professional_mode.bulk_sku:
redirect_url = ecommerce_service.checkout_page_url(professional_mode.bulk_sku)
return redirect(redirect_url) return redirect(redirect_url)
# If there isn't a verified mode available, then there's nothing # If there isn't a verified mode available, then there's nothing
......
...@@ -200,7 +200,7 @@ def auth_pipeline_urls(auth_entry, redirect_url=None): ...@@ -200,7 +200,7 @@ def auth_pipeline_urls(auth_entry, redirect_url=None):
# Query string parameters that can be passed to the "finish_auth" view to manage # Query string parameters that can be passed to the "finish_auth" view to manage
# things like auto-enrollment. # things like auto-enrollment.
POST_AUTH_PARAMS = ('course_id', 'enrollment_action', 'course_mode', 'email_opt_in') POST_AUTH_PARAMS = ('course_id', 'enrollment_action', 'course_mode', 'email_opt_in', 'purchase_workflow')
def get_next_url_for_login_page(request): def get_next_url_for_login_page(request):
......
...@@ -44,13 +44,17 @@ class StaticContent(object): ...@@ -44,13 +44,17 @@ class StaticContent(object):
return self.location.category == 'thumbnail' return self.location.category == 'thumbnail'
@staticmethod @staticmethod
def generate_thumbnail_name(original_name, dimensions=None): def generate_thumbnail_name(original_name, dimensions=None, extension=None):
""" """
- original_name: Name of the asset (typically its location.name) - original_name: Name of the asset (typically its location.name)
- dimensions: `None` or a tuple of (width, height) in pixels - dimensions: `None` or a tuple of (width, height) in pixels
- extension: `None` or desired filename extension of the thumbnail
""" """
if extension is None:
extension = XASSET_THUMBNAIL_TAIL_NAME
name_root, ext = os.path.splitext(original_name) name_root, ext = os.path.splitext(original_name)
if not ext == XASSET_THUMBNAIL_TAIL_NAME: if not ext == extension:
name_root = name_root + ext.replace(u'.', u'-') name_root = name_root + ext.replace(u'.', u'-')
if dimensions: if dimensions:
...@@ -59,7 +63,7 @@ class StaticContent(object): ...@@ -59,7 +63,7 @@ class StaticContent(object):
return u"{name_root}{extension}".format( return u"{name_root}{extension}".format(
name_root=name_root, name_root=name_root,
extension=XASSET_THUMBNAIL_TAIL_NAME, extension=extension,
) )
@staticmethod @staticmethod
...@@ -330,9 +334,10 @@ class ContentStore(object): ...@@ -330,9 +334,10 @@ class ContentStore(object):
pixels. It defaults to None. pixels. It defaults to None.
""" """
thumbnail_content = None thumbnail_content = None
is_svg = content.content_type == 'image/svg+xml'
# use a naming convention to associate originals with the thumbnail # use a naming convention to associate originals with the thumbnail
thumbnail_name = StaticContent.generate_thumbnail_name( thumbnail_name = StaticContent.generate_thumbnail_name(
content.location.name, dimensions=dimensions content.location.name, dimensions=dimensions, extension='.svg' if is_svg else None
) )
thumbnail_file_location = StaticContent.compute_location( thumbnail_file_location = StaticContent.compute_location(
content.location.course_key, thumbnail_name, is_thumbnail=True content.location.course_key, thumbnail_name, is_thumbnail=True
...@@ -340,27 +345,41 @@ class ContentStore(object): ...@@ -340,27 +345,41 @@ class ContentStore(object):
# if we're uploading an image, then let's generate a thumbnail so that we can # if we're uploading an image, then let's generate a thumbnail so that we can
# serve it up when needed without having to rescale on the fly # serve it up when needed without having to rescale on the fly
if content.content_type is not None and content.content_type.split('/')[0] == 'image':
try: try:
if is_svg:
# for svg simply store the provided svg file, since vector graphics should be good enough
# for downscaling client-side
if tempfile_path is None:
thumbnail_file = StringIO.StringIO(content.data)
else:
with open(tempfile_path) as f:
thumbnail_file = StringIO.StringIO(f.read())
thumbnail_content = StaticContent(thumbnail_file_location, thumbnail_name,
'image/svg+xml', thumbnail_file)
self.save(thumbnail_content)
elif content.content_type is not None and content.content_type.split('/')[0] == 'image':
# use PIL to do the thumbnail generation (http://www.pythonware.com/products/pil/) # use PIL to do the thumbnail generation (http://www.pythonware.com/products/pil/)
# My understanding is that PIL will maintain aspect ratios while restricting # My understanding is that PIL will maintain aspect ratios while restricting
# the max-height/width to be whatever you pass in as 'size' # the max-height/width to be whatever you pass in as 'size'
# @todo: move the thumbnail size to a configuration setting?!? # @todo: move the thumbnail size to a configuration setting?!?
if tempfile_path is None: if tempfile_path is None:
im = Image.open(StringIO.StringIO(content.data)) source = StringIO.StringIO(content.data)
else: else:
im = Image.open(tempfile_path) source = tempfile_path
# We use the context manager here to avoid leaking the inner file descriptor
# of the Image object -- this way it gets closed after we're done with using it.
thumbnail_file = StringIO.StringIO()
with Image.open(source) as image:
# I've seen some exceptions from the PIL library when trying to save palletted # I've seen some exceptions from the PIL library when trying to save palletted
# PNG files to JPEG. Per the google-universe, they suggest converting to RGB first. # PNG files to JPEG. Per the google-universe, they suggest converting to RGB first.
im = im.convert('RGB') thumbnail_image = image.convert('RGB')
if not dimensions: if not dimensions:
dimensions = (128, 128) dimensions = (128, 128)
im.thumbnail(dimensions, Image.ANTIALIAS) thumbnail_image.thumbnail(dimensions, Image.ANTIALIAS)
thumbnail_file = StringIO.StringIO() thumbnail_image.save(thumbnail_file, 'JPEG')
im.save(thumbnail_file, 'JPEG')
thumbnail_file.seek(0) thumbnail_file.seek(0)
# store this thumbnail as any other piece of content # store this thumbnail as any other piece of content
...@@ -369,9 +388,11 @@ class ContentStore(object): ...@@ -369,9 +388,11 @@ class ContentStore(object):
self.save(thumbnail_content) self.save(thumbnail_content)
except Exception, e: except Exception, exc: # pylint: disable=broad-except
# log and continue as thumbnails are generally considered as optional # log and continue as thumbnails are generally considered as optional
logging.exception(u"Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(e))) logging.exception(
u"Failed to generate thumbnail for {0}. Exception: {1}".format(content.location, str(exc))
)
return thumbnail_content, thumbnail_file_location return thumbnail_content, thumbnail_file_location
......
...@@ -3,6 +3,7 @@ ...@@ -3,6 +3,7 @@
import os import os
import unittest import unittest
import ddt import ddt
from mock import Mock, patch
from path import Path as path from path import Path as path
from xmodule.contentstore.content import StaticContent, StaticContentStream from xmodule.contentstore.content import StaticContent, StaticContentStream
...@@ -58,6 +59,7 @@ class Content(object): ...@@ -58,6 +59,7 @@ class Content(object):
def __init__(self, location, content_type): def __init__(self, location, content_type):
self.location = location self.location = location
self.content_type = content_type self.content_type = content_type
self.data = None
class FakeGridFsItem(object): class FakeGridFsItem(object):
...@@ -84,6 +86,17 @@ class FakeGridFsItem(object): ...@@ -84,6 +86,17 @@ class FakeGridFsItem(object):
return chunk return chunk
class MockImage(Mock):
"""
This class pretends to be PIL.Image for purposes of thumbnails testing.
"""
def __enter__(self):
return self
def __exit__(self, *args):
self.close()
@ddt.ddt @ddt.ddt
class ContentTest(unittest.TestCase): class ContentTest(unittest.TestCase):
def test_thumbnail_none(self): def test_thumbnail_none(self):
...@@ -103,11 +116,43 @@ class ContentTest(unittest.TestCase): ...@@ -103,11 +116,43 @@ class ContentTest(unittest.TestCase):
) )
@ddt.unpack @ddt.unpack
def test_generate_thumbnail_image(self, original_filename, thumbnail_filename): def test_generate_thumbnail_image(self, original_filename, thumbnail_filename):
contentStore = ContentStore() content_store = ContentStore()
content = Content(AssetLocation(u'mitX', u'800', u'ignore_run', u'asset', original_filename), None) content = Content(AssetLocation(u'mitX', u'800', u'ignore_run', u'asset', original_filename), None)
(thumbnail_content, thumbnail_file_location) = contentStore.generate_thumbnail(content) (thumbnail_content, thumbnail_file_location) = content_store.generate_thumbnail(content)
self.assertIsNone(thumbnail_content) self.assertIsNone(thumbnail_content)
self.assertEqual(AssetLocation(u'mitX', u'800', u'ignore_run', u'thumbnail', thumbnail_filename), thumbnail_file_location) self.assertEqual(
AssetLocation(u'mitX', u'800', u'ignore_run', u'thumbnail', thumbnail_filename),
thumbnail_file_location
)
@patch('xmodule.contentstore.content.Image')
def test_image_is_closed_when_generating_thumbnail(self, image_class_mock):
# We used to keep the Image's file descriptor open when generating a thumbnail.
# It should be closed after being used.
mock_image = MockImage()
image_class_mock.open.return_value = mock_image
content_store = ContentStore()
content = Content(AssetLocation(u'mitX', u'800', u'ignore_run', u'asset', "monsters.jpg"), "image/jpeg")
content.data = 'mock data'
content_store.generate_thumbnail(content)
self.assertTrue(image_class_mock.open.called, "Image.open not called")
self.assertTrue(mock_image.close.called, "mock_image.close not called")
def test_store_svg_as_thumbnail(self):
# We had a bug that caused generate_thumbnail to attempt to pass SVG to PIL to generate a thumbnail.
# SVG files should be stored in original form for thumbnail purposes.
content_store = ContentStore()
content_store.save = Mock()
thumbnail_filename = u'test.svg'
content = Content(AssetLocation(u'mitX', u'800', u'ignore_run', u'asset', u'test.svg'), 'image/svg+xml')
content.data = 'mock svg file'
(thumbnail_content, thumbnail_file_location) = content_store.generate_thumbnail(content)
self.assertEqual(thumbnail_content.data.read(), b'mock svg file')
self.assertEqual(
AssetLocation(u'mitX', u'800', u'ignore_run', u'thumbnail', thumbnail_filename),
thumbnail_file_location
)
def test_compute_location(self): def test_compute_location(self):
# We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space) # We had a bug that __ got converted into a single _. Make sure that substitution of INVALID_CHARS (like space)
...@@ -115,7 +160,10 @@ class ContentTest(unittest.TestCase): ...@@ -115,7 +160,10 @@ class ContentTest(unittest.TestCase):
asset_location = StaticContent.compute_location( asset_location = StaticContent.compute_location(
SlashSeparatedCourseKey('mitX', '400', 'ignore'), 'subs__1eo_jXvZnE .srt.sjson' SlashSeparatedCourseKey('mitX', '400', 'ignore'), 'subs__1eo_jXvZnE .srt.sjson'
) )
self.assertEqual(AssetLocation(u'mitX', u'400', u'ignore', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None), asset_location) self.assertEqual(
AssetLocation(u'mitX', u'400', u'ignore', u'asset', u'subs__1eo_jXvZnE_.srt.sjson', None),
asset_location
)
def test_get_location_from_path(self): def test_get_location_from_path(self):
asset_location = StaticContent.get_location_from_path(u'/c4x/a/b/asset/images_course_image.jpg') asset_location = StaticContent.get_location_from_path(u'/c4x/a/b/asset/images_course_image.jpg')
......
...@@ -758,6 +758,14 @@ class VideoExportTestCase(VideoDescriptorTestBase): ...@@ -758,6 +758,14 @@ class VideoExportTestCase(VideoDescriptorTestBase):
with self.assertRaises(ValueError): with self.assertRaises(ValueError):
self.descriptor.definition_to_xml(None) self.descriptor.definition_to_xml(None)
def test_export_to_xml_unicode_characters(self):
"""
Test XML export handles the unicode characters.
"""
self.descriptor.display_name = '这是文'
xml = self.descriptor.definition_to_xml(None)
self.assertEqual(xml.get('display_name'), u'\u8fd9\u662f\u6587')
class VideoDescriptorIndexingTestCase(unittest.TestCase): class VideoDescriptorIndexingTestCase(unittest.TestCase):
""" """
......
...@@ -38,7 +38,7 @@ from xmodule.exceptions import NotFoundError ...@@ -38,7 +38,7 @@ from xmodule.exceptions import NotFoundError
from xmodule.contentstore.content import StaticContent from xmodule.contentstore.content import StaticContent
from .transcripts_utils import VideoTranscriptsMixin, Transcript, get_html5_ids from .transcripts_utils import VideoTranscriptsMixin, Transcript, get_html5_ids
from .video_utils import create_youtube_string, get_poster, rewrite_video_url from .video_utils import create_youtube_string, get_poster, rewrite_video_url, format_xml_exception_message
from .bumper_utils import bumperize from .bumper_utils import bumperize
from .video_xfields import VideoFields from .video_xfields import VideoFields
from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers from .video_handlers import VideoStudentViewHandlers, VideoStudioViewHandlers
...@@ -563,14 +563,16 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler ...@@ -563,14 +563,16 @@ class VideoDescriptor(VideoFields, VideoTranscriptsMixin, VideoStudioViewHandler
if key in self.fields and self.fields[key].is_set_on(self): if key in self.fields and self.fields[key].is_set_on(self):
try: try:
xml.set(key, unicode(value)) xml.set(key, unicode(value))
except ValueError as exception: except UnicodeDecodeError:
exception_message = "{message}, Block-location:{location}, Key:{key}, Value:{value}".format( exception_message = format_xml_exception_message(self.location, key, value)
message=exception.message, log.exception(exception_message)
location=unicode(self.location), # If exception is UnicodeDecodeError set value using unicode 'utf-8' scheme.
key=key, log.info("Setting xml value using 'utf-8' scheme.")
value=unicode(value) xml.set(key, unicode(value, 'utf-8'))
) except ValueError:
raise ValueError(exception_message) exception_message = format_xml_exception_message(self.location, key, value)
log.exception(exception_message)
raise
for source in self.html5_sources: for source in self.html5_sources:
ele = etree.Element('source') ele = etree.Element('source')
......
...@@ -98,6 +98,19 @@ def get_poster(video): ...@@ -98,6 +98,19 @@ def get_poster(video):
return poster return poster
def format_xml_exception_message(location, key, value):
"""
Generate exception message for VideoDescriptor class which will use for ValueError and UnicodeDecodeError
when setting xml attributes.
"""
exception_message = "Block-location:{location}, Key:{key}, Value:{value}".format(
location=unicode(location),
key=key,
value=value
)
return exception_message
def set_query_parameter(url, param_name, param_value): def set_query_parameter(url, param_name, param_value):
""" """
Given a URL, set or replace a query parameter and return the Given a URL, set or replace a query parameter and return the
......
...@@ -4,8 +4,9 @@ End-to-end tests for Student's Profile Page. ...@@ -4,8 +4,9 @@ End-to-end tests for Student's Profile Page.
""" """
from contextlib import contextmanager from contextlib import contextmanager
from datetime import datetime
from bok_choy.web_app_test import WebAppTest from bok_choy.web_app_test import WebAppTest
from datetime import datetime
from flaky import flaky
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from ...pages.common.logout import LogoutPage from ...pages.common.logout import LogoutPage
...@@ -300,6 +301,7 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest): ...@@ -300,6 +301,7 @@ class OwnLearnerProfilePageTest(LearnerProfileTestMixin, WebAppTest):
self.verify_profile_page_is_private(profile_page) self.verify_profile_page_is_private(profile_page)
self.verify_profile_page_view_event(username, user_id, visibility=self.PRIVACY_PRIVATE) self.verify_profile_page_view_event(username, user_id, visibility=self.PRIVACY_PRIVATE)
@flaky # TODO fix this, see TNL-4683
def test_fields_on_my_public_profile(self): def test_fields_on_my_public_profile(self):
""" """
Scenario: Verify that desired fields are shown when looking at her own public profile. Scenario: Verify that desired fields are shown when looking at her own public profile.
......
"""Acceptance tests for LMS-hosted Programs pages""" """Acceptance tests for LMS-hosted Programs pages"""
from unittest import skip
from nose.plugins.attrib import attr from nose.plugins.attrib import attr
from ...fixtures.programs import ProgramsFixture, ProgramsConfigMixin from ...fixtures.programs import ProgramsFixture, ProgramsConfigMixin
...@@ -139,7 +137,6 @@ class ProgramListingPageA11yTest(ProgramPageBase): ...@@ -139,7 +137,6 @@ class ProgramListingPageA11yTest(ProgramPageBase):
@attr('a11y') @attr('a11y')
@skip('The tested page is currently disabled. This test will be re-enabled once a11y failures are resolved.')
class ProgramDetailsPageA11yTest(ProgramPageBase): class ProgramDetailsPageA11yTest(ProgramPageBase):
"""Test program details page accessibility.""" """Test program details page accessibility."""
def setUp(self): def setUp(self):
......
...@@ -1240,8 +1240,15 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red ...@@ -1240,8 +1240,15 @@ def get_students_features(request, course_id, csv=False): # pylint: disable=red
available_features = instructor_analytics.basic.AVAILABLE_FEATURES available_features = instructor_analytics.basic.AVAILABLE_FEATURES
# Allow for microsites to be able to define additional columns (e.g. ) # Allow for microsites to be able to define additional columns.
query_features = microsite.get_value('student_profile_download_fields') # Note that adding additional columns has the potential to break
# the student profile report due to a character limit on the
# asynchronous job input which in this case is a JSON string
# containing the list of columns to include in the report.
# TODO: Refactor the student profile report code to remove the list of columns
# that should be included in the report from the asynchronous job input.
# We need to clone the list because we modify it below
query_features = list(microsite.get_value('student_profile_download_fields', []))
if not query_features: if not query_features:
query_features = [ query_features = [
......
...@@ -337,7 +337,7 @@ def submit_calculate_students_features_csv(request, course_key, features): ...@@ -337,7 +337,7 @@ def submit_calculate_students_features_csv(request, course_key, features):
""" """
task_type = 'profile_info_csv' task_type = 'profile_info_csv'
task_class = calculate_students_features_csv task_class = calculate_students_features_csv
task_input = {'features': features} task_input = features
task_key = "" task_key = ""
return submit_task(request, task_type, task_class, course_key, task_input, task_key) return submit_task(request, task_type, task_class, course_key, task_input, task_key)
......
...@@ -996,7 +996,7 @@ def upload_students_csv(_xmodule_instance_args, _entry_id, course_id, task_input ...@@ -996,7 +996,7 @@ def upload_students_csv(_xmodule_instance_args, _entry_id, course_id, task_input
task_progress.update_task_state(extra_meta=current_step) task_progress.update_task_state(extra_meta=current_step)
# compute the student features table and format it # compute the student features table and format it
query_features = task_input.get('features') query_features = task_input
student_data = enrolled_students_features(course_id, query_features) student_data = enrolled_students_features(course_id, query_features)
header, rows = format_dictlist(student_data, query_features) header, rows = format_dictlist(student_data, query_features)
......
...@@ -261,7 +261,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase): ...@@ -261,7 +261,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
course_codes=[self.course_code] course_codes=[self.course_code]
) )
def _mock_programs_api(self): def _mock_programs_api(self, data, status=200):
"""Helper for mocking out Programs API URLs.""" """Helper for mocking out Programs API URLs."""
self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.') self.assertTrue(httpretty.is_enabled(), msg='httpretty must be enabled to mock Programs API calls.')
...@@ -269,9 +269,16 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase): ...@@ -269,9 +269,16 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
api_root=ProgramsApiConfig.current().internal_api_url.strip('/'), api_root=ProgramsApiConfig.current().internal_api_url.strip('/'),
id=self.program_id id=self.program_id
) )
body = json.dumps(self.data)
httpretty.register_uri(httpretty.GET, url, body=body, content_type='application/json') body = json.dumps(data)
httpretty.register_uri(
httpretty.GET,
url,
body=body,
status=status,
content_type='application/json',
)
def _assert_program_data_present(self, response): def _assert_program_data_present(self, response):
"""Verify that program data is present.""" """Verify that program data is present."""
...@@ -283,7 +290,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase): ...@@ -283,7 +290,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
Verify that login is required to access the page. Verify that login is required to access the page.
""" """
self.create_programs_config() self.create_programs_config()
self._mock_programs_api() self._mock_programs_api(self.data)
self.client.logout() self.client.logout()
...@@ -310,7 +317,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase): ...@@ -310,7 +317,7 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
def test_page_routing(self): def test_page_routing(self):
"""Verify that the page can be hit with or without a program name in the URL.""" """Verify that the page can be hit with or without a program name in the URL."""
self.create_programs_config() self.create_programs_config()
self._mock_programs_api() self._mock_programs_api(self.data)
response = self.client.get(self.details_page) response = self.client.get(self.details_page)
self._assert_program_data_present(response) self._assert_program_data_present(response)
...@@ -320,3 +327,17 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase): ...@@ -320,3 +327,17 @@ class TestProgramDetails(ProgramsApiConfigMixin, SharedModuleStoreTestCase):
response = self.client.get(self.details_page + 'program_name/invalid/') response = self.client.get(self.details_page + 'program_name/invalid/')
self.assertEquals(response.status_code, 404) self.assertEquals(response.status_code, 404)
def test_404_if_no_data(self):
"""Verify that the page 404s if no program data is found."""
self.create_programs_config()
self._mock_programs_api(self.data, status=404)
response = self.client.get(self.details_page)
self.assertEquals(response.status_code, 404)
httpretty.reset()
self._mock_programs_api({})
response = self.client.get(self.details_page)
self.assertEquals(response.status_code, 404)
...@@ -57,6 +57,10 @@ def program_details(request, program_id): ...@@ -57,6 +57,10 @@ def program_details(request, program_id):
raise Http404 raise Http404
program_data = utils.get_programs(request.user, program_id=program_id) program_data = utils.get_programs(request.user, program_id=program_id)
if not program_data:
raise Http404
program_data = utils.supplement_program_data(program_data, request.user) program_data = utils.supplement_program_data(program_data, request.user)
context = { context = {
......
...@@ -190,6 +190,7 @@ def show_cart(request): ...@@ -190,6 +190,7 @@ def show_cart(request):
'form_html': form_html, 'form_html': form_html,
'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1], 'currency_symbol': settings.PAID_COURSE_REGISTRATION_CURRENCY[1],
'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0], 'currency': settings.PAID_COURSE_REGISTRATION_CURRENCY[0],
'enable_bulk_purchase': microsite.get_value('ENABLE_SHOPPING_CART_BULK_PURCHASE', True)
} }
return render_to_response("shoppingcart/shopping_cart.html", context) return render_to_response("shoppingcart/shopping_cart.html", context)
......
...@@ -32,7 +32,7 @@ class TestProfEdVerification(ModuleStoreTestCase): ...@@ -32,7 +32,7 @@ class TestProfEdVerification(ModuleStoreTestCase):
min_price=self.MIN_PRICE, min_price=self.MIN_PRICE,
suggested_prices='' suggested_prices=''
) )
purchase_workflow = "?purchase_workflow=single"
self.urls = { self.urls = {
'course_modes_choose': reverse( 'course_modes_choose': reverse(
'course_modes_choose', 'course_modes_choose',
...@@ -42,7 +42,7 @@ class TestProfEdVerification(ModuleStoreTestCase): ...@@ -42,7 +42,7 @@ class TestProfEdVerification(ModuleStoreTestCase):
'verify_student_start_flow': reverse( 'verify_student_start_flow': reverse(
'verify_student_start_flow', 'verify_student_start_flow',
args=[unicode(self.course_key)] args=[unicode(self.course_key)]
), ) + purchase_workflow,
} }
def test_start_flow(self): def test_start_flow(self):
......
...@@ -334,6 +334,10 @@ class PayAndVerifyView(View): ...@@ -334,6 +334,10 @@ class PayAndVerifyView(View):
# Redirect the user to a more appropriate page if the # Redirect the user to a more appropriate page if the
# messaging won't make sense based on the user's # messaging won't make sense based on the user's
# enrollment / payment / verification status. # enrollment / payment / verification status.
sku_to_use = relevant_course_mode.sku
purchase_workflow = request.GET.get('purchase_workflow', 'single')
if purchase_workflow == 'bulk' and relevant_course_mode.bulk_sku:
sku_to_use = relevant_course_mode.bulk_sku
redirect_response = self._redirect_if_necessary( redirect_response = self._redirect_if_necessary(
message, message,
already_verified, already_verified,
...@@ -342,7 +346,7 @@ class PayAndVerifyView(View): ...@@ -342,7 +346,7 @@ class PayAndVerifyView(View):
course_key, course_key,
user_is_trying_to_pay, user_is_trying_to_pay,
request.user, request.user,
relevant_course_mode.sku sku_to_use
) )
if redirect_response is not None: if redirect_response is not None:
return redirect_response return redirect_response
......
...@@ -61,7 +61,7 @@ define([ ...@@ -61,7 +61,7 @@ define([
expect(view.$('.header-img').attr('src')).toEqual(context.run_modes[0].course_image_url); expect(view.$('.header-img').attr('src')).toEqual(context.run_modes[0].course_image_url);
expect(view.$('.course-details .course-title-link').text().trim()).toEqual(context.display_name); expect(view.$('.course-details .course-title-link').text().trim()).toEqual(context.display_name);
expect(view.$('.course-details .course-title-link').attr('href')).toEqual( expect(view.$('.course-details .course-title-link').attr('href')).toEqual(
context.run_modes[0].marketing_url); context.run_modes[0].course_url);
expect(view.$('.course-details .course-text .course-key').html()).toEqual(context.key); expect(view.$('.course-details .course-text .course-key').html()).toEqual(context.key);
expect(view.$('.course-details .course-text .run-period').html()) expect(view.$('.course-details .course-text .run-period').html())
.toEqual(context.run_modes[0].start_date + ' - ' + context.run_modes[0].end_date); .toEqual(context.run_modes[0].start_date + ' - ' + context.run_modes[0].end_date);
...@@ -71,7 +71,7 @@ define([ ...@@ -71,7 +71,7 @@ define([
expect(view.$('.header-img').attr('src')).toEqual(context.run_modes[0].course_image_url); expect(view.$('.header-img').attr('src')).toEqual(context.run_modes[0].course_image_url);
expect(view.$('.course-details .course-title-link').text().trim()).toEqual(context.display_name); expect(view.$('.course-details .course-title-link').text().trim()).toEqual(context.display_name);
expect(view.$('.course-details .course-title-link').attr('href')).toEqual( expect(view.$('.course-details .course-title-link').attr('href')).toEqual(
context.run_modes[0].marketing_url); context.run_modes[0].course_url);
expect(view.$('.course-details .course-text .course-key').html()).toEqual(context.key); expect(view.$('.course-details .course-text .course-key').html()).toEqual(context.key);
expect(view.$('.course-details .course-text .run-period').html()).not.toBeDefined(); expect(view.$('.course-details .course-text .run-period').html()).not.toBeDefined();
}); });
......
...@@ -51,7 +51,8 @@ ...@@ -51,7 +51,8 @@
enrollmentAction: $.url( '?enrollment_action' ), enrollmentAction: $.url( '?enrollment_action' ),
courseId: $.url( '?course_id' ), courseId: $.url( '?course_id' ),
courseMode: $.url( '?course_mode' ), courseMode: $.url( '?course_mode' ),
emailOptIn: $.url( '?email_opt_in' ) emailOptIn: $.url( '?email_opt_in' ),
purchaseWorkflow: $.url( '?purchase_workflow' )
}; };
for (var key in queryParams) { for (var key in queryParams) {
if (queryParams[key]) { if (queryParams[key]) {
...@@ -63,6 +64,7 @@ ...@@ -63,6 +64,7 @@
this.courseMode = queryParams.courseMode; this.courseMode = queryParams.courseMode;
this.emailOptIn = queryParams.emailOptIn; this.emailOptIn = queryParams.emailOptIn;
this.nextUrl = this.urls.defaultNextUrl; this.nextUrl = this.urls.defaultNextUrl;
this.purchaseWorkflow = queryParams.purchaseWorkflow;
if (queryParams.next) { if (queryParams.next) {
// Ensure that the next URL is internal for security reasons // Ensure that the next URL is internal for security reasons
if ( ! window.isExternal( queryParams.next ) ) { if ( ! window.isExternal( queryParams.next ) ) {
......
<div class="grid-container grid-manual"> <div class="grid-container grid-manual">
<div class="course-meta-container col-12 md-col-8 sm-col-12"> <div class="course-meta-container col-12 md-col-8 sm-col-12">
<a href="<%- marketing_url %>" class="course-image-link"> <a href="<%- course_url %>" class="course-image-link">
<img <img
class="header-img" class="header-img"
src="<%- course_image_url %>" src="<%- course_image_url %>"
...@@ -8,7 +8,7 @@ ...@@ -8,7 +8,7 @@
</a> </a>
<div class="course-details"> <div class="course-details">
<h3 class="course-title"> <h3 class="course-title">
<a href="<%- marketing_url %>" class="course-title-link"> <a href="<%- course_url %>" class="course-title-link">
<%- display_name %> <%- display_name %>
</a> </a>
</h3> </h3>
......
...@@ -5,7 +5,7 @@ ...@@ -5,7 +5,7 @@
</picture> </picture>
<h2 class="hd-2 title"><%- name %></h2> <h2 class="hd-2 title"><%- name %></h2>
<p class="subtitle"><%- subtitle %></p> <p class="subtitle"><%- subtitle %></p>
<a href="" class="breadcrumb"><%- gettext('Programs') %></a> <a href="/dashboard/programs" class="breadcrumb"><%- gettext('Programs') %></a>
<span><%- StringUtils.interpolate( <span><%- StringUtils.interpolate(
gettext('{category}\'s program'), gettext('{category}\'s program'),
{category: category} {category: category}
......
...@@ -101,6 +101,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -101,6 +101,7 @@ from openedx.core.lib.courses import course_image_url
% endif % endif
</div> </div>
<div class="col-2"> <div class="col-2">
% if enable_bulk_purchase:
<div class="numbers-row" aria-live="polite"> <div class="numbers-row" aria-live="polite">
<label for="field_${item.id}">${_('Students:')}</label> <label for="field_${item.id}">${_('Students:')}</label>
<div class="counter"> <div class="counter">
...@@ -117,6 +118,7 @@ from openedx.core.lib.courses import course_image_url ...@@ -117,6 +118,7 @@ from openedx.core.lib.courses import course_image_url
<!--<a name="updateBtn" class="updateBtn hidden" id="updateBtn-${item.id}" href="#">update</a>--> <!--<a name="updateBtn" class="updateBtn hidden" id="updateBtn-${item.id}" href="#">update</a>-->
<span class="error-text hidden" id="students-${item.id}"></span> <span class="error-text hidden" id="students-${item.id}"></span>
</div> </div>
% endif
</div> </div>
<div class="col-3"> <div class="col-3">
......
...@@ -16,7 +16,7 @@ ...@@ -16,7 +16,7 @@
"underscore.string": "~3.3.4" "underscore.string": "~3.3.4"
}, },
"devDependencies": { "devDependencies": {
"edx-custom-a11y-rules": "edx/edx-custom-a11y-rules", "edx-custom-a11y-rules": "git+https://github.com/edx/edx-custom-a11y-rules.git#12d2cae4ffdbb45c5643819211e06c17d6200210",
"pa11y": "3.6.0", "pa11y": "3.6.0",
"pa11y-reporter-1.0-json": "1.0.2", "pa11y-reporter-1.0-json": "1.0.2",
"jasmine-core": "^2.4.1", "jasmine-core": "^2.4.1",
......
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