diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index 0aeb95dbd9..ca8b3ec4cb 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -79,7 +79,6 @@ Feature: CMS.Course Settings | Course Start Time | 11:00 | | Course Introduction Video | 4r7wHMg5Yjg | | Course Effort | 200:00 | - | Course Image URL | image.jpg | # Special case because we have to type in code mirror Scenario: Changes in Course Overview show a confirmation @@ -94,11 +93,3 @@ Feature: CMS.Course Settings When I select Schedule and Details And I change the "Course Start Date" field to "" Then the save notification button is disabled - - Scenario: User can upload course image - Given I have opened a new course in Studio - When I select Schedule and Details - And I click the "Upload Course Image" button - And I upload a new course image - Then I should see the new course image - And the image URL should be present in the field diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index 4e11b393d1..350eb0392e 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -132,37 +132,6 @@ def test_change_course_overview(_step): type_in_codemirror(0, "

Overview

") -@step('I click the "Upload Course Image" button') -def click_upload_button(_step): - button_css = '.action-upload-image' - world.css_click(button_css) - - -@step('I upload a new course image$') -def upload_new_course_image(_step): - upload_file('image.jpg', sub_path="uploads") - - -@step('I should see the new course image$') -def i_see_new_course_image(_step): - img_css = '#course-image' - images = world.css_find(img_css) - assert len(images) == 1 - img = images[0] - expected_src = 'image.jpg' - - # Don't worry about the domain in the URL - success_func = lambda _: img['src'].endswith(expected_src) - world.wait_for(success_func) - - -@step('the image URL should be present in the field') -def image_url_present(_step): - field_css = '#course-image-url' - expected_value = 'image.jpg' - assert world.css_value(field_css).endswith(expected_value) - - ############### HELPER METHODS #################### def set_date_or_time(css, date_or_time): """ diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 99dff6acd8..5171d59059 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -255,11 +255,17 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertContains(response, "not the dates shown on your course summary page") self.assertContains(response, "Introducing Your Course") - self.assertContains(response, "Course Image") + self.assertContains(response, "Course Card Image") self.assertContains(response, "Course Short Description") + self.assertNotContains(response, "Course Title") + self.assertNotContains(response, "Course Subtitle") + self.assertNotContains(response, "Course Duration") + self.assertNotContains(response, "Course Description") self.assertNotContains(response, "Course Overview") self.assertNotContains(response, "Course Introduction Video") self.assertNotContains(response, "Requirements") + self.assertNotContains(response, "Course Banner Image") + self.assertNotContains(response, "Course Video Thumbnail Image") @unittest.skipUnless(settings.FEATURES.get('ENTRANCE_EXAMS', False), True) def test_entrance_exam_created_updated_and_deleted_successfully(self): @@ -367,7 +373,8 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): def test_regular_site_fetch(self): settings_details_url = get_url(self.course.id) - with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': False}): + with mock.patch.dict('django.conf.settings.FEATURES', {'ENABLE_MKTG_SITE': False, + 'ENABLE_EXTENDED_COURSE_DETAILS': True}): response = self.client.get_html(settings_details_url) self.assertContains(response, "Course Summary Page") self.assertContains(response, "Send a note to students via email") @@ -380,11 +387,17 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertNotContains(response, "not the dates shown on your course summary page") self.assertContains(response, "Introducing Your Course") - self.assertContains(response, "Course Image") + self.assertContains(response, "Course Card Image") + self.assertContains(response, "Course Title") + self.assertContains(response, "Course Subtitle") + self.assertContains(response, "Course Duration") + self.assertContains(response, "Course Description") self.assertContains(response, "Course Short Description") self.assertContains(response, "Course Overview") self.assertContains(response, "Course Introduction Video") self.assertContains(response, "Requirements") + self.assertContains(response, "Course Banner Image") + self.assertContains(response, "Course Video Thumbnail Image") @ddt.ddt diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 530b2f9d6b..476cab45da 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -976,18 +976,24 @@ def settings_handler(request, course_key_string): 'ENABLE_MKTG_SITE', settings.FEATURES.get('ENABLE_MKTG_SITE', False) ) + enable_extended_course_details = microsite.get_value_for_org( + course_module.location.org, + 'ENABLE_EXTENDED_COURSE_DETAILS', + settings.FEATURES.get('ENABLE_EXTENDED_COURSE_DETAILS', False) + ) about_page_editable = not marketing_site_enabled enrollment_end_editable = GlobalStaff().has_user(request.user) or not marketing_site_enabled short_description_editable = settings.FEATURES.get('EDITABLE_SHORT_DESCRIPTION', True) - self_paced_enabled = SelfPacedConfiguration.current().enabled settings_context = { 'context_course': course_module, 'course_locator': course_key, 'lms_link_for_about_page': utils.get_lms_link_for_about_page(course_key), - 'course_image_url': course_image_url(course_module), + 'course_image_url': course_image_url(course_module, 'course_image'), + 'banner_image_url': course_image_url(course_module, 'banner_image'), + 'video_thumbnail_image_url': course_image_url(course_module, 'video_thumbnail_image'), 'details_url': reverse_course_url('settings_handler', course_key), 'about_page_editable': about_page_editable, 'short_description_editable': short_description_editable, @@ -1001,6 +1007,7 @@ def settings_handler(request, course_key_string): 'is_prerequisite_courses_enabled': is_prerequisite_courses_enabled(), 'is_entrance_exams_enabled': is_entrance_exams_enabled(), 'self_paced_enabled': self_paced_enabled, + 'enable_extended_course_details': enable_extended_course_details } if is_prerequisite_courses_enabled(): courses, in_process_course_actions = get_courses_accessible_to_user(request) diff --git a/cms/envs/bok_choy.env.json b/cms/envs/bok_choy.env.json index 14115e3612..2e9c6086b6 100644 --- a/cms/envs/bok_choy.env.json +++ b/cms/envs/bok_choy.env.json @@ -76,7 +76,8 @@ "ALLOW_ALL_ADVANCED_COMPONENTS": true, "ENABLE_CONTENT_LIBRARIES": true, "ENABLE_SPECIAL_EXAMS": true, - "SHOW_LANGUAGE_SELECTOR": true + "SHOW_LANGUAGE_SELECTOR": true, + "ENABLE_EXTENDED_COURSE_DETAILS": true }, "FEEDBACK_SUBMISSION_EMAIL": "", "GITHUB_REPO_ROOT": "** OVERRIDDEN **", diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 99db4d68a1..202d1097e7 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -12,6 +12,10 @@ var CourseDetails = Backbone.Model.extend({ enrollment_start: null, enrollment_end: null, syllabus: null, + title: "", + subtitle: "", + duration: "", + description: "", short_description: "", overview: "", intro_video: null, @@ -19,9 +23,15 @@ var CourseDetails = Backbone.Model.extend({ license: null, course_image_name: '', // the filename course_image_asset_path: '', // the full URL (/c4x/org/course/num/asset/filename) + banner_image_name: '', + banner_image_asset_path: '', + video_thumbnail_image_name: '', + video_thumbnail_image_asset_path: '', pre_requisite_courses: [], entrance_exam_enabled : '', - entrance_exam_minimum_score_pct: '50' + entrance_exam_minimum_score_pct: '50', + learning_info: [], + instructor_info: {} }, validate: function(newattrs) { @@ -32,9 +42,30 @@ var CourseDetails = Backbone.Model.extend({ newattrs, ["start_date", "end_date", "enrollment_start", "enrollment_end"] ); + if (newattrs.title.length > 50) { + errors.title = gettext("The title field must be limited to 50 characters."); + } + + if (newattrs.subtitle.length > 150) { + errors.subtitle = gettext("The subtitle field must be limited to 150 characters."); + } + + if (newattrs.duration.length > 50) { + errors.duration = gettext("The duration field must be limited to 50 characters."); + } + + if (newattrs.short_description.length > 150) { + errors.short_description = gettext("The short description field must be limited to 150 characters."); + } + + if (newattrs.description.length > 1000) { + errors.description = gettext("The description field must be limited to 1000 characters."); + } + if (newattrs.start_date === null) { errors.start_date = gettext("The course must have an assigned start date."); } + if (newattrs.start_date && newattrs.end_date && newattrs.start_date >= newattrs.end_date) { errors.end_date = gettext("The course end date must be later than the course start date."); } diff --git a/cms/static/js/spec/views/settings/main_spec.js b/cms/static/js/spec/views/settings/main_spec.js index f39d669c5d..e87d706c9e 100644 --- a/cms/static/js/spec/views/settings/main_spec.js +++ b/cms/static/js/spec/views/settings/main_spec.js @@ -1,13 +1,17 @@ define([ 'jquery', 'js/models/settings/course_details', 'js/views/settings/main', - 'common/js/spec_helpers/ajax_helpers' -], function($, CourseDetailsModel, MainView, AjaxHelpers) { + 'common/js/spec_helpers/ajax_helpers', 'common/js/spec_helpers/template_helpers', +], function($, CourseDetailsModel, MainView, AjaxHelpers, TemplateHelpers) { 'use strict'; var SELECTORS = { entrance_exam_min_score: '#entrance-exam-minimum-score-pct', entrance_exam_enabled_field: '#entrance-exam-enabled', - grade_requirement_div: '.div-grade-requirements div' + grade_requirement_div: '.div-grade-requirements div', + add_course_learning_info: '.add-course-learning-info', + delete_course_learning_info: '.delete-course-learning-info', + add_course_instructor_info: '.add-course-instructor-info', + remove_instructor_data: '.remove-instructor-data' }; describe('Settings/Main', function () { @@ -21,24 +25,50 @@ define([ course_id : '', run : '', syllabus : null, + title: '', + subtitle: '', + duration: '', + description: '', short_description : '', overview : '', intro_video : null, effort : null, course_image_name : '', course_image_asset_path : '', + banner_image_name : '', + banner_image_asset_path : '', + video_thumbnail_image_name : '', + video_thumbnail_image_asset_path : '', pre_requisite_courses : [], entrance_exam_enabled : '', entrance_exam_minimum_score_pct: '50', license: null, - language: '' + language: '', + learning_info: [''], + instructor_info: { + 'instructors': [{"name": "","title": "","organization": "","image": "","bio": ""}] + } }, - mockSettingsPage = readFixtures('mock/mock-settings-page.underscore'); + + mockSettingsPage = readFixtures('mock/mock-settings-page.underscore'), + learningInfoTpl = readFixtures('course-settings-learning-fields.underscore'), + instructorInfoTpl = readFixtures('course-instructor-details.underscore'); beforeEach(function () { - setFixtures(mockSettingsPage); + TemplateHelpers.installTemplates(['course-settings-learning-fields', 'course-instructor-details'], true); + appendSetFixtures(mockSettingsPage); + appendSetFixtures( + $(" @@ -27,7 +27,7 @@ @@ -292,9 +292,33 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; ${_("Information for prospective students")}
    + + % if enable_extended_course_details: +
  1. + + + ${_("Displayed as title on the course details page. Limit to 50 characters.")} +
  2. +
  3. + + + ${_("Displayed as subtitle on the course details page. Limit to 150 characters.")} +
  4. +
  5. + + + ${_("Displayed on the course details page. Limit to 50 characters.")} +
  6. +
  7. + + + ${_("Displayed on the course details page. Limit to 1000 characters.")} +
  8. + % endif + % if short_description_editable:
  9. - + ${_("Appears on the course catalog page when students roll over the course name. Limit to ~150 characters")}
  10. @@ -315,11 +339,11 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; % endif
  11. - +
    % if context_course.course_image: - ${_('Course Image')} + ${_('Course Card Image')} @@ -328,7 +352,7 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; % else: - ${_('Course Image')} + ${_('Course Card Image')} ${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")} % endif @@ -340,13 +364,75 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; ${_("Please provide a valid path and name to your course image (Note: only JPEG or PNG format supported)")}
    - +
  12. + % if enable_extended_course_details: +
  13. + +
    + % if context_course.banner_image: + + + + + + ${_("You can manage this image along with all of your other files & uploads").format(upload_asset_url)} + + + % else: + + + + ${_("Your course currently does not have an image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 1440px wide by 400px tall)")} + % endif +
    + +
    +
    + ## Translators: This is the placeholder text for a field that requests the URL for a course banner image + + ${_("Please provide a valid path and name to your banner image (Note: only JPEG or PNG format supported)")} +
    + +
    +
  14. + +
  15. + +
    + % if context_course.video_thumbnail_image: + + ${_('Video Thumbnail Image')} + + + + ${_("You can manage this image along with all of your other files & uploads").format(upload_asset_url)} + + + % else: + + ${_('Video Thumbnail Image')} + + ${_("Your course currently does not have a video thumbnail image. Please upload one (JPEG or PNG format, and minimum suggested dimensions are 375px wide by 200px tall)")} + % endif +
    + +
    +
    + ## Translators: This is the placeholder text for a field that requests the URL for a course video thumbnail image + + ${_("Please provide a valid path and name to your video thumbnail image (Note: only JPEG or PNG format supported)")} +
    + +
    +
  16. + % endif + % if about_page_editable:
  17. - +
    @@ -362,10 +448,44 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; ${_("Enter your YouTube video's ID (along with any restriction parameters)")}
  18. - % endif + % endif
+ % if enable_extended_course_details: +
+
+
+

${_("Learning Outcomes")}

+ ${_("Add the learning outcomes for this course")} +
+
    +
  1. +
+
+ +
+
+ +
+
+
+

${_("Instructors")}

+ ${_("Add details about the instructors for this course")} +
+
    +
  1. +
+
+ +
+
+ % endif + % if about_page_editable or is_prerequisite_courses_enabled or is_entrance_exams_enabled:
diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 93e1481417..85188e7ddb 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -475,6 +475,26 @@ class CourseFields(object): # Ensure that courses imported from XML keep their image default="images_course_image.jpg" ) + banner_image = String( + display_name=_("Course Banner Image"), + help=_( + "Edit the name of the banner image file. " + "You can set the banner image on the Settings & Details page." + ), + scope=Scope.settings, + # Ensure that courses imported from XML keep their image + default="images_course_image.jpg" + ) + video_thumbnail_image = String( + display_name=_("Course Video Thumbnail Image"), + help=_( + "Edit the name of the video thumbnail image file. " + "You can set the video thumbnail image on the Settings & Details page." + ), + scope=Scope.settings, + # Ensure that courses imported from XML keep their image + default="images_course_image.jpg" + ) issue_badges = Boolean( display_name=_("Issue Open Badges"), help=_( @@ -774,6 +794,36 @@ class CourseFields(object): scope=Scope.settings ) + learning_info = List( + display_name=_("Course Learning Information"), + help=_("Specify what student can learn from the course."), + default=[], + scope=Scope.settings + ) + + """ + instructor_info dict structure: + { + "instructors": [ + { + "name": "", + "title": "", + "organization": "", + "image": "", + "bio": "" + } + ] + } + """ + instructor_info = Dict( + display_name=_("Course Instructor"), + help=_("Enter the details for Course Instructor"), + default={ + "instructors": [] + }, + scope=Scope.settings + ) + class CourseModule(CourseFields, SequenceModule): # pylint: disable=abstract-method """ diff --git a/common/test/acceptance/pages/studio/settings.py b/common/test/acceptance/pages/studio/settings.py index 2adfb161ca..9546de0c2b 100644 --- a/common/test/acceptance/pages/studio/settings.py +++ b/common/test/acceptance/pages/studio/settings.py @@ -3,6 +3,7 @@ Course Schedule and Details Settings page. """ from __future__ import unicode_literals +import os from bok_choy.promise import EmptyPromise from bok_choy.javascript import requirejs @@ -17,6 +18,9 @@ class SettingsPage(CoursePage): """ url_path = "settings/details" + upload_image_browse_button_selector = 'form.upload-dialog input[type=file]' + upload_image_upload_button_selector = '.modal-actions li:nth-child(1) a' + upload_image_popup_window_selector = '.assetupload-modal' ################ # Helpers @@ -234,3 +238,53 @@ class SettingsPage(CoursePage): ).fulfill() self.wait_for_require_js() self.wait_for_ajax() + + @staticmethod + def get_asset_path(file_name): + """ + Returns the full path of the file to upload. + These files have been placed in edx-platform/common/test/data/uploads/ + """ + + # Separate the list of folders in the path reaching to the current file, + # e.g. '... common/test/acceptance/pages/lms/instructor_dashboard.py' will result in + # [..., 'common', 'test', 'acceptance', 'pages', 'lms', 'instructor_dashboard.py'] + folders_list_in_path = __file__.split(os.sep) + + # Get rid of the last 4 elements: 'acceptance', 'pages', 'lms', and 'instructor_dashboard.py' + # to point to the 'test' folder, a shared point in the path's tree. + folders_list_in_path = folders_list_in_path[:-4] + + # Append the folders in the asset's path + folders_list_in_path.extend(['data', 'uploads', file_name]) + + # Return the joined path of the required asset. + return os.sep.join(folders_list_in_path) + + def upload_image(self, upload_btn_selector, file_to_upload): + """ + Upload image specified by image_selector and file_to_upload + """ + + # wait for upload button + self.wait_for_element_presence(upload_btn_selector, 'upload button is present') + + self.q(css=upload_btn_selector).results[0].click() + + # wait for popup + self.wait_for_element_presence(self.upload_image_popup_window_selector, 'upload dialog is present') + + # upload image + filepath = SettingsPage.get_asset_path(file_to_upload) + self.q(css=self.upload_image_browse_button_selector).results[0].send_keys(filepath) + self.q(css=self.upload_image_upload_button_selector).results[0].click() + + # wait for popup closed + self.wait_for_element_absence(self.upload_image_popup_window_selector, 'upload dialog is hidden') + + def get_uploaded_image_path(self, image_selector): + """ + Returns the uploaded image path + """ + + return self.q(css=image_selector).attrs('src')[0] diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py index e209207189..87acaf1785 100644 --- a/common/test/acceptance/pages/studio/settings_advanced.py +++ b/common/test/acceptance/pages/studio/settings_advanced.py @@ -172,6 +172,8 @@ class AdvancedSettingsPage(CoursePage): 'cert_name_short', 'certificates_display_behavior', 'course_image', + 'banner_image', + 'video_thumbnail_image', 'cosmetic_display_price', 'advertised_start', 'announcement', @@ -217,4 +219,6 @@ class AdvancedSettingsPage(CoursePage): 'enable_proctored_exams', 'enable_timed_exams', 'enable_subsection_gating', + 'learning_info', + 'instructor_info' ] diff --git a/common/test/acceptance/tests/studio/test_studio_settings.py b/common/test/acceptance/tests/studio/test_studio_settings.py index d050493600..a783c0d81f 100644 --- a/common/test/acceptance/tests/studio/test_studio_settings.py +++ b/common/test/acceptance/tests/studio/test_studio_settings.py @@ -475,3 +475,36 @@ class ContentLicenseTest(StudioCourseTest): # The course_license text will include a bunch of screen reader text to explain # the selected options self.assertIn("Some Rights Reserved", self.lms_courseware.course_license) + + +@attr('a11y') +class StudioSettingsA11yTest(StudioCourseTest): + + """ + Class to test Studio pages accessibility. + """ + + def setUp(self): # pylint: disable=arguments-differ + super(StudioSettingsA11yTest, self).setUp() + self.settings_page = SettingsPage(self.browser, self.course_info['org'], self.course_info['number'], + self.course_info['run']) + + def test_studio_settings_page_a11y(self): + """ + Check accessibility of SettingsPage. + """ + self.settings_page.visit() + self.settings_page.wait_for_page() + + # There are several existing color contrast errors on this page, + # we will ignore this error in the test until we fix them. + self.settings_page.a11y_audit.config.set_rules({ + "ignore": [ + 'color-contrast', # TODO: AC-225 + 'link-href', # TODO: AC-226 + 'nav-aria-label', # TODO: AC-227 + 'icon-aria-hidden', # TODO: AC-229 + ], + }) + + self.settings_page.a11y_audit.check_for_accessibility_errors() diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index cdf0c76340..0db50a3803 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -18,6 +18,10 @@ from xmodule.modulestore.django import modulestore # handled separately; its value maps to an alternate key name. ABOUT_ATTRIBUTES = [ 'syllabus', + 'title', + 'subtitle', + 'duration', + 'description', 'short_description', 'overview', 'effort', @@ -43,6 +47,10 @@ class CourseDetails(object): self.enrollment_start = None self.enrollment_end = None self.syllabus = None # a pdf file asset + self.title = "" + self.subtitle = "" + self.duration = "" + self.description = "" self.short_description = "" self.overview = "" # html to render as the overview self.intro_video = None # a video pointer @@ -50,6 +58,10 @@ class CourseDetails(object): self.license = "all-rights-reserved" # default course license is all rights reserved self.course_image_name = "" self.course_image_asset_path = "" # URL of the course image + self.banner_image_name = "" + self.banner_image_asset_path = "" + self.video_thumbnail_image_name = "" + self.video_thumbnail_image_asset_path = "" self.pre_requisite_courses = [] # pre-requisite courses self.entrance_exam_enabled = "" # is entrance exam enabled self.entrance_exam_id = "" # the content location for the entrance exam @@ -58,6 +70,8 @@ class CourseDetails(object): '50' ) # minimum passing score for entrance exam content module/tree, self.self_paced = None + self.learning_info = [] + self.instructor_info = [] @classmethod def fetch_about_attribute(cls, course_key, attribute): @@ -89,9 +103,15 @@ class CourseDetails(object): course_details.enrollment_end = descriptor.enrollment_end course_details.pre_requisite_courses = descriptor.pre_requisite_courses course_details.course_image_name = descriptor.course_image - course_details.course_image_asset_path = course_image_url(descriptor) + course_details.course_image_asset_path = course_image_url(descriptor, 'course_image') + course_details.banner_image_name = descriptor.banner_image + course_details.banner_image_asset_path = course_image_url(descriptor, 'banner_image') + course_details.video_thumbnail_image_name = descriptor.video_thumbnail_image + course_details.video_thumbnail_image_asset_path = course_image_url(descriptor, 'video_thumbnail_image') course_details.language = descriptor.language course_details.self_paced = descriptor.self_paced + course_details.learning_info = descriptor.learning_info + course_details.instructor_info = descriptor.instructor_info # Default course license is "All Rights Reserved" course_details.license = getattr(descriptor, "license", "all-rights-reserved") @@ -209,6 +229,15 @@ class CourseDetails(object): descriptor.course_image = jsondict['course_image_name'] dirty = True + if 'banner_image_name' in jsondict and jsondict['banner_image_name'] != descriptor.banner_image: + descriptor.banner_image = jsondict['banner_image_name'] + dirty = True + + if 'video_thumbnail_image_name' in jsondict \ + and jsondict['video_thumbnail_image_name'] != descriptor.video_thumbnail_image: + descriptor.video_thumbnail_image = jsondict['video_thumbnail_image_name'] + dirty = True + if 'pre_requisite_courses' in jsondict \ and sorted(jsondict['pre_requisite_courses']) != sorted(descriptor.pre_requisite_courses): descriptor.pre_requisite_courses = jsondict['pre_requisite_courses'] @@ -218,6 +247,14 @@ class CourseDetails(object): descriptor.license = jsondict['license'] dirty = True + if 'learning_info' in jsondict: + descriptor.learning_info = jsondict['learning_info'] + dirty = True + + if 'instructor_info' in jsondict: + descriptor.instructor_info = jsondict['instructor_info'] + dirty = True + if 'language' in jsondict and jsondict['language'] != descriptor.language: descriptor.language = jsondict['language'] dirty = True diff --git a/openedx/core/djangoapps/models/tests/test_course_details.py b/openedx/core/djangoapps/models/tests/test_course_details.py index 46f39982cf..661ddfe456 100644 --- a/openedx/core/djangoapps/models/tests/test_course_details.py +++ b/openedx/core/djangoapps/models/tests/test_course_details.py @@ -95,11 +95,41 @@ class CourseDetailsTestCase(ModuleStoreTestCase): CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).course_image_name, jsondetails.course_image_name ) + jsondetails.banner_image_name = "an_image.jpg" + self.assertEqual( + CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).banner_image_name, + jsondetails.banner_image_name + ) + jsondetails.video_thumbnail_image_name = "an_image.jpg" + self.assertEqual( + CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).video_thumbnail_image_name, + jsondetails.video_thumbnail_image_name + ) jsondetails.language = "hr" self.assertEqual( CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).language, jsondetails.language ) + jsondetails.learning_info = ["test", "test"] + self.assertEqual( + CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).learning_info, + jsondetails.learning_info + ) + jsondetails.instructor_info = { + "instructors": [ + { + "name": "test", + "title": "test", + "organization": "test", + "image": "test", + "bio": "test" + } + ] + } + self.assertEqual( + CourseDetails.update_from_json(self.course.id, jsondetails.__dict__, self.user).instructor_info, + jsondetails.instructor_info + ) def test_toggle_pacing_during_course_run(self): SelfPacedConfiguration(enabled=True).save() diff --git a/openedx/core/lib/courses.py b/openedx/core/lib/courses.py index 2936cc93d8..a62cdc4e73 100644 --- a/openedx/core/lib/courses.py +++ b/openedx/core/lib/courses.py @@ -10,24 +10,25 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore import ModuleStoreEnum -def course_image_url(course): +def course_image_url(course, image_key='course_image'): """Try to look up the image url for the course. If it's not found, - log an error and return the dead link""" + log an error and return the dead link. + image_key can be one of the three: 'course_image', 'hero_image', 'thumbnail_image' """ if course.static_asset_path or modulestore().get_modulestore_type(course.id) == ModuleStoreEnum.Type.xml: - # If we are a static course with the course_image attribute + # If we are a static course with the image_key attribute # set different than the default, return that path so that # courses can use custom course image paths, otherwise just # return the default static path. url = '/static/' + (course.static_asset_path or getattr(course, 'data_dir', '')) - if hasattr(course, 'course_image') and course.course_image != course.fields['course_image'].default: - url += '/' + course.course_image + if hasattr(course, image_key) and getattr(course, image_key) != course.fields[image_key].default: + url += '/' + getattr(course, image_key) else: - url += '/images/course_image.jpg' - elif not course.course_image: - # if course_image is empty, use the default image url from settings + url += '/images/' + image_key + '.jpg' + elif not getattr(course, image_key): + # if image_key is empty, use the default image url from settings url = settings.STATIC_URL + settings.DEFAULT_COURSE_ABOUT_IMAGE_URL else: - loc = StaticContent.compute_location(course.id, course.course_image) + loc = StaticContent.compute_location(course.id, getattr(course, image_key)) url = StaticContent.serialize_asset_key_with_slash(loc) return url diff --git a/openedx/core/lib/tests/test_courses.py b/openedx/core/lib/tests/test_courses.py index 696f9e503b..45a8b47cdb 100644 --- a/openedx/core/lib/tests/test_courses.py +++ b/openedx/core/lib/tests/test_courses.py @@ -65,3 +65,21 @@ class CourseImageTestCase(ModuleStoreTestCase): 'static/test.png', course_image_url(course), ) + + def test_get_banner_image_url(self): + """Test banner image URL formatting.""" + banner_image = u'banner_image.jpg' + course = CourseFactory.create(banner_image=banner_image) + self.verify_url( + unicode(course.id.make_asset_key('asset', banner_image)), + course_image_url(course, 'banner_image') + ) + + def test_get_video_thumbnail_image_url(self): + """Test video thumbnail image URL formatting.""" + thumbnail_image = u'thumbnail_image.jpg' + course = CourseFactory.create(video_thumbnail_image=thumbnail_image) + self.verify_url( + unicode(course.id.make_asset_key('asset', thumbnail_image)), + course_image_url(course, 'video_thumbnail_image') + )