Commit 2d6604a0 by Douglas Hall

Merge pull request #12318 from edx/hotfix/2016-05-02

Patch Release 2016-05-02 WL Studio Course Details Fields
parents 7d7d5a16 59f70cf8
......@@ -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
......@@ -132,37 +132,6 @@ def test_change_course_overview(_step):
type_in_codemirror(0, "<h1>Overview</h1>")
@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):
"""
......
......@@ -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
......
......@@ -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)
......
......@@ -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 **",
......
......@@ -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.");
}
......
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(
$("<script>", { id: "basic-learning-info-tpl", type: "text/template" }).text(learningInfoTpl)
);
appendSetFixtures(
$("<script>", { id: "basic-instructor-info-tpl", type: "text/template" }).text(instructorInfoTpl)
);
this.model = new CourseDetailsModel(modelData, {parse: true});
this.model = new CourseDetailsModel($.extend(true, {}, modelData, {
instructor_info: {
'instructors': [{"name": "","title": "","organization": "","image": "","bio": ""}]
}}), {parse: true});
this.model.urlRoot = urlRoot;
this.view = new MainView({
el: $('.settings-details'),
......@@ -178,5 +208,149 @@ 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);
});
it('can add learning information', function () {
this.view.$(SELECTORS.add_course_learning_info).click();
expect('click').not.toHaveBeenPreventedOn(SELECTORS.add_course_learning_info);
expect(this.model.get('learning_info').length).toEqual(2);
this.view.$(SELECTORS.add_course_learning_info).click();
expect(this.model.get('learning_info').length).toEqual(3);
});
it('can delete learning information', function () {
for (var i = 0 ; i < 2; i++) {
this.view.$(SELECTORS.add_course_learning_info).click();
}
expect(this.model.get('learning_info').length).toEqual(3);
expect(this.view.$(SELECTORS.delete_course_learning_info)).toExist();
this.view.$(SELECTORS.delete_course_learning_info).click();
expect(this.model.get('learning_info').length).toEqual(2);
});
it('can save learning information', function () {
expect(this.model.get('learning_info').length).toEqual(1);
var requests = AjaxHelpers.requests(this),
expectedJson = $.extend(true, {}, modelData, {
learning_info: ['testing info']
});
// Input some value.
this.view.$("#course-learning-info-0").val('testing info');
this.view.$("#course-learning-info-0").trigger('change');
this.view.saveView();
AjaxHelpers.expectJsonRequest(
requests, 'POST', urlRoot, expectedJson
);
AjaxHelpers.respondWithJson(requests, expectedJson);
});
it('can add instructor information', function () {
this.view.$(SELECTORS.add_course_instructor_info).click();
expect(this.model.get('instructor_info').instructors.length).toEqual(2);
this.view.$(SELECTORS.add_course_instructor_info).click();
expect(this.model.get('instructor_info').instructors.length).toEqual(3);
});
it('can delete instructor information', function () {
this.view.$(SELECTORS.add_course_instructor_info).click();
expect(this.model.get('instructor_info').instructors.length).toEqual(2);
expect(this.view.$(SELECTORS.remove_instructor_data)).toExist();
this.view.$(SELECTORS.remove_instructor_data).click();
expect(this.model.get('instructor_info').instructors.length).toEqual(1);
});
it('can save instructor information', function () {
var requests = AjaxHelpers.requests(this),
expectedJson = $.extend(true, {}, modelData, {
instructor_info: {
instructors:
[{
"name": "test_name",
"title": "test_title",
"organization": "test_org",
"image": "",
"bio": "test_bio"
}]
}
});
// Input some value.
this.view.$("#course-instructor-name-0").val('test_name').trigger('change');
this.view.$("#course-instructor-title-0").val('test_title').trigger('change');
this.view.$("#course-instructor-organization-0").val('test_org').trigger('change');
this.view.$("#course-instructor-bio-0").val('test_bio').trigger('change');
this.view.saveView();
AjaxHelpers.expectJsonRequest(
requests, 'POST', urlRoot, expectedJson
);
AjaxHelpers.respondWithJson(requests, expectedJson);
});
});
});
// Backbone Application View: Instructor Information
define([ // jshint ignore:line
'jquery',
'underscore',
'backbone',
'gettext',
'js/utils/templates',
"js/models/uploads",
"js/views/uploads"
],
function ($, _, Backbone, gettext, TemplateUtils, FileUploadModel, FileUploadDialog) {
'use strict';
var InstructorInfoView = Backbone.View.extend({
events : {
'click .remove-instructor-data': 'removeInstructor',
'click .action-upload-instructor-image': "uploadInstructorImage"
},
initialize: function() {
// Set up the initial state of the attributes set for this model instance
_.bindAll(this, 'render');
this.template = this.loadTemplate('course-instructor-details');
this.listenTo(this.model, 'change:instructor_info', this.render);
},
loadTemplate: function(name) {
// Retrieve the corresponding template for this model
return TemplateUtils.loadTemplate(name);
},
render: function() {
// Assemble the render view for this model.
$(".course-instructor-details-fields").empty();
var self = this;
$.each(this.model.get('instructor_info').instructors, function( index, data ) {
$(self.el).append(self.template({
data: data,
index: index
}));
});
// Avoid showing broken image on mistyped/nonexistent image
this.$el.find('img').error(function() {
$(this).hide();
});
this.$el.find('img').load(function() {
$(this).show();
});
},
removeInstructor: function(event) {
/*
* Remove course Instructor fields.
* */
event.preventDefault();
var index = event.currentTarget.getAttribute('data-index'),
instructors = this.model.get('instructor_info').instructors.slice(0);
instructors.splice(index, 1);
this.model.set('instructor_info', {instructors: instructors});
},
uploadInstructorImage: function(event) {
/*
* Upload instructor image.
* */
event.preventDefault();
var index = event.currentTarget.getAttribute('data-index'),
instructors = this.model.get('instructor_info').instructors.slice(0),
instructor = instructors[index];
var upload = new FileUploadModel({
title: gettext("Upload instructor image."),
message: gettext("Files must be in JPEG or PNG format."),
mimeTypes: ['image/jpeg', 'image/png']
});
var self = this;
var modal = new FileUploadDialog({
model: upload,
onSuccess: function(response) {
instructor.image = response.asset.url;
self.model.set('instructor_info', {instructors: instructors});
self.model.trigger('change', self.model);
self.model.trigger('change:instructor_info', self.model);
}
});
modal.show();
}
});
return InstructorInfoView;
});
// Backbone Application View: Course Learning Information
define([ // jshint ignore:line
'jquery',
'underscore',
'backbone',
'gettext',
'js/utils/templates'
],
function ($, _, Backbone, gettext, TemplateUtils) {
'use strict';
var LearningInfoView = Backbone.View.extend({
events: {
'click .delete-course-learning-info': "removeLearningInfo"
},
initialize: function() {
// Set up the initial state of the attributes set for this model instance
_.bindAll(this, 'render');
this.template = this.loadTemplate('course-settings-learning-fields');
this.listenTo(this.model, 'change:learning_info', this.render);
},
loadTemplate: function(name) {
// Retrieve the corresponding template for this model
return TemplateUtils.loadTemplate(name);
},
render: function() {
// rendering for this model
$("li.course-settings-learning-fields").empty();
var self = this;
var learning_information = this.model.get('learning_info');
$.each(learning_information, function( index, info ) {
$(self.el).append(self.template({index: index, info: info, info_count: learning_information.length }));
});
},
removeLearningInfo: function(event) {
/*
* Remove course learning fields.
* */
event.preventDefault();
var index = event.currentTarget.getAttribute('data-index'),
existing_info = _.clone(this.model.get('learning_info'));
existing_info.splice(index, 1);
this.model.set('learning_info', existing_info);
}
});
return LearningInfoView;
});
......@@ -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);
......@@ -838,6 +838,142 @@
}
}
&.course-learning-info {
.list-input {
margin-bottom: $baseline;
.course-settings-learning-fields {
.field {
.input-learning-info {
width: flex-grid(10, 12);
display: inline-block;
}
}
}
}
.actions {
width: flex-grid(9, 9);
.new-button {
@extend %btn-primary-green;
}
.delete-button {
margin: 0;
}
}
}
&.instructor-types {
.list-input {
.course-instructor-details-fields {
.field {
width: flex-grid(2, 6);
&.field-course-instructor-bio {
width: flex-grid(6, 6);
}
&.current-instructor-image {
width: flex-grid(6, 6);
text-align: left;
padding: 0;
.wrapper-instructor-image {
margin: 15px auto;
}
}
}
}
&:last-child {
margin-bottom: 0;
}
}
.field-group {
@include clearfix();
width: flex-grid(9, 9);
margin-bottom: ($baseline*1.5);
border-bottom: 1px solid $gray-l5;
padding-bottom: ($baseline*1.5);
&:last-child {
border: none;
padding-bottom: 0;
}
.field {
display: inline-block;
vertical-align: top;
width: flex-grid(3, 6);
margin-bottom: ($baseline/2);
margin-right: flex-gutter();
}
// specific fields - course image
.field-course-instructor-image {
margin-bottom: ($baseline/2);
padding: ($baseline/2) $baseline;
background: $gray-l5;
text-align: left;
.wrapper-instructor-image {
display: block;
width: 375px;
height: 200px;
overflow: hidden;
margin: 0 auto;
border: 1px solid $gray-l4;
box-shadow: 0 1px 1px $shadow-l1;
padding: ($baseline/2);
background: $white;
}
.instructor-image {
display: block;
width: 100%;
min-height: 100%;
}
.msg {
@extend %t-copy-sub2;
display: block;
margin-top: ($baseline/2);
color: $gray-l3;
}
}
.wrapper-input {
@include clearfix();
width: flex-grid(9,9);
.input {
float: left;
width: flex-grid(6,9);
margin-right: flex-gutter();
}
.action-upload-instructor-image {
@extend %ui-btn-flat-outline;
float: right;
width: flex-grid(2,9);
margin-top: ($baseline/4);
padding: ($baseline/2) $baseline;
}
}
}
.actions {
width: flex-grid(9, 9);
.new-button {
@extend %btn-primary-green;
}
.delete-button {
margin: 0;
}
}
}
// specific fields - advanced settings
&.advanced-policies {
......
<li class="field-group">
<div class="field text field-course-instructor-name">
<label for="course-instructor-name-<%- index %>"><%- gettext("Name") %></label>
<input type="text" class="long" id="course-instructor-name-<%- index %>" value="<%- data['name'] %>" data-index=<%- index %> data-field="name" placeholder="<%- gettext('Instructor Name') %>" />
<span class="tip tip-stacked"><%- gettext("Please add the instructor's name")%></span>
</div>
<div class="field text field-course-instructor-title">
<label for="course-instructor-title-<%- index %>"><%- gettext("Title") %></label>
<input type="text" class="long" id="course-instructor-title-<%- index %>" value="<%- data['title'] %>" data-index=<%- index %> data-field="title" placeholder="<%- gettext('Instructor Title') %>" />
<span class="tip tip-stacked"><%- gettext("Please add the instructor's title")%></span>
</div>
<div class="field text field-course-instructor-organization">
<label for="course-instructor-organization-<%- index %>"><%- gettext("Organization") %></label>
<input type="text" class="long" id="course-instructor-organization-<%- index %>" value = "<%- data['organization'] %>" data-index=<%- index %> data-field="organization" placeholder="<%- gettext('Organization Name') %>" />
<span class="tip tip-stacked"><%- gettext("Please add the institute where the instructor is associated")%></span>
</div>
<div class="field text field-course-instructor-bio">
<label for="course-instructor-bio-<%- index %>"><%- gettext("Biography") %></label>
<textarea class="short text" id="course-instructor-bio-<%- index %>" data-index=<%- index %> data-field="bio" placeholder="<%- gettext('Instructor Biography') %>" ><%- data['bio'] %></textarea>
<span class="tip tip-stacked"><%- gettext("Please add the instructor's biography")%></span>
</div>
<div class="field image field-course-instructor-image current-instructor-image">
<label for="course-instructor-image-<%- index %>"><%- gettext("Photo") %></label>
<span class="wrapper-instructor-image">
<img class="instructor-image" src="<%- data['image']%>" alt="<%- gettext('Instructor Photo') %>" />
</span>
<div class="wrapper-input">
<div class="input">
<input type="text" dir="ltr" class="long new-instructor-image-url" id="course-instructor-image-<%- index %>" value="<%- data['image'] %>" data-field="image" placeholder="<%- gettext('Instructor Photo URL') %>" autocomplete="off" />
<span class="tip tip-stacked"><%- gettext("Please add a photo of the instructor (Note: only JPEG or PNG format supported)")%></span>
</div>
<button type="button" class="action action-upload-instructor-image" data-index=<%- index %>><%- gettext("Upload Photo") %></button>
</div>
</div>
<div class="actions">
<button type="button" class="button delete-button standard remove-item remove-instructor-data" data-index=<%- index %>><%- gettext("Delete") %></button>
</div>
</li>
<div class="field text" id="fields-course-learning-info-<%- index %>">
<label for= "course-learning-info-<%- index %>"><%- gettext("Learning Outcome") %> <%- index + 1 %></label>
<input type="text" class="input-learning-info" id="course-learning-info-<%- index %>" value="<%- info %>" data-index="<%- index %>" placeholder="<%- gettext('Add a learning outcome here') %>">
<button type="button" class="button delete-button standard remove-item delete-course-learning-info" data-index="<%- index %>"><%- gettext("Delete") %></button>
</div>
......@@ -105,6 +105,125 @@
</select>
<span class="tip tip-stacked">Identify the course language here. This is used to assist users find courses that are taught in a specific language.</span>
</li>
</ol>
</section>
<section class="group-settings marketing">
<header>
<h2 class="title-2">Introducing Your Course</h2>
<span class="tip">Information for prospective students</span>
</header>
<ol class="list-input">
<li class="field text" id="field-course-title">
<label for="course-title">Course Title</label>
<input type="text" id="course-title" data-display-name="${context_course.display_name}">
<span class="tip tip-stacked">Displayed as title on the course details page. Limit to 50 characters.</span>
</li>
<li class="field text" id="field-course-subtitle">
<label for="course-subtitle">Course Subtitle</label>
<input type="text" id="course-subtitle">
<span class="tip tip-stacked">Displayed as subtitle on the course details page. Limit to 150 characters.</span>
</li>
<li class="field text" id="field-course-duration">
<label for="course-duration">Course Duration</label>
<input type="text" id="course-duration">
<span class="tip tip-stacked">Displayed on the course details page. Limit to 50 characters.</span>
</li>
<li class="field text" id="field-course-description">
<label for="course-description">Course Description</label>
<textarea class="text" id="course-description"></textarea>
<span class="tip tip-stacked">Displayed on the course details page. Limit to 1000 characters.</span>
</li>
<li class="field image" id="field-course-image">
<label for="course-image-url">Course Card Image</label>
<div class="current current-course-image">
<span class="wrapper-course-image">
<img class="course-image placeholder" id="course-image" src="${course_image_url}" alt="Course Image"/>
</span>
<span class="msg msg-empty">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)</span>
</div>
<div class="wrapper-input">
<div class="input">
## Translators: This is the placeholder text for a field that requests the URL for a course image
<input type="text" dir="ltr" class="long new-course-image-url" id="course-image-url" value="" placeholder="Your course image URL" autocomplete="off" />
<span class="tip tip-stacked">Please provide a valid path and name to your course image (Note: only JPEG or PNG format supported)</span>
</div>
<button type="button" class="action action-upload-image" id="upload-course-image">Upload Course Card Image</button>
</div>
</li>
<li class="field image" id="field-banner-image">
<label for="banner-image-url">Course Banner Image</label>
<div class="current current-course-image">
<span class="wrapper-course-image">
<img class="course-image placeholder" id="banner-image" src="${banner_image_url}" alt="Course Banner Image"/>
</span>
<span class="msg msg-empty">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)</span>
</div>
<div class="wrapper-input">
<div class="input">
## Translators: This is the placeholder text for a field that requests the URL for a course banner image
<input type="text" dir="ltr" class="long new-course-image-url" id="banner-image-url" value="" placeholder="Your banner image URL" autocomplete="off" />
<span class="tip tip-stacked">Please provide a valid path and name to your banner image (Note: only JPEG or PNG format supported)</span>
</div>
<button type="button" class="action action-upload-image" id="upload-banner-image">Upload Course Banner Image</button>
</div>
</li>
<li class="field image" id="field-video-thumbnail-image">
<label for="video-thumbnail-image-url">Course Video Thumbnail Image</label>
<div class="current current-course-image">
<span class="wrapper-course-image">
<img class="course-image placeholder" id="video-thumbnail-image" src="${video_thumbnail_image_url}" alt="Video Thumbnail Image"/>
</span>
<span class="msg msg-empty">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)</span>
</div>
<div class="wrapper-input">
<div class="input">
## Translators: This is the placeholder text for a field that requests the URL for a course video thumbnail image
<input type="text" dir="ltr" class="long new-course-image-url" id="video-thumbnail-image-url" value="" placeholder="Your video thumbnail image URL" autocomplete="off" />
<span class="tip tip-stacked">Please provide a valid path and name to your video thumbnail image (Note: only JPEG or PNG format supported)</span>
</div>
<button type="button" class="action action-upload-image" id="upload-video-thumbnail-image">Upload Video Thumbnail Image</button>
</div>
</li>
</ol>
</section>
<section class="group-settings course-learning-info">
<header>
<h2 class="title-2">Learning Outcomes</h2>
<span class="tip">Add the learning outcomes for this course</span>
</header>
<ol class="list-input enum">
<li class="course-settings-learning-fields"></li>
</ol>
<div class="actions">
<button type="button" class="action action-primary button new-button add-course-learning-info">
<i class="icon fa fa-plus icon-inline"></i>Add Learning Outcome
</button>
</div>
</section>
<section class="group-settings instructor-types">
<header>
<h2 class="title-2">Instructors</h2>
<span class="tip">Add details about the instructors for this course</span>
</header>
<ol class="list-input enum">
<li class="course-instructor-details-fields"></li>
</ol>
<div class="actions">
<button type="button" class="action action-primary button new-button add-course-instructor-info">
<i class="icon fa fa-plus icon-inline"></i>Add Instructor
</button>
</div>
</section>
</form>
......@@ -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
"""
......
......@@ -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]
......@@ -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'
]
......@@ -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()
......@@ -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
......
......@@ -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()
......
......@@ -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
......
......@@ -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')
)
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