From 357a9f80808557dd4428c07c9cfe7bdea05776e3 Mon Sep 17 00:00:00 2001 From: Ibrahim Date: Mon, 11 Apr 2016 18:04:53 +0500 Subject: [PATCH 1/3] add new text fields to schedule and details settings page --- .../tests/test_course_settings.py | 11 ++++++- cms/djangoapps/contentstore/views/course.py | 7 +++- .../js/models/settings/course_details.js | 25 ++++++++++++++ .../js/spec/views/settings/main_spec.js | 4 +++ cms/static/js/views/settings/main.js | 32 +++++++++++++----- cms/templates/settings.html | 32 +++++++++++++++--- .../tests/studio/test_studio_settings.py | 33 +++++++++++++++++++ .../core/djangoapps/models/course_details.py | 8 +++++ 8 files changed, 137 insertions(+), 15 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 99dff6acd8..5d5c6a5d7c 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -257,6 +257,10 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertContains(response, "Introducing Your Course") self.assertContains(response, "Course 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") @@ -367,7 +371,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") @@ -381,6 +386,10 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): self.assertContains(response, "Introducing Your Course") self.assertContains(response, "Course 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") diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 530b2f9d6b..cb913b8a9a 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -976,11 +976,15 @@ 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 = { @@ -1001,6 +1005,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/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 99db4d68a1..8742a8fe67 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, @@ -32,9 +36,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..202ff4cf0d 100644 --- a/cms/static/js/spec/views/settings/main_spec.js +++ b/cms/static/js/spec/views/settings/main_spec.js @@ -21,6 +21,10 @@ define([ course_id : '', run : '', syllabus : null, + title: '', + subtitle: '', + duration: '', + description: '', short_description : '', overview : '', intro_video : null, diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index 74aacc24c5..0897cccbdb 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -71,6 +71,16 @@ var DetailsView = ValidatingView.extend({ this.$el.find('#' + this.fieldToSelectorMap['overview']).val(this.model.get('overview')); this.codeMirrorize(null, $('#course-overview')[0]); + if (this.model.get('title') !== '') { + this.$el.find('#' + this.fieldToSelectorMap.title).val(this.model.get('title')); + } else { + var displayName = this.$el.find('#' + this.fieldToSelectorMap.title).attr('data-display-name'); + this.$el.find('#' + this.fieldToSelectorMap.title).val(displayName); + } + this.$el.find('#' + this.fieldToSelectorMap.subtitle).val(this.model.get('subtitle')); + this.$el.find('#' + this.fieldToSelectorMap.duration).val(this.model.get('duration')); + this.$el.find('#' + this.fieldToSelectorMap.description).val(this.model.get('description')); + this.$el.find('#' + this.fieldToSelectorMap['short_description']).val(this.model.get('short_description')); this.$el.find('.current-course-introduction-video iframe').attr('src', this.model.videosourceSample()); @@ -126,6 +136,10 @@ var DetailsView = ValidatingView.extend({ 'enrollment_start' : 'enrollment-start', 'enrollment_end' : 'enrollment-end', 'overview' : 'course-overview', + 'title': 'course-title', + 'subtitle': 'course-subtitle', + 'duration': 'course-duration', + 'description': 'course-description', 'short_description' : 'course-short-description', 'intro_video' : 'course-introduction-video', 'effort' : "course-effort", @@ -149,9 +163,6 @@ var DetailsView = ValidatingView.extend({ updateModel: function(event) { switch (event.currentTarget.id) { - case 'course-language': - this.setField(event); - break; case 'course-image-url': this.setField(event); var url = $(event.currentTarget).val(); @@ -163,9 +174,6 @@ var DetailsView = ValidatingView.extend({ $('#course-image').attr('src', $(event.currentTarget).val()); }, 1000); break; - case 'course-effort': - this.setField(event); - break; case 'entrance-exam-enabled': if($(event.currentTarget).is(":checked")){ this.$('.div-grade-requirements').show(); @@ -183,9 +191,6 @@ var DetailsView = ValidatingView.extend({ this.setField(event); } break; - case 'course-short-description': - this.setField(event); - break; case 'pre-requisite-course': var value = $(event.currentTarget).val(); value = value == "" ? [] : [value]; @@ -212,6 +217,15 @@ var DetailsView = ValidatingView.extend({ case 'course-pace-instructor-paced': this.model.set('self_paced', JSON.parse(event.currentTarget.value)); break; + case 'course-language': + case 'course-effort': + case 'course-title': + case 'course-subtitle': + case 'course-duration': + case 'course-description': + case 'course-short-description': + this.setField(event); + break; default: // Everything else is handled by datepickers and CodeMirror. break; } diff --git a/cms/templates/settings.html b/cms/templates/settings.html index c10e9ebc8b..f5df1caddb 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -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 hero image overlay on the course details page. Limit to 50 characters.")} +
  2. +
  3. + + + ${_("Displayed as hero image overlay on the course details page below the Course Title in a smaller font. Limit to 150 characters.")} +
  4. +
  5. + + + ${_("Displayed on the course details page below the hero image. 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,7 +339,7 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; % endif
  11. - +
    % if context_course.course_image: @@ -346,7 +370,7 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'; % if about_page_editable:
  12. - +
    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..980484d57a 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 From a13e531042f860b29d9caaafeb6ab9e4ee7e0dff Mon Sep 17 00:00:00 2001 From: Ibrahim Date: Tue, 26 Apr 2016 16:10:54 +0500 Subject: [PATCH 2/3] WL-398 Add Course Background Image and Video Thumbnail Image Fields to Studio --- .../features/course-settings.feature | 9 -- .../contentstore/features/course-settings.py | 31 ------- .../tests/test_course_settings.py | 8 +- cms/djangoapps/contentstore/views/course.py | 4 +- cms/envs/bok_choy.env.json | 3 +- .../js/models/settings/course_details.js | 4 + .../js/spec/views/settings/main_spec.js | 68 ++++++++++++++ cms/static/js/views/settings/main.js | 81 ++++++++++++----- cms/static/sass/views/_settings.scss | 2 +- .../js/mock/mock-settings-page.underscore | 90 +++++++++++++++++++ cms/templates/settings.html | 76 ++++++++++++++-- common/lib/xmodule/xmodule/course_module.py | 20 +++++ .../test/acceptance/pages/studio/settings.py | 51 +++++++++++ .../pages/studio/settings_advanced.py | 2 + .../core/djangoapps/models/course_details.py | 19 +++- .../models/tests/test_course_details.py | 10 +++ openedx/core/lib/courses.py | 19 ++-- openedx/core/lib/tests/test_courses.py | 18 ++++ 18 files changed, 432 insertions(+), 83 deletions(-) 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 5d5c6a5d7c..5171d59059 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -255,7 +255,7 @@ 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") @@ -264,6 +264,8 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): 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): @@ -385,7 +387,7 @@ 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") @@ -394,6 +396,8 @@ class CourseDetailsViewTest(CourseTestCase, MilestonesTestCaseMixin): 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 cb913b8a9a..476cab45da 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -991,7 +991,9 @@ def settings_handler(request, course_key_string): '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, 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 8742a8fe67..2e192e066b 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -23,6 +23,10 @@ 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' diff --git a/cms/static/js/spec/views/settings/main_spec.js b/cms/static/js/spec/views/settings/main_spec.js index 202ff4cf0d..5c686f4ac9 100644 --- a/cms/static/js/spec/views/settings/main_spec.js +++ b/cms/static/js/spec/views/settings/main_spec.js @@ -31,6 +31,10 @@ define([ 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', @@ -182,5 +186,69 @@ define([ AjaxHelpers.expectJsonRequest(requests, 'POST', urlRoot, modelData); }); + it('should save title', function(){ + var requests = AjaxHelpers.requests(this), + expectedJson = $.extend(true, {}, modelData, { + title: 'course title' + }); + + // Input some value. + this.view.$("#course-title").val('course title'); + this.view.$("#course-title").trigger('change'); + this.view.saveView(); + AjaxHelpers.expectJsonRequest( + requests, 'POST', urlRoot, expectedJson + ); + AjaxHelpers.respondWithJson(requests, expectedJson); + }); + + it('should save subtitle', function(){ + var requests = AjaxHelpers.requests(this), + expectedJson = $.extend(true, {}, modelData, { + subtitle: 'course subtitle' + }); + + // Input some value. + this.view.$("#course-subtitle").val('course subtitle'); + this.view.$("#course-subtitle").trigger('change'); + this.view.saveView(); + AjaxHelpers.expectJsonRequest( + requests, 'POST', urlRoot, expectedJson + ); + AjaxHelpers.respondWithJson(requests, expectedJson); + }); + + it('should save duration', function(){ + var requests = AjaxHelpers.requests(this), + expectedJson = $.extend(true, {}, modelData, { + duration: '8 weeks' + }); + + // Input some value. + this.view.$("#course-duration").val('8 weeks'); + this.view.$("#course-duration").trigger('change'); + this.view.saveView(); + AjaxHelpers.expectJsonRequest( + requests, 'POST', urlRoot, expectedJson + ); + AjaxHelpers.respondWithJson(requests, expectedJson); + }); + + it('should save description', function(){ + var requests = AjaxHelpers.requests(this), + expectedJson = $.extend(true, {}, modelData, { + description: 'course description' + }); + + // Input some value. + this.view.$("#course-description").val('course description'); + this.view.$("#course-description").trigger('change'); + this.view.saveView(); + AjaxHelpers.expectJsonRequest( + requests, 'POST', urlRoot, expectedJson + ); + AjaxHelpers.respondWithJson(requests, expectedJson); + }); + }); }); diff --git a/cms/static/js/views/settings/main.js b/cms/static/js/views/settings/main.js index 0897cccbdb..5cd46e419d 100644 --- a/cms/static/js/views/settings/main.js +++ b/cms/static/js/views/settings/main.js @@ -34,10 +34,10 @@ var DetailsView = ValidatingView.extend({ this.$el.find('.set-date').datepicker({ 'dateFormat': 'm/d/yy' }); // Avoid showing broken image on mistyped/nonexistent image - this.$el.find('img.course-image').error(function() { + this.$el.find('img').error(function() { $(this).hide(); }); - this.$el.find('img.course-image').load(function() { + this.$el.find('img').load(function() { $(this).show(); }); @@ -92,9 +92,17 @@ var DetailsView = ValidatingView.extend({ this.$el.find('#' + this.fieldToSelectorMap['effort']).val(this.model.get('effort')); - var imageURL = this.model.get('course_image_asset_path'); - this.$el.find('#course-image-url').val(imageURL); - this.$el.find('#course-image').attr('src', imageURL); + var courseImageURL = this.model.get('course_image_asset_path'); + this.$el.find('#course-image-url').val(courseImageURL); + this.$el.find('#course-image').attr('src', courseImageURL); + + var bannerImageURL = this.model.get('banner_image_asset_path'); + this.$el.find('#banner-image-url').val(bannerImageURL); + this.$el.find('#banner-image').attr('src', bannerImageURL); + + var videoThumbnailImageURL = this.model.get('video_thumbnail_image_asset_path'); + this.$el.find('#video-thumbnail-image-url').val(videoThumbnailImageURL); + this.$el.find('#video-thumbnail-image').attr('src', videoThumbnailImageURL); var pre_requisite_courses = this.model.get('pre_requisite_courses'); pre_requisite_courses = pre_requisite_courses.length > 0 ? pre_requisite_courses : ''; @@ -144,6 +152,8 @@ var DetailsView = ValidatingView.extend({ 'intro_video' : 'course-introduction-video', 'effort' : "course-effort", 'course_image_asset_path': 'course-image-url', + 'banner_image_asset_path': 'banner-image-url', + 'video_thumbnail_image_asset_path': 'video-thumbnail-image-url', 'pre_requisite_courses': 'pre-requisite-course', 'entrance_exam_enabled': 'entrance-exam-enabled', 'entrance_exam_minimum_score_pct': 'entrance-exam-minimum-score-pct' @@ -164,15 +174,13 @@ var DetailsView = ValidatingView.extend({ updateModel: function(event) { switch (event.currentTarget.id) { case 'course-image-url': - this.setField(event); - var url = $(event.currentTarget).val(); - var image_name = _.last(url.split('/')); - this.model.set('course_image_name', image_name); - // Wait to set the image src until the user stops typing - clearTimeout(this.imageTimer); - this.imageTimer = setTimeout(function() { - $('#course-image').attr('src', $(event.currentTarget).val()); - }, 1000); + this.updateImageField(event, 'course_image_name', '#course-image'); + break; + case 'banner-image-url': + this.updateImageField(event, 'banner_image_name', '#banner-image'); + break; + case 'video-thumbnail-image-url': + this.updateImageField(event, 'video_thumbnail_image_name', '#video-thumbnail-image'); break; case 'entrance-exam-enabled': if($(event.currentTarget).is(":checked")){ @@ -230,7 +238,17 @@ var DetailsView = ValidatingView.extend({ break; } }, - + updateImageField: function(event, image_field, selector) { + this.setField(event); + var url = $(event.currentTarget).val(); + var image_name = _.last(url.split('/')); + this.model.set(image_field, image_name); + // Wait to set the image src until the user stops typing + clearTimeout(this.imageTimer); + this.imageTimer = setTimeout(function() { + $(selector).attr('src', $(event.currentTarget).val()); + }, 1000); + }, removeVideo: function(event) { event.preventDefault(); if (this.model.has('intro_video')) { @@ -314,8 +332,30 @@ var DetailsView = ValidatingView.extend({ uploadImage: function(event) { event.preventDefault(); + var title = "", selector = "", image_key = "", image_path_key = ""; + switch (event.currentTarget.id) { + case 'upload-course-image': + title = gettext("Upload your course image."); + selector = "#course-image"; + image_key = 'course_image_name'; + image_path_key = 'course_image_asset_path'; + break; + case 'upload-banner-image': + title = gettext("Upload your banner image."); + selector = "#banner-image"; + image_key = 'banner_image_name'; + image_path_key = 'banner_image_asset_path'; + break; + case 'upload-video-thumbnail-image': + title = gettext("Upload your video thumbnail image."); + selector = "#video-thumbnail-image"; + image_key = 'video_thumbnail_image_name'; + image_path_key = 'video_thumbnail_image_asset_path'; + break; + } + var upload = new FileUploadModel({ - title: gettext("Upload your course image."), + title: title, message: gettext("Files must be in JPEG or PNG format."), mimeTypes: ['image/jpeg', 'image/png'] }); @@ -323,13 +363,12 @@ var DetailsView = ValidatingView.extend({ var modal = new FileUploadDialog({ model: upload, onSuccess: function(response) { - var options = { - 'course_image_name': response.asset.display_name, - 'course_image_asset_path': response.asset.url - }; + var options = {}; + options[image_key] = response.asset.display_name; + options[image_path_key] = response.asset.url; self.model.set(options); self.render(); - $('#course-image').attr('src', self.model.get('course_image_asset_path')); + $(selector).attr('src', self.model.get(image_path_key)); } }); modal.show(); diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index f753a42d20..0a56fec268 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -516,7 +516,7 @@ } // specific fields - course image - #field-course-image { + #field-course-image, #field-banner-image, #field-video-thumbnail-image { .current-course-image { margin-bottom: ($baseline/2); diff --git a/cms/templates/js/mock/mock-settings-page.underscore b/cms/templates/js/mock/mock-settings-page.underscore index 1422279fb9..cc92b82f9e 100644 --- a/cms/templates/js/mock/mock-settings-page.underscore +++ b/cms/templates/js/mock/mock-settings-page.underscore @@ -105,6 +105,96 @@ Identify the course language here. This is used to assist users find courses that are taught in a specific language.
  13. +
+ +
+
+

Introducing Your Course

+ Information for prospective students +
+
    + +
  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. + +
  9. + +
    + + Course 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) +
    + +
    +
    + ## Translators: This is the placeholder text for a field that requests the URL for a course image + + Please provide a valid path and name to your course image (Note: only JPEG or PNG format supported) +
    + +
    +
  10. + +
  11. + +
    + + + + 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) +
    + +
    +
    + ## 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) +
    + +
    +
  12. + +
  13. + +
    + + 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) +
    + +
    +
    + ## 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) +
    + +
    +
  14. + +
+
+ diff --git a/cms/templates/settings.html b/cms/templates/settings.html index f5df1caddb..428870f17b 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -297,17 +297,17 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}'
  • - ${_("Displayed as hero image overlay on the course details page. Limit to 50 characters.")} + ${_("Displayed as title on the course details page. Limit to 50 characters.")}
  • - ${_("Displayed as hero image overlay on the course details page below the Course Title in a smaller font. Limit to 150 characters.")} + ${_("Displayed as subtitle on the course details page. Limit to 150 characters.")}
  • - ${_("Displayed on the course details page below the hero image. Limit to 50 characters.")} + ${_("Displayed on the course details page. Limit to 50 characters.")}
  • @@ -339,11 +339,11 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}' % endif
  • - +
    % if context_course.course_image: - ${_('Course Image')} + ${_('Course Card Image')} @@ -352,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 @@ -364,10 +364,72 @@ 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)")}
    - +
  • + % if enable_extended_course_details: +
  • + +
    + % 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)")} +
    + +
    +
  • + +
  • + +
    + % 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)")} +
    + +
    +
  • + % endif + % if about_page_editable:
  • diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 93e1481417..c24444b825 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=_( diff --git a/common/test/acceptance/pages/studio/settings.py b/common/test/acceptance/pages/studio/settings.py index 2adfb161ca..eab10069cb 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,50 @@ 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, image_selector, file_to_upload): + """ + Upload image specified by image_selector and file_to_upload + """ + + self.q(css=image_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..7f189a4cd9 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', diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index 980484d57a..c818642def 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -58,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 @@ -97,7 +101,11 @@ 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 @@ -217,6 +225,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'] diff --git a/openedx/core/djangoapps/models/tests/test_course_details.py b/openedx/core/djangoapps/models/tests/test_course_details.py index 46f39982cf..fd778ee10a 100644 --- a/openedx/core/djangoapps/models/tests/test_course_details.py +++ b/openedx/core/djangoapps/models/tests/test_course_details.py @@ -95,6 +95,16 @@ 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, 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') + ) From 59f70cf85d310d81c50468d55c6dee77b69fc22a Mon Sep 17 00:00:00 2001 From: asadiqbal Date: Wed, 20 Apr 2016 20:44:11 +0500 Subject: [PATCH 3/3] WL-399 Course Details List Fields --- .../js/models/settings/course_details.js | 4 +- .../js/spec/views/settings/main_spec.js | 116 ++++++++++++++- cms/static/js/views/instructor_info.js | 92 ++++++++++++ cms/static/js/views/learning_info.js | 53 +++++++ cms/static/js/views/settings/main.js | 78 ++++++++-- cms/static/sass/views/_settings.scss | 136 ++++++++++++++++++ .../js/course-instructor-details.underscore | 43 ++++++ ...course-settings-learning-fields.underscore | 5 + .../js/mock/mock-settings-page.underscore | 29 ++++ cms/templates/settings.html | 38 ++++- common/lib/xmodule/xmodule/course_module.py | 30 ++++ .../test/acceptance/pages/studio/settings.py | 7 +- .../pages/studio/settings_advanced.py | 2 + .../core/djangoapps/models/course_details.py | 12 ++ .../models/tests/test_course_details.py | 20 +++ 15 files changed, 645 insertions(+), 20 deletions(-) create mode 100644 cms/static/js/views/instructor_info.js create mode 100644 cms/static/js/views/learning_info.js create mode 100644 cms/templates/js/course-instructor-details.underscore create mode 100644 cms/templates/js/course-settings-learning-fields.underscore diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 2e192e066b..202d1097e7 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -29,7 +29,9 @@ var CourseDetails = Backbone.Model.extend({ 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) { diff --git a/cms/static/js/spec/views/settings/main_spec.js b/cms/static/js/spec/views/settings/main_spec.js index 5c686f4ac9..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 () { @@ -39,14 +43,32 @@ define([ 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( + $(" @@ -448,10 +448,44 @@ CMS.URL.UPLOAD_ASSET = '${upload_asset_url}' ${_("Enter your YouTube video's ID (along with any restriction parameters)")}
  • - % 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 c24444b825..85188e7ddb 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -794,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 eab10069cb..9546de0c2b 100644 --- a/common/test/acceptance/pages/studio/settings.py +++ b/common/test/acceptance/pages/studio/settings.py @@ -261,12 +261,15 @@ class SettingsPage(CoursePage): # Return the joined path of the required asset. return os.sep.join(folders_list_in_path) - def upload_image(self, image_selector, file_to_upload): + def upload_image(self, upload_btn_selector, file_to_upload): """ Upload image specified by image_selector and file_to_upload """ - self.q(css=image_selector).results[0].click() + # 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') diff --git a/common/test/acceptance/pages/studio/settings_advanced.py b/common/test/acceptance/pages/studio/settings_advanced.py index 7f189a4cd9..87acaf1785 100644 --- a/common/test/acceptance/pages/studio/settings_advanced.py +++ b/common/test/acceptance/pages/studio/settings_advanced.py @@ -219,4 +219,6 @@ class AdvancedSettingsPage(CoursePage): 'enable_proctored_exams', 'enable_timed_exams', 'enable_subsection_gating', + 'learning_info', + 'instructor_info' ] diff --git a/openedx/core/djangoapps/models/course_details.py b/openedx/core/djangoapps/models/course_details.py index c818642def..0db50a3803 100644 --- a/openedx/core/djangoapps/models/course_details.py +++ b/openedx/core/djangoapps/models/course_details.py @@ -70,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): @@ -108,6 +110,8 @@ class CourseDetails(object): 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") @@ -243,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 fd778ee10a..661ddfe456 100644 --- a/openedx/core/djangoapps/models/tests/test_course_details.py +++ b/openedx/core/djangoapps/models/tests/test_course_details.py @@ -110,6 +110,26 @@ class CourseDetailsTestCase(ModuleStoreTestCase): 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()