From 0cadc8af36d088a57eddb550d73cdaeed2762e10 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 12 Jul 2013 14:13:53 -0400 Subject: [PATCH 001/395] Change status bar UI --- .../css/combinedopenended/display.scss | 5 ++++- .../combined_open_ended_modulev1.py | 22 ++++++++++++++++--- .../combined_open_ended_status.html | 13 ++--------- 3 files changed, 25 insertions(+), 15 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 78f0213c8d..951242ac6c 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -56,13 +56,16 @@ section.legend-container { section.combined-open-ended-status { + padding: 20px 20px 20px 0px; + .statusitem { color: #2C2C2C; background-color : #d4d4d4; font-size: .9em; - padding: 2px; + padding: 20px; display: inline; width: 20%; + margin-right: 20px; .show-results { margin-top: .3em; text-align:right; diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 1fe62035e6..f5feb36ae6 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -33,6 +33,8 @@ TRUE_DICT = ["True", True, "TRUE", "true"] HUMAN_TASK_TYPE = { 'selfassessment': "Self Assessment", 'openended': "edX Assessment", + 'ml_grading.conf' : "AI Assessment", + 'peer_grading.conf' : "Peer Assessment", } # Default value that controls whether or not to skip basic spelling checks in the controller @@ -468,6 +470,19 @@ class CombinedOpenEndedV1Module(): } return last_response_dict + def extract_human_name_from_task(self, task_xml): + tree = etree.fromstring(task_xml) + log.info(etree.tostring(tree)) + payload = tree.xpath("/openended/openendedparam/grader_payload") + if len(payload)==0: + task_name = "selfassessment" + else: + inner_payload = json.loads(payload[0].text) + task_name = inner_payload['grader_settings'] + + human_task = HUMAN_TASK_TYPE[task_name] + return human_task + def update_task_states(self): """ Updates the task state of the combined open ended module with the task state of the current child module. @@ -689,9 +704,10 @@ class CombinedOpenEndedV1Module(): Output: The status html to be rendered """ status = [] - for i in xrange(0, self.current_task_number + 1): - task_data = self.get_last_response(i) - task_data.update({'task_number': i + 1}) + for i in xrange(0, len(self.task_xml)): + human_task_name = self.extract_human_name_from_task(self.task_xml[i]) + + task_data = {'task_number': i + 1, 'human_task' : human_task_name, 'current' : self.current_task_number==i} status.append(task_data) context = { diff --git a/lms/templates/combinedopenended/combined_open_ended_status.html b/lms/templates/combinedopenended/combined_open_ended_status.html index d13077737f..5495885044 100644 --- a/lms/templates/combinedopenended/combined_open_ended_status.html +++ b/lms/templates/combinedopenended/combined_open_ended_status.html @@ -1,22 +1,13 @@
-
- Status -
%for i in xrange(0,len(status_list)): <%status=status_list[i]%> - %if i==len(status_list)-1: + %if status['current']:
%else:
%endif - %if status['grader_type'] in grader_type_image_dict and render_via_ajax: - <% grader_image = grader_type_image_dict[status['grader_type']]%> - - %else: - ${status['human_task']} - %endif - (${status['human_state']}) + ${status['human_task']}
%endfor
From ffad965536e75c427d4e79a72e1e68f5ed52ff1f Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 12 Jul 2013 15:06:11 -0400 Subject: [PATCH 002/395] fix some combinedopenended ui and data routing --- .../lib/xmodule/xmodule/css/combinedopenended/display.scss | 6 ++++++ .../open_ended_grading_classes/peer_grading_service.py | 6 ++++-- common/lib/xmodule/xmodule/peer_grading_module.py | 3 ++- .../combinedopenended/combined_open_ended_status.html | 4 ++++ 4 files changed, 16 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 951242ac6c..86c028a8b0 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -108,6 +108,12 @@ section.combined-open-ended-status { float: right; } } + + .icon-caret-right { + display: inline-block; + vertical-align: baseline; + margin-right: ($baseline/4); + } } div.combined-rubric-container { diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py index 56bd1ec0a8..f80aff610a 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py @@ -41,7 +41,7 @@ class PeerGradingService(GradingService): return self.try_to_decode(self._render_rubric(response)) def save_grade(self, location, grader_id, submission_id, score, feedback, submission_key, rubric_scores, - submission_flagged): + submission_flagged, answer_unknown): data = {'grader_id': grader_id, 'submission_id': submission_id, 'score': score, @@ -50,7 +50,9 @@ class PeerGradingService(GradingService): 'location': location, 'rubric_scores': rubric_scores, 'rubric_scores_complete': True, - 'submission_flagged': submission_flagged} + 'submission_flagged': submission_flagged, + 'answer_unknown' : answer_unknown, + } return self.try_to_decode(self.post(self.save_grade_url, data)) def is_student_calibrated(self, problem_location, grader_id): diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 7df444a892..0dbdde836e 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -310,10 +310,11 @@ class PeerGradingModule(PeerGradingFields, XModule): submission_key = data.get('submission_key') rubric_scores = data.getlist('rubric_scores[]') submission_flagged = data.get('submission_flagged') + answer_unknown = data.get('answer_unknown', False) try: response = self.peer_gs.save_grade(location, grader_id, submission_id, - score, feedback, submission_key, rubric_scores, submission_flagged) + score, feedback, submission_key, rubric_scores, submission_flagged, answer_unknown) return response except GradingServiceError: # This is a dev_facing_error diff --git a/lms/templates/combinedopenended/combined_open_ended_status.html b/lms/templates/combinedopenended/combined_open_ended_status.html index 5495885044..66afb79021 100644 --- a/lms/templates/combinedopenended/combined_open_ended_status.html +++ b/lms/templates/combinedopenended/combined_open_ended_status.html @@ -8,7 +8,11 @@
%endif ${status['human_task']} +
+ %if i + %endif %endfor
From 59f639d9d2e1891ff6979c97d51d4005ee5021c4 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 18 Jul 2013 14:40:11 -0400 Subject: [PATCH 003/395] Change js around --- .../js/src/combinedopenended/display.coffee | 110 ++++++++++-------- 1 file changed, 62 insertions(+), 48 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 5939fbcdd8..7eb4f06805 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -1,9 +1,11 @@ class @Rubric constructor: () -> - @initialize: (location) -> - $('.rubric').data("location", location) - $('input[class="score-selection"]').change @tracking_callback + @initialize: (location,el) -> + @el = el + @$el = $(el) + @$('.rubric').data("location", location) + @$('input[class="score-selection"]').change @tracking_callback # set up the hotkeys $(window).unbind('keydown', @keypress_callback) $(window).keydown @keypress_callback @@ -12,8 +14,11 @@ class @Rubric @category = $(@categories.first()) @category.prepend('> ') @category_index = 0 - - + + # locally scoped jquery. + $: (selector) -> + $(selector, @el) + @keypress_callback: (event) => # don't try to do this when user is typing in a text input if $(event.target).is('input, textarea') @@ -86,34 +91,48 @@ class @Rubric return true class @CombinedOpenEnded - constructor: (element) -> - @element=element - @reinitialize(element) + constructor: (el) -> + @el=el + @$el = $(el) + @reinitialize(el) $(window).keydown @keydown_handler $(window).keyup @keyup_handler + # locally scoped jquery. + $: (selector) -> + $(selector, @el) + reinitialize: (element) -> - @wrapper=$(element).find('section.xmodule_CombinedOpenEndedModule') - @el = $(element).find('section.combined-open-ended') - @combined_open_ended=$(element).find('section.combined-open-ended') - @id = @el.data('id') - @ajax_url = @el.data('ajax-url') - @state = @el.data('state') - @task_count = @el.data('task-count') - @task_number = @el.data('task-number') - @accept_file_upload = @el.data('accept-file-upload') - @location = @el.data('location') + @wrapper=@$('section.xmodule_CombinedOpenEndedModule') + @coe = @$('section.combined-open-ended') + + #Get data from combinedopenended + @allow_reset = @coe.data('allow_reset') + @id = @coe.data('id') + @ajax_url = @coe.data('ajax-url') + @state = @coe.data('state') + @task_count = @coe.data('task-count') + @task_number = @coe.data('task-number') + @accept_file_upload = @coe.data('accept-file-upload') + @location = @coe.data('location') + @child_state = @coe.data('state') + @child_type = @coe.data('child-type') + # set up handlers for click tracking Rubric.initialize(@location) @is_ctrl = false - @allow_reset = @el.data('allow_reset') + #Setup reset @reset_button = @$('.reset-button') @reset_button.click @reset + + #Setup next problem @next_problem_button = @$('.next-step-button') @next_problem_button.click @next_problem + @status_container = @$('.status-elements') + #setup show results @show_results_button=@$('.show-results-button') @show_results_button.click @show_results @@ -122,33 +141,32 @@ class @CombinedOpenEnded # valid states: 'initial', 'assessing', 'post_assessment', 'done' Collapsible.setCollapsibles(@el) - @submit_evaluation_button = $('.submit-evaluation-button') + @submit_evaluation_button = @$('.submit-evaluation-button') @submit_evaluation_button.click @message_post - @results_container = $('.result-container') - @combined_rubric_container = $('.combined-rubric-container') + @results_container = @$('.result-container') + @combined_rubric_container = @$('.combined-rubric-container') - @legend_container= $('.legend-container') + @legend_container= @$('.legend-container') @show_legend_current() # Where to put the rubric once we load it - @el = $(element).find('section.open-ended-child') - @errors_area = @$('.error') - @answer_area = @$('textarea.answer') - @prompt_container = @$('.prompt') - @rubric_wrapper = @$('.rubric-wrapper') - @hint_wrapper = @$('.hint-wrapper') - @message_wrapper = @$('.message-wrapper') - @submit_button = @$('.submit-button') - @child_state = @el.data('state') - @child_type = @el.data('child-type') + + @oe = @$('section.open-ended-child') + @errors_area = @$(@oe).find('.error') + @answer_area = @$(@oe).find('textarea.answer') + @prompt_container = @$(@oe).find('.prompt') + @rubric_wrapper = @$(@oe).find('.rubric-wrapper') + @hint_wrapper = @$(@oe).find('.hint-wrapper') + @message_wrapper = @$(@oe).find('.message-wrapper') + @submit_button = @$(@oe).find('.submit-button') if @child_type=="openended" - @skip_button = @$('.skip-button') + @skip_button = @$(@oe).find('.skip-button') @skip_button.click @skip_post_assessment - @file_upload_area = @$('.file-upload') + @file_upload_area = $(@oe).find('.file-upload') @can_upload_files = false - @open_ended_child= @$('.open-ended-child') + @open_ended_child= $(@oe).find('.open-ended-child') @out_of_sync_message = 'The problem state got out of sync. Try reloading the page.' @@ -166,10 +184,6 @@ class @CombinedOpenEnded @show_combined_rubric_current() @show_results_current() - # locally scoped jquery. - $: (selector) -> - $(selector, @el) - show_results_current: () => data = {'task_number' : @task_number-1} $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => @@ -223,10 +237,10 @@ class @CombinedOpenEnded evaluation_scoring = $(event.target).parent() fd = new FormData() - feedback = evaluation_scoring.find('textarea.feedback-on-feedback')[0].value - submission_id = external_grader_message.find('input.submission_id')[0].value - grader_id = external_grader_message.find('input.grader_id')[0].value - score = evaluation_scoring.find("input:radio[name='evaluation-score']:checked").val() + feedback = @$(evaluation_scoring).find('textarea.feedback-on-feedback')[0].value + submission_id = @$(external_grader_message).find('input.submission_id')[0].value + grader_id = @$(external_grader_message).find('input.grader_id')[0].value + score = @$(evaluation_scoring).find("input:radio[name='evaluation-score']:checked").val() fd.append('feedback', feedback) fd.append('submission_id', submission_id) @@ -474,11 +488,11 @@ class @CombinedOpenEnded @errors_area.html(@out_of_sync_message) gentle_alert: (msg) => - if @el.find('.open-ended-alert').length - @el.find('.open-ended-alert').remove() + if @$el.find('.open-ended-alert').length + @$el.find('.open-ended-alert').remove() alert_elem = "
" + msg + "
" - @el.find('.open-ended-action').after(alert_elem) - @el.find('.open-ended-alert').css(opacity: 0).animate(opacity: 1, 700) + @$el.find('.open-ended-action').after(alert_elem) + @$el.find('.open-ended-alert').css(opacity: 0).animate(opacity: 1, 700) queueing: => if @child_state=="assessing" and @child_type=="openended" From 3509aea2b1578a42741485724581c17b8fa703ca Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 19 Jul 2013 14:21:01 -0400 Subject: [PATCH 004/395] Redo js --- .../js/src/combinedopenended/display.coffee | 175 +++++++++++------- .../peergrading/peer_grading_problem.coffee | 4 +- .../src/staff_grading/staff_grading.coffee | 4 +- 3 files changed, 107 insertions(+), 76 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 7eb4f06805..bf569c984a 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -3,22 +3,17 @@ class @Rubric @initialize: (location,el) -> @el = el - @$el = $(el) - @$('.rubric').data("location", location) - @$('input[class="score-selection"]').change @tracking_callback + $('.rubric',@el).data("location", location) + $('input[class="score-selection"]',@el).change @tracking_callback # set up the hotkeys $(window).unbind('keydown', @keypress_callback) $(window).keydown @keypress_callback # display the 'current' carat - @categories = $('.rubric-category') - @category = $(@categories.first()) + @categories = $('.rubric-category',el) + @category = $(@categories.first(),el) @category.prepend('> ') @category_index = 0 - # locally scoped jquery. - $: (selector) -> - $(selector, @el) - @keypress_callback: (event) => # don't try to do this when user is typing in a text input if $(event.target).is('input, textarea') @@ -47,14 +42,14 @@ class @Rubric old_category_text = @category.html().substring(5) @category.html(old_category_text) @category_index++ - @category = $(@categories[@category_index]) + @category = $(@categories[@category_index],@el) @category.prepend('> ') @tracking_callback: (event) -> target_selection = $(event.target).val() # chop off the beginning of the name so that we can get the number of the category category = $(event.target).data("category") - location = $('.rubric').data('location') + location = $('.rubric',@el).data('location') # probably want the original problem location as well data = {location: location, selection: target_selection, category: category} @@ -64,7 +59,7 @@ class @Rubric # finds the scores for each rubric category @get_score_list: () => # find the number of categories: - num_categories = $('.rubric-category').length + num_categories = $('.rubric-category',@el).length score_lst = [] # get the score for each one @@ -83,14 +78,43 @@ class @Rubric @check_complete: () -> # check to see whether or not any categories have not been scored - num_categories = $('.rubric-category').length + num_categories = $('.rubric-category',@el).length for i in [0..(num_categories-1)] - score = $("input[name='score-selection-#{i}']:checked").val() + score = $("input[name='score-selection-#{i}']:checked",@el).val() if score == undefined return false return true class @CombinedOpenEnded + + wrapper_sel: 'section.xmodule_CombinedOpenEndedModule' + coe_sel: 'section.combined-open-ended' + reset_button_sel: '.reset-button' + next_step_sel: '.next-step-button' + status_container_sel: '.status-elements' + show_results_sel: '.show-results-button' + question_header_sel: '.question-header' + submit_evaluation_sel: '.submit-evaluation-button' + result_container_sel: 'div.result-container' + combined_rubric_sel: '.combined-rubric-container' + legend_sel: '.legend-container' + open_ended_child_sel: 'section.open-ended-child' + error_sel: '.error' + answer_area_sel: 'textarea.answer' + prompt_sel: '.prompt' + rubric_wrapper_sel: '.rubric-wrapper' + hint_wrapper_sel: '.hint-wrapper' + message_wrapper_sel: '.message-wrapper' + submit_button_sel: '.submit-button' + skip_button_sel: '.skip-button' + file_upload_sel: '.file-upload' + file_upload_box_sel: '.file-upload-box' + file_upload_preview_sel: '.file-upload-preview' + fof_sel: 'textarea.feedback-on-feedback' + sub_id_sel: 'input.submission_id' + grader_id_sel: 'input.grader_id' + grader_status_sel: '.grader-status' + constructor: (el) -> @el=el @$el = $(el) @@ -103,8 +127,8 @@ class @CombinedOpenEnded $(selector, @el) reinitialize: (element) -> - @wrapper=@$('section.xmodule_CombinedOpenEndedModule') - @coe = @$('section.combined-open-ended') + @wrapper=@$(@wrapper_sel) + @coe = @$(@coe_sel) #Get data from combinedopenended @allow_reset = @coe.data('allow_reset') @@ -118,55 +142,61 @@ class @CombinedOpenEnded @child_state = @coe.data('state') @child_type = @coe.data('child-type') + console.log(@child_state) + # set up handlers for click tracking - Rubric.initialize(@location) + Rubric.initialize(@location,@coe) @is_ctrl = false - + console.log("init rubric") #Setup reset - @reset_button = @$('.reset-button') + @reset_button = @$(@reset_button_sel) @reset_button.click @reset - + console.log("init reset") #Setup next problem - @next_problem_button = @$('.next-step-button') + @next_problem_button = @$(@next_step_sel) @next_problem_button.click @next_problem - @status_container = @$('.status-elements') + @status_container = @$(@status_container_sel) #setup show results - @show_results_button=@$('.show-results-button') + @show_results_button=@$(@show_results_sel) @show_results_button.click @show_results - @question_header = @$('.question-header') + @question_header = @$(@question_header_sel) @question_header.click @collapse_question # valid states: 'initial', 'assessing', 'post_assessment', 'done' - Collapsible.setCollapsibles(@el) - @submit_evaluation_button = @$('.submit-evaluation-button') + console.log("init collapse") + Collapsible.setCollapsibles(@$el) + console.log("finish collapse") + @submit_evaluation_button = @$(@submit_evaluation_sel) @submit_evaluation_button.click @message_post - @results_container = @$('.result-container') - @combined_rubric_container = @$('.combined-rubric-container') + @results_container = @$(@result_container_sel) + console.log(@results_container) + @combined_rubric_container = @$(@combined_rubric_sel) - @legend_container= @$('.legend-container') + @legend_container= @$(@legend_sel) @show_legend_current() # Where to put the rubric once we load it - - @oe = @$('section.open-ended-child') - @errors_area = @$(@oe).find('.error') - @answer_area = @$(@oe).find('textarea.answer') - @prompt_container = @$(@oe).find('.prompt') - @rubric_wrapper = @$(@oe).find('.rubric-wrapper') - @hint_wrapper = @$(@oe).find('.hint-wrapper') - @message_wrapper = @$(@oe).find('.message-wrapper') - @submit_button = @$(@oe).find('.submit-button') + console.log("started child") + @oe = @$(@open_ended_child_sel) + @errors_area = @$(@oe).find(@error_sel) + @answer_area = @$(@oe).find(@answer_area_sel) + @prompt_container = @$(@oe).find(@prompt_sel) + @rubric_wrapper = @$(@oe).find(@rubric_wrapper_sel) + @hint_wrapper = @$(@oe).find(@hint_wrapper_sel) + @message_wrapper = @$(@oe).find(@message_wrapper_sel) + @submit_button = @$(@oe).find(@submit_button_sel) if @child_type=="openended" - @skip_button = @$(@oe).find('.skip-button') + @skip_button = @$(@oe).find(@skip_button_sel) @skip_button.click @skip_post_assessment - @file_upload_area = $(@oe).find('.file-upload') + @file_upload_area = $(@oe).find(@file_upload_sel) @can_upload_files = false - @open_ended_child= $(@oe).find('.open-ended-child') + @open_ended_child= $(@oe).find(@open_ended_child_sel) + console.log("Passed child") @out_of_sync_message = 'The problem state got out of sync. Try reloading the page.' @@ -188,9 +218,10 @@ class @CombinedOpenEnded data = {'task_number' : @task_number-1} $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => if response.success - @results_container.after(response.html).remove() - @results_container = $('div.result-container') - @submit_evaluation_button = $('.submit-evaluation-button') + if (results_container?) + @results_container.after(response.html).remove() + @results_container = @$(@result_container_sel) + @submit_evaluation_button = @$(@submit_evaluation_sel) @submit_evaluation_button.click @message_post Collapsible.setCollapsibles(@results_container) # make sure we still have click tracking @@ -204,8 +235,8 @@ class @CombinedOpenEnded $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => if response.success @results_container.after(response.html).remove() - @results_container = $('div.result-container') - @submit_evaluation_button = $('.submit-evaluation-button') + @results_container = @$(@result_container_sel) + @submit_evaluation_button = @$(@submit_evaluation_sel) @submit_evaluation_button.click @message_post Collapsible.setCollapsibles(@results_container) else @@ -216,30 +247,30 @@ class @CombinedOpenEnded $.postWithPrefix "#{@ajax_url}/get_combined_rubric", data, (response) => if response.success @combined_rubric_container.after(response.html).remove() - @combined_rubric_container= $('div.combined_rubric_container') + @combined_rubric_container= $(@combined_rubric_sel) show_status_current: () => data = {} $.postWithPrefix "#{@ajax_url}/get_status", data, (response) => if response.success @status_container.after(response.html).remove() - @status_container= $('.status-elements') + @status_container= $(@status_container_sel) show_legend_current: () => data = {} $.postWithPrefix "#{@ajax_url}/get_legend", data, (response) => if response.success @legend_container.after(response.html).remove() - @legend_container= $('.legend-container') + @legend_container= $(@legend_sel) message_post: (event)=> external_grader_message=$(event.target).parent().parent().parent() evaluation_scoring = $(event.target).parent() fd = new FormData() - feedback = @$(evaluation_scoring).find('textarea.feedback-on-feedback')[0].value - submission_id = @$(external_grader_message).find('input.submission_id')[0].value - grader_id = @$(external_grader_message).find('input.grader_id')[0].value + feedback = @$(evaluation_scoring).find(@fof_sel)[0].value + submission_id = @$(external_grader_message).find(@sub_id_sel)[0].value + grader_id = @$(external_grader_message).find(@grader_id_sel)[0].value score = @$(evaluation_scoring).find("input:radio[name='evaluation-score']:checked").val() fd.append('feedback', feedback) @@ -304,7 +335,7 @@ class @CombinedOpenEnded @submit_button.hide() @queueing() if @task_number==1 and @task_count==1 - @grader_status = $('.grader-status') + @grader_status = @$(@grader_status_sel) @grader_status.html("

Response submitted for scoring.

") else if @child_state == 'post_assessment' if @child_type=="openended" @@ -347,7 +378,7 @@ class @CombinedOpenEnded if @child_state == 'initial' files = "" if @can_upload_files == true - files = $('.file-upload-box')[0].files[0] + files = @$(@file_upload_box_sel)[0].files[0] if files != undefined if files.size > max_filesize @can_upload_files = false @@ -369,7 +400,7 @@ class @CombinedOpenEnded if response.success @rubric_wrapper.html(response.rubric_html) @rubric_wrapper.show() - Rubric.initialize(@location) + Rubric.initialize(@location,@coe) @answer_area.html(response.student_response) @child_state = 'assessing' @find_assessment_elements() @@ -396,6 +427,7 @@ class @CombinedOpenEnded @is_ctrl=false save_assessment: (event) => + console.log("callback save assessment") event.preventDefault() if @child_state == 'assessing' && Rubric.check_complete() checked_assessment = Rubric.get_total_score() @@ -454,7 +486,7 @@ class @CombinedOpenEnded @hint_wrapper.html('') @message_wrapper.html('') @child_state = 'initial' - @combined_open_ended.after(response.html).remove() + @coe.after(response.html).remove() @allow_reset="False" @reinitialize(@element) @rebind() @@ -473,7 +505,7 @@ class @CombinedOpenEnded @hint_wrapper.html('') @message_wrapper.html('') @child_state = 'initial' - @combined_open_ended.after(response.html).remove() + @coe.after(response.html).remove() @reinitialize(@element) @rebind() @next_problem_button.hide() @@ -514,8 +546,8 @@ class @CombinedOpenEnded @can_upload_files = true @file_upload_area.html('Uploaded image') @file_upload_area.show() - $('.file-upload-preview').hide() - $('.file-upload-box').change @preview_image + @$(@file_upload_preview_sel).hide() + @$(@file_upload_box_sel).change @preview_image else @gentle_alert 'File uploads are required for this question, but are not supported in this browser. Try the newest version of google chrome. Alternatively, if you have uploaded the image to the web, you can paste a link to it into the answer box.' @@ -559,7 +591,7 @@ class @CombinedOpenEnded @question_header.text("(Show)") log_feedback_click: (event) -> - link_text = $(event.target).html() + link_text = @$(event.target).html() if link_text == 'See full feedback' Logger.log 'oe_show_full_feedback', {} else if link_text == 'Respond to Feedback' @@ -567,32 +599,31 @@ class @CombinedOpenEnded else generated_event_type = link_text.toLowerCase().replace(" ","_") Logger.log "oe_" + generated_event_type, {} - log_feedback_selection: (event) -> - target_selection = $(event.target).val() + target_selection = @$(event.target).val() Logger.log 'oe_feedback_response_selected', {value: target_selection} remove_attribute: (name) => - if $('.file-upload-preview').attr(name) - $('.file-upload-preview')[0].removeAttribute(name) + if @$(@file_upload_preview_sel).attr(name) + @$(@file_upload_preview_sel)[0].removeAttribute(name) preview_image: () => - if $('.file-upload-box')[0].files && $('.file-upload-box')[0].files[0] + if @$(@file_upload_box_sel)[0].files && @$(@file_upload_box_sel)[0].files[0] reader = new FileReader() reader.onload = (e) => max_dim = 150 @remove_attribute('src') @remove_attribute('height') @remove_attribute('width') - $('.file-upload-preview').attr('src', e.target.result) - height_px = $('.file-upload-preview')[0].height - width_px = $('.file-upload-preview')[0].width + @$(@file_upload_preview_sel).attr('src', e.target.result) + height_px = @$(@file_upload_preview_sel)[0].height + width_px = @$(@file_upload_preview_sel)[0].width scale_factor = 0 if height_px>width_px scale_factor = height_px/max_dim else scale_factor = width_px/max_dim - $('.file-upload-preview')[0].width = width_px/scale_factor - $('.file-upload-preview')[0].height = height_px/scale_factor - $('.file-upload-preview').show() - reader.readAsDataURL($('.file-upload-box')[0].files[0]) + @$(@file_upload_preview_sel)[0].width = width_px/scale_factor + @$(@file_upload_preview_sel)[0].height = height_px/scale_factor + @$(@file_upload_preview_sel).show() + reader.readAsDataURL(@$(@file_upload_box_sel)[0].files[0]) diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index 9483932f80..050d525a13 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -162,7 +162,7 @@ class @PeerGradingProblem @prompt_wrapper = $('.prompt-wrapper') @backend = backend @is_ctrl = false - + @el = $('.peer-grading-container') # get the location of the problem @location = $('.peer-grading').data('location') @@ -463,7 +463,7 @@ class @PeerGradingProblem @submit_button.hide() @action_button.hide() @calibration_feedback_panel.hide() - Rubric.initialize(@location) + Rubric.initialize(@location,@el) render_calibration_feedback: (response) => diff --git a/lms/static/coffee/src/staff_grading/staff_grading.coffee b/lms/static/coffee/src/staff_grading/staff_grading.coffee index f4a3360d1e..a57a8ebdb1 100644 --- a/lms/static/coffee/src/staff_grading/staff_grading.coffee +++ b/lms/static/coffee/src/staff_grading/staff_grading.coffee @@ -152,7 +152,7 @@ class @StaffGrading @backend = backend # all the jquery selectors - + @el = $('.staff-grading') @problem_list_container = $('.problem-list-container') @problem_list = $('.problem-list') @@ -224,7 +224,7 @@ class @StaffGrading setup_score_selection: => @score_selection_container.html(@rubric) $('input[class="score-selection"]').change => @graded_callback() - Rubric.initialize(@location) + Rubric.initialize(@location, @el) graded_callback: () => From e2326802106c7c88c69078ebf6c379aa918bc2f2 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 19 Jul 2013 15:15:10 -0400 Subject: [PATCH 005/395] Move child attributes --- .../xmodule/xmodule/js/src/combinedopenended/display.coffee | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index bf569c984a..e7084d315b 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -139,8 +139,6 @@ class @CombinedOpenEnded @task_number = @coe.data('task-number') @accept_file_upload = @coe.data('accept-file-upload') @location = @coe.data('location') - @child_state = @coe.data('state') - @child_type = @coe.data('child-type') console.log(@child_state) @@ -189,6 +187,8 @@ class @CombinedOpenEnded @hint_wrapper = @$(@oe).find(@hint_wrapper_sel) @message_wrapper = @$(@oe).find(@message_wrapper_sel) @submit_button = @$(@oe).find(@submit_button_sel) + @child_state = @oe.data('state') + @child_type = @oe.data('child-type') if @child_type=="openended" @skip_button = @$(@oe).find(@skip_button_sel) @skip_button.click @skip_post_assessment From a47fe070583f5b41f22f57b0bde385f4699bc373 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 19 Jul 2013 15:50:28 -0400 Subject: [PATCH 006/395] Hide legend, move display name --- .../lib/xmodule/xmodule/js/src/combinedopenended/display.coffee | 2 ++ lms/templates/combinedopenended/combined_open_ended.html | 2 +- 2 files changed, 3 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index e7084d315b..e976b75828 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -303,6 +303,7 @@ class @CombinedOpenEnded @reset_button.hide() @next_problem_button.hide() @hide_file_upload() + @legend_container.show() @hint_area.attr('disabled', false) if @task_number>1 or @child_state!='initial' @show_status_current() @@ -325,6 +326,7 @@ class @CombinedOpenEnded @submit_button.prop('value', 'Submit') @submit_button.click @save_answer @setup_file_upload() + @legend_container.hide() else if @child_state == 'assessing' @answer_area.attr("disabled", true) @replace_text_inputs() diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 5d8ef859aa..0dc5ec914f 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -1,8 +1,8 @@
+

${display_name}

${status|n}
-

${display_name}

Prompt (Hide)

From ca0cefd260d73cec70b73295e22ab4524a74ce7a Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 19 Jul 2013 16:26:23 -0400 Subject: [PATCH 007/395] Move legend, make status elements smaller --- .../lib/xmodule/xmodule/css/combinedopenended/display.scss | 2 +- lms/templates/combinedopenended/combined_open_ended.html | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 86c028a8b0..b6f78612f1 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -62,7 +62,7 @@ section.combined-open-ended-status { color: #2C2C2C; background-color : #d4d4d4; font-size: .9em; - padding: 20px; + padding: 10px; display: inline; width: 20%; margin-right: 20px; diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 0dc5ec914f..89bfb9ebf7 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -16,11 +16,12 @@
-
-
+
+
+
From 2dd5338378d4cd0706434ddbdf185af5f2236992 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 19 Jul 2013 16:43:01 -0400 Subject: [PATCH 008/395] Add in progress text --- .../xmodule/css/combinedopenended/display.scss | 13 ++++++++++++- .../combined_open_ended_modulev1.py | 8 ++++++++ .../combinedopenended/combined_open_ended.html | 9 +++++++-- 3 files changed, 27 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index b6f78612f1..f2d8e5c497 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -22,9 +22,20 @@ h2 { section.combined-open-ended { @include clearfix; - .status-container + .status-bar { + + .status-container + { padding-bottom: 5px; + display: inline-block; + padding-right: 20px + } + .progress-container + { + padding-bottom: 5px; + display: inline-block; + } } .item-container { diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index f7ae9af9d3..724c650cce 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -37,6 +37,13 @@ HUMAN_TASK_TYPE = { 'peer_grading.conf' : "Peer Assessment", } +HUMAN_STATES = { + 'intitial' : "Not started.", + 'assessing' : "Being scored.", + 'intermediate_done' : "Scoring finished.", + 'done' : "Complete." +} + # Default value that controls whether or not to skip basic spelling checks in the controller # Metadata overrides this SKIP_BASIC_CHECKS = False @@ -291,6 +298,7 @@ class CombinedOpenEndedV1Module(): 'accept_file_upload': self.accept_file_upload, 'location': self.location, 'legend_list': LEGEND_LIST, + 'human_state': HUMAN_STATES.get(self.state,"Not started.") } return context diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 89bfb9ebf7..e09fb29482 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -1,7 +1,12 @@

${display_name}

-
- ${status|n} +
+
+ ${status|n} +
+
+ Progress: ${human_state} +
From 579b66d9dd641994aeaa99cdfd682e14fb302665 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 11:44:42 -0400 Subject: [PATCH 009/395] Redesign header css --- .../css/combinedopenended/display.scss | 29 +++++++++++++++---- .../combined_open_ended.html | 15 ++++++---- .../combined_open_ended_status.html | 3 -- 3 files changed, 33 insertions(+), 14 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index f2d8e5c497..2b20943d8b 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -16,6 +16,21 @@ h2 { } } +div.name{ + padding-bottom: 15px; + h2{ + display: inline; + } + + .progress-container + { + display: inline; + float: right; + padding-top: 3px; + } + +} + .inline-error { color: darken($error-red, 10%); } @@ -24,6 +39,13 @@ section.combined-open-ended { @include clearfix; .status-bar { + float: right; + display: inline-block; + + .problemtype{ + display: inline; + margin-right: 140px; + } .status-container { @@ -31,15 +53,11 @@ section.combined-open-ended { display: inline-block; padding-right: 20px } - .progress-container - { - padding-bottom: 5px; - display: inline-block; - } } .item-container { padding-bottom: 10px; + display: inline-block; } .result-container @@ -76,7 +94,6 @@ section.combined-open-ended-status { padding: 10px; display: inline; width: 20%; - margin-right: 20px; .show-results { margin-top: .3em; text-align:right; diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index e09fb29482..c4623b781d 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -1,13 +1,18 @@
-

${display_name}

-
-
- ${status|n} -
+
+

${display_name}

Progress: ${human_state}
+
+
+ Open Response Assessment +
+ Assessments:
+ ${status|n} +
+

Prompt (Hide)

diff --git a/lms/templates/combinedopenended/combined_open_ended_status.html b/lms/templates/combinedopenended/combined_open_ended_status.html index 66afb79021..50c0459ce9 100644 --- a/lms/templates/combinedopenended/combined_open_ended_status.html +++ b/lms/templates/combinedopenended/combined_open_ended_status.html @@ -10,9 +10,6 @@ ${status['human_task']}
- %if i - %endif %endfor
From 5ae6ce9d47434c893e122f8e3790d0cd78cf61fd Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 12:10:16 -0400 Subject: [PATCH 010/395] Add in css borders --- .../css/combinedopenended/display.scss | 26 +++++++--- .../js/src/combinedopenended/display.coffee | 9 ++-- .../combined_open_ended.html | 48 ++++++++++--------- 3 files changed, 49 insertions(+), 34 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 2b20943d8b..f38d33d300 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -36,28 +36,40 @@ div.name{ } section.combined-open-ended { - @include clearfix; + @include clearfix; +} + +div.problemwrapper { + border: 1px solid; + border-color: lightgray; + -webkit-border-radius: 20px; + -moz-border-radius: 20px; + border-radius: 20px; + padding: 5px; .status-bar { float: right; display: inline-block; + padding: 5px; .problemtype{ display: inline; - margin-right: 140px; + margin-right: 135px; } .status-container { padding-bottom: 5px; display: inline-block; - padding-right: 20px } + border-bottom: 1px solid; + border-color: lightgray; } .item-container { padding-bottom: 10px; display: inline-block; + padding: 15px; } .result-container @@ -66,10 +78,6 @@ section.combined-open-ended { width: 100%; position:relative; } - h4 - { - margin-bottom:10px; - } } section.legend-container { @@ -694,4 +702,8 @@ section.open-ended-child { } } + div.prompt{ + padding: 10px; + background-color:white; + } } diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index e976b75828..f89a124c09 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -140,8 +140,6 @@ class @CombinedOpenEnded @accept_file_upload = @coe.data('accept-file-upload') @location = @coe.data('location') - console.log(@child_state) - # set up handlers for click tracking Rubric.initialize(@location,@coe) @is_ctrl = false @@ -174,8 +172,6 @@ class @CombinedOpenEnded console.log(@results_container) @combined_rubric_container = @$(@combined_rubric_sel) - @legend_container= @$(@legend_sel) - @show_legend_current() # Where to put the rubric once we load it console.log("started child") @@ -205,6 +201,10 @@ class @CombinedOpenEnded else if @task_number==1 and @child_state!='initial' @prompt_hide() + if @child_state!="initial" + @show_legend_current() + @legend_container= @$(@legend_sel) + @find_assessment_elements() @find_hint_elements() @@ -339,6 +339,7 @@ class @CombinedOpenEnded if @task_number==1 and @task_count==1 @grader_status = @$(@grader_status_sel) @grader_status.html("

Response submitted for scoring.

") + @legend_container.hide() else if @child_state == 'post_assessment' if @child_type=="openended" @skip_button.show() diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index c4623b781d..ad96afc2df 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -5,34 +5,36 @@ Progress: ${human_state}
-
-
- Open Response Assessment -
- Assessments:
- ${status|n} -
-
- -
-

Prompt (Hide)

-
- % for item in items: -
${item['content'] | n}
- % endfor +
+
+
+ Open Response Assessment +
+ Assessments:
+ ${status|n} +
- - -
+
+

Prompt (Hide)

+
+ % for item in items: +
${item['content'] | n}
+ % endfor +
-
-
+ + +
-
-
+
+
-
+
+
+ +
+
From 9cb7d448c61a713aaf5e7cb38c04637a42164c9a Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 12:15:05 -0400 Subject: [PATCH 011/395] Border between prompt element --- .../xmodule/xmodule/css/combinedopenended/display.scss | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index f38d33d300..bb48b29996 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -702,8 +702,14 @@ section.open-ended-child { } } + div.prompt{ - padding: 10px; background-color:white; + border-bottom: 1px solid; + border-color: lightgray; + } + + h4{ + margin-top: 10px; } } From c059a31623b1c27368ca040f64149d1d46402301 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 12:33:12 -0400 Subject: [PATCH 012/395] Fix border locations, remove open ended response text --- .../xmodule/css/combinedopenended/display.scss | 14 +++++++++----- .../combinedopenended/combined_open_ended.html | 3 --- 2 files changed, 9 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index bb48b29996..2c53e51918 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -62,14 +62,18 @@ div.problemwrapper { padding-bottom: 5px; display: inline-block; } - border-bottom: 1px solid; - border-color: lightgray; } .item-container { padding-bottom: 10px; display: inline-block; padding: 15px; + + h4 { + padding-top: 10px; + border-top: 1px solid; + border-color: lightgray; + } } .result-container @@ -705,11 +709,11 @@ section.open-ended-child { div.prompt{ background-color:white; - border-bottom: 1px solid; - border-color: lightgray; } h4{ - margin-top: 10px; + padding-top: 10px; + border-top: 1px solid; + border-color: lightgray; } } diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index ad96afc2df..21008d83db 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -7,9 +7,6 @@
-
- Open Response Assessment -
Assessments:
${status|n}
From 172bc5cfa51989d7deeb66d3c811daf529792a00 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 14:23:11 -0400 Subject: [PATCH 013/395] Change naming, edit border radius --- .../css/combinedopenended/display.scss | 24 +++++++++++++++---- .../combined_open_ended_modulev1.py | 8 +++---- .../combined_open_ended.html | 15 +++++++++--- 3 files changed, 35 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 2c53e51918..66b9c708c6 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -42,9 +42,9 @@ section.combined-open-ended { div.problemwrapper { border: 1px solid; border-color: lightgray; - -webkit-border-radius: 20px; - -moz-border-radius: 20px; - border-radius: 20px; + -webkit-border-radius: 10px; + -moz-border-radius: 10px; + border-radius: 10px; padding: 5px; .status-bar { @@ -54,7 +54,7 @@ div.problemwrapper { .problemtype{ display: inline; - margin-right: 135px; + float: left; } .status-container @@ -62,9 +62,18 @@ div.problemwrapper { padding-bottom: 5px; display: inline-block; } + + .problemtype-container{ + width: 40% + } + + .assessments-container{ + width: 30% + } } .item-container { + @clearfix padding-bottom: 10px; display: inline-block; padding: 15px; @@ -98,8 +107,13 @@ section.legend-container { section.combined-open-ended-status { padding: 20px 20px 20px 0px; - .statusitem { + &:first-child{ + border-bottom-right-radius: 10px; + } + &:last-child{ + border-top-left-radius: 10px; + } color: #2C2C2C; background-color : #d4d4d4; font-size: .9em; diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 724c650cce..2fc19fb6d7 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -31,10 +31,10 @@ ACCEPT_FILE_UPLOAD = False TRUE_DICT = ["True", True, "TRUE", "true"] HUMAN_TASK_TYPE = { - 'selfassessment': "Self Assessment", - 'openended': "edX Assessment", - 'ml_grading.conf' : "AI Assessment", - 'peer_grading.conf' : "Peer Assessment", + 'selfassessment': "Self", + 'openended': "edX", + 'ml_grading.conf' : "AI", + 'peer_grading.conf' : "Peer", } HUMAN_STATES = { diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 21008d83db..799abf2711 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -7,9 +7,18 @@
- Assessments:
- ${status|n} -
+ + + +
+
+ Open Response Assessment +
+
+ Assessments:
+ ${status|n} +
+
From d23d354ab36202e34a63bd0ade416d5498e467f6 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 14:41:54 -0400 Subject: [PATCH 014/395] Change top text --- .../css/combinedopenended/display.scss | 15 ++++++++--- .../combined_open_ended.html | 25 +++++++++++-------- 2 files changed, 26 insertions(+), 14 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 66b9c708c6..0373ce1aa8 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -55,6 +55,9 @@ div.problemwrapper { .problemtype{ display: inline; float: left; + background-color: #d4d4d4; + padding: 10px; + border-radius: 5px; } .status-container @@ -64,11 +67,15 @@ div.problemwrapper { } .problemtype-container{ - width: 40% + width: 40%; + padding-top: 12px; } .assessments-container{ - width: 30% + width: 30%; + .assessment-text{ + display: inline-block; + } } } .item-container @@ -109,10 +116,10 @@ section.combined-open-ended-status { padding: 20px 20px 20px 0px; .statusitem { &:first-child{ - border-bottom-right-radius: 10px; + border-bottom-left-radius: 10px; } &:last-child{ - border-top-left-radius: 10px; + border-top-right-radius: 10px; } color: #2C2C2C; background-color : #d4d4d4; diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 799abf2711..da9e8ab04f 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -8,16 +8,21 @@
- - + + + +
-
- Open Response Assessment -
-
- Assessments:
- ${status|n} -
-
+
+ Open Response Assessment +
+
+
+ Assessments: +
+
+ ${status|n} +
+
From b6ff513e335ac4031ff2c39178b0f871cc20126b Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 15:06:40 -0400 Subject: [PATCH 015/395] Add in get last response functionality --- .../xmodule/js/src/combinedopenended/display.coffee | 9 +++++++++ .../combined_open_ended_modulev1.py | 6 ++++++ 2 files changed, 15 insertions(+) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index f89a124c09..730a9462c7 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -196,6 +196,8 @@ class @CombinedOpenEnded @out_of_sync_message = 'The problem state got out of sync. Try reloading the page.' + @get_last_response() + if @task_number>1 @prompt_hide() else if @task_number==1 and @child_state!='initial' @@ -263,6 +265,13 @@ class @CombinedOpenEnded @legend_container.after(response.html).remove() @legend_container= $(@legend_sel) + get_last_response: () => + data = {} + $.postWithPrefix "#{@ajax_url}/get_last_response", data, (response) => + if response.success + console.log(response) + console.log(response.response) + message_post: (event)=> external_grader_message=$(event.target).parent().parent().parent() evaluation_scoring = $(event.target).parent() diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 2fc19fb6d7..3ceafdca65 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -7,6 +7,7 @@ from xmodule.progress import Progress from xmodule.stringify import stringify_children import self_assessment_module import open_ended_module +from functools import partial from .combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST log = logging.getLogger("mitx.courseware") @@ -445,6 +446,7 @@ class CombinedOpenEndedV1Module(): 'feedback_dicts': feedback_dicts, 'grader_ids': grader_ids, 'submission_ids': submission_ids, + 'success' : True } return last_response_dict @@ -608,6 +610,7 @@ class CombinedOpenEndedV1Module(): 'get_combined_rubric': self.get_rubric, 'get_status': self.get_status_ajax, 'get_legend': self.get_legend, + 'get_last_response': self.get_last_response_ajax, } if dispatch not in handlers: @@ -617,6 +620,9 @@ class CombinedOpenEndedV1Module(): d = handlers[dispatch](data) return json.dumps(d, cls=ComplexEncoder) + def get_last_response_ajax(self,data): + return self.get_last_response(self.current_task_number) + def next_problem(self, _data): """ Called via ajax to advance to the next problem. From 29bd64555f55d534a7113d7c4910e9db75098d97 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 15:30:11 -0400 Subject: [PATCH 016/395] Get table to take up the width of the page --- common/lib/xmodule/xmodule/css/combinedopenended/display.scss | 4 ++++ .../xmodule/xmodule/js/src/combinedopenended/display.coffee | 3 --- lms/templates/combinedopenended/combined_open_ended.html | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 0373ce1aa8..b1ac3d773c 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -60,6 +60,10 @@ div.problemwrapper { border-radius: 5px; } + .statustable{ + width: 750px; + } + .status-container { padding-bottom: 5px; diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 730a9462c7..dcb3cd24fc 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -196,8 +196,6 @@ class @CombinedOpenEnded @out_of_sync_message = 'The problem state got out of sync. Try reloading the page.' - @get_last_response() - if @task_number>1 @prompt_hide() else if @task_number==1 and @child_state!='initial' @@ -269,7 +267,6 @@ class @CombinedOpenEnded data = {} $.postWithPrefix "#{@ajax_url}/get_last_response", data, (response) => if response.success - console.log(response) console.log(response.response) message_post: (event)=> diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index da9e8ab04f..f66088be8a 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -7,7 +7,7 @@
- +
') @@ -60,12 +66,10 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase) # Check the enrollment table user = User.objects.get(email='student0@test.com') - ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) - self.assertEqual(0, len(ce)) + self.assertFalse(CourseEnrollment.is_enrolled(user, course.id)) user = User.objects.get(email='student1@test.com') - ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) - self.assertEqual(0, len(ce)) + self.assertFalse(CourseEnrollment.is_enrolled(user, course.id)) # Check the outbox self.assertEqual(len(mail.outbox), 0) @@ -96,7 +100,7 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase) self.assertEqual(1, cea[0].auto_enroll) # Check there is no enrollment db entry other than for the other students - ce = CourseEnrollment.objects.filter(course_id=course.id) + ce = CourseEnrollment.objects.filter(course_id=course.id, is_active=1) self.assertEqual(4, len(ce)) # Create and activate student accounts with same email @@ -111,12 +115,10 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase) # Check students are enrolled user = User.objects.get(email='student1_1@test.com') - ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) - self.assertEqual(1, len(ce)) + self.assertTrue(CourseEnrollment.is_enrolled(user, course.id)) user = User.objects.get(email='student1_2@test.com') - ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) - self.assertEqual(1, len(ce)) + self.assertTrue(CourseEnrollment.is_enrolled(user, course.id)) def test_repeat_enroll(self): """ @@ -156,7 +158,7 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase) self.assertEqual(0, cea[0].auto_enroll) # Check there is no enrollment db entry other than for the setup instructor and students - ce = CourseEnrollment.objects.filter(course_id=course.id) + ce = CourseEnrollment.objects.filter(course_id=course.id, is_active=1) self.assertEqual(4, len(ce)) # Create and activate student accounts with same email @@ -171,11 +173,10 @@ class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase) # Check students are not enrolled user = User.objects.get(email='student2_1@test.com') - ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) - self.assertEqual(0, len(ce)) + self.assertFalse(CourseEnrollment.is_enrolled(user, course.id)) + user = User.objects.get(email='student2_2@test.com') - ce = CourseEnrollment.objects.filter(course_id=course.id, user=user) - self.assertEqual(0, len(ce)) + self.assertFalse(CourseEnrollment.is_enrolled(user, course.id)) def test_get_and_clean_student_list(self): """ diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 42f399c143..dd4c776446 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -93,7 +93,7 @@ def instructor_dashboard(request, course_id): datatable = {'header': ['Statistic', 'Value'], 'title': 'Course Statistics At A Glance', } - data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id).count()]] + data = [['# Enrolled', CourseEnrollment.objects.filter(course_id=course_id, is_active=1).count()]] data += [['Date', timezone.now().isoformat()]] data += compute_course_stats(course).items() if request.user.is_staff: @@ -530,7 +530,10 @@ def instructor_dashboard(request, course_id): # DataDump elif 'Download CSV of all student profile data' in action: - enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related("profile") + enrolled_students = User.objects.filter( + courseenrollment__course_id=course_id, + courseenrollment__is_active=1, + ).order_by('username').select_related("profile") profkeys = ['name', 'language', 'location', 'year_of_birth', 'gender', 'level_of_education', 'mailing_address', 'goals'] datatable = {'header': ['username', 'email'] + profkeys} @@ -1002,7 +1005,10 @@ def get_student_grade_summary_data(request, course, course_id, get_grades=True, If get_raw_scores=True, then instead of grade summaries, the raw grades for all graded modules are returned. ''' - enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).prefetch_related("groups").order_by('username') + enrolled_students = User.objects.filter( + courseenrollment__course_id=course_id, + courseenrollment__is_active=1, + ).prefetch_related("groups").order_by('username') header = ['ID', 'Username', 'Full Name', 'edX email', 'External email'] assignments = [] @@ -1053,7 +1059,10 @@ def gradebook(request, course_id): """ course = get_course_with_access(request.user, course_id, 'staff', depth=None) - enrolled_students = User.objects.filter(courseenrollment__course_id=course_id).order_by('username').select_related("profile") + enrolled_students = User.objects.filter( + courseenrollment__course_id=course_id, + courseenrollment__is_active=1 + ).order_by('username').select_related("profile") # TODO (vshnayder): implement pagination. enrolled_students = enrolled_students[:1000] # HACK! @@ -1110,7 +1119,7 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll for ce in todelete: if not has_access(ce.user, course, 'staff') and ce.user.email.lower() not in new_students_lc: status[ce.user.email] = 'deleted' - ce.delete() + ce.deactivate() else: status[ce.user.email] = 'is staff' ceaset = CourseEnrollmentAllowed.objects.filter(course_id=course_id) @@ -1162,14 +1171,13 @@ def _do_enroll_students(course, course_id, students, overload=False, auto_enroll continue #Student has already registered - if CourseEnrollment.objects.filter(user=user, course_id=course_id): + if CourseEnrollment.is_enrolled(user, course_id): status[student] = 'already enrolled' continue try: #Not enrolled yet - ce = CourseEnrollment(user=user, course_id=course_id) - ce.save() + ce = CourseEnrollment.enroll(user, course_id) status[student] = 'added' if email_students: @@ -1239,11 +1247,10 @@ def _do_unenroll_students(course_id, students, email_students=False): continue - ce = CourseEnrollment.objects.filter(user=user, course_id=course_id) #Will be 0 or 1 records as there is a unique key on user + course_id - if ce: + if CourseEnrollment.is_enrolled(user, course_id): try: - ce[0].delete() + CourseEnrollment.unenroll(user, course_id) status[student] = "un-enrolled" if email_students: #User was enrolled From c9807db308fa7f17d8eb77564cc916ccf53e8ab6 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 14 Aug 2013 14:12:04 -0400 Subject: [PATCH 174/395] styling of peer review interface for students along with minimal adjustments to the staff grading interface as well. added internationalization of peer grading strings. --- lms/static/sass/course/_staff_grading.scss | 44 +++-- .../combined_open_ended.html | 8 +- .../combined_open_ended_results.html | 8 +- lms/templates/instructor/staff_grading.html | 98 +++++----- .../peer_grading/peer_grading_problem.html | 167 +++++++++--------- 5 files changed, 166 insertions(+), 159 deletions(-) diff --git a/lms/static/sass/course/_staff_grading.scss b/lms/static/sass/course/_staff_grading.scss index 4dd225199c..739cdc6ca5 100644 --- a/lms/static/sass/course/_staff_grading.scss +++ b/lms/static/sass/course/_staff_grading.scss @@ -1,7 +1,6 @@ div.staff-grading, div.peer-grading{ - padding: $baseline; - border: none; + border: 1px solid lightgray; textarea.feedback-area { margin: 0; @@ -37,7 +36,8 @@ div.peer-grading{ &.submission-container{ @include clearfix; overflow-y: auto; - height: 150px; + height: auto; + max-height: 300px; border: 1px solid #ddd; background: #F6F6F6; } @@ -133,12 +133,14 @@ div.peer-grading{ .instructions-panel { @include clearfix; - margin-right:2px; + padding: $baseline/2; + background-color: #eee; + font-size: .8em; > div { margin-bottom: 5px; - padding: 2px; - width: 47.6%; + padding: $baseline/2; + width: 49%; background: #eee; h3 { @@ -152,18 +154,19 @@ div.peer-grading{ } } .calibration-panel { - float: left; + display: inline-block; + width: 20%; + border-radius: 3px; } .grading-panel { - float: right; + display: inline-block; + width: 20%; + border-radius: 3px; } .current-state { - background: #1d9dd9; + background: #fff; - h3, p { - color: white; - } } } @@ -186,3 +189,20 @@ div.peer-grading{ } } +div.peer-grading { + border-radius: $baseline/2; + padding: 0; + + .prompt-wrapper { + padding: $baseline; + } + + .grading-wrapper { + padding: $baseline; + } +} + +div.staff-grading { + padding: $baseline; +} + diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 84ac00fab3..098f6d456a 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -11,12 +11,12 @@
From 1a4be13d4c693dc002a53fc42fc16c4a873ded4c Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 15:40:09 -0400 Subject: [PATCH 017/395] Improve rubric look and feel --- .../xmodule/css/combinedopenended/display.scss | 13 ++++++++++--- 1 file changed, 10 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index b1ac3d773c..e91765b50a 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -87,7 +87,7 @@ div.problemwrapper { @clearfix padding-bottom: 10px; display: inline-block; - padding: 15px; + margin: 0px 15px 0px 15px; h4 { padding-top: 10px; @@ -112,7 +112,7 @@ section.legend-container { display: inline; width: 20%; } - margin-bottom: 5px; + margin: 15px; } section.combined-open-ended-status { @@ -182,10 +182,11 @@ section.combined-open-ended-status { } div.combined-rubric-container { + margin:15px; ul.rubric-list{ list-style-type: none; padding:0; - margin:0; + margin:4px; li { &.rubric-list-item{ margin-bottom: 2px; @@ -194,6 +195,12 @@ div.combined-rubric-container { } } + h4{ + padding-top: 10px; + border-top: 1px solid; + border-color: lightgray; + } + span.rubric-category { font-size: .9em; font-weight: bold; From 2d966209f40935f9712ad9d27a4f4ab9a2615050 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 15:55:34 -0400 Subject: [PATCH 018/395] fix test --- .../xmodule/open_ended_grading_classes/peer_grading_service.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py index f80aff610a..415298edf3 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py @@ -111,7 +111,7 @@ class MockPeerGradingService(object): 'max_score': 4} def save_grade(self, location, grader_id, submission_id, - score, feedback, submission_key, rubric_scores, submission_flagged): + score, feedback, submission_key, rubric_scores, submission_flagged, answer_unknown): return {'success': True} def is_student_calibrated(self, problem_location, grader_id): From 12a2c06fb13f53290e5bed53c3482c2e87c490e3 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 15:57:21 -0400 Subject: [PATCH 019/395] Remove console.log statements --- .../xmodule/js/src/combinedopenended/display.coffee | 8 -------- lms/static/coffee/src/staff_grading/staff_grading.coffee | 1 - 2 files changed, 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index dcb3cd24fc..96ad75f088 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -143,11 +143,9 @@ class @CombinedOpenEnded # set up handlers for click tracking Rubric.initialize(@location,@coe) @is_ctrl = false - console.log("init rubric") #Setup reset @reset_button = @$(@reset_button_sel) @reset_button.click @reset - console.log("init reset") #Setup next problem @next_problem_button = @$(@next_step_sel) @next_problem_button.click @next_problem @@ -162,19 +160,15 @@ class @CombinedOpenEnded @question_header.click @collapse_question # valid states: 'initial', 'assessing', 'post_assessment', 'done' - console.log("init collapse") Collapsible.setCollapsibles(@$el) - console.log("finish collapse") @submit_evaluation_button = @$(@submit_evaluation_sel) @submit_evaluation_button.click @message_post @results_container = @$(@result_container_sel) - console.log(@results_container) @combined_rubric_container = @$(@combined_rubric_sel) # Where to put the rubric once we load it - console.log("started child") @oe = @$(@open_ended_child_sel) @errors_area = @$(@oe).find(@error_sel) @answer_area = @$(@oe).find(@answer_area_sel) @@ -192,7 +186,6 @@ class @CombinedOpenEnded @file_upload_area = $(@oe).find(@file_upload_sel) @can_upload_files = false @open_ended_child= $(@oe).find(@open_ended_child_sel) - console.log("Passed child") @out_of_sync_message = 'The problem state got out of sync. Try reloading the page.' @@ -436,7 +429,6 @@ class @CombinedOpenEnded @is_ctrl=false save_assessment: (event) => - console.log("callback save assessment") event.preventDefault() if @child_state == 'assessing' && Rubric.check_complete() checked_assessment = Rubric.get_total_score() diff --git a/lms/static/coffee/src/staff_grading/staff_grading.coffee b/lms/static/coffee/src/staff_grading/staff_grading.coffee index a57a8ebdb1..f22fe9c7fd 100644 --- a/lms/static/coffee/src/staff_grading/staff_grading.coffee +++ b/lms/static/coffee/src/staff_grading/staff_grading.coffee @@ -103,7 +103,6 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for t else if cmd == 'save_grade' - console.log("eval: #{data.score} pts, Feedback: #{data.feedback}") response = @mock('get_next', {location: data.location}) # get_problem_list From 77e57448b354f5d438e3b5fd61648eaa696b6adb Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 1 Aug 2013 12:42:11 -0400 Subject: [PATCH 020/395] cleaned up sass document format and added baseline variable throughout capa and combined open ended problems. no style rule changes, simply whitespace, ordering of properties, and the addition of baseline over direct pixels for padding and margins. --- .../lib/xmodule/xmodule/css/capa/display.scss | 562 +++++++++--------- .../css/combinedopenended/display.scss | 494 ++++++++------- 2 files changed, 537 insertions(+), 519 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 48912795f0..a35dc01633 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -19,10 +19,10 @@ h2 { iframe[seamless]{ - background-color: transparent; - border: 0px none transparent; - padding: 0px; - overflow: hidden; + overflow: hidden; + padding: 0px; + border: 0px none transparent; + background-color: transparent; } .inline-error { @@ -31,17 +31,17 @@ iframe[seamless]{ section.problem-progress { display: inline-block; - color: #999; - font-size: em(16); - font-weight: 100; padding-left: 5px; + color: #999; + font-weight: 100; + font-size: em(16); } section.problem { @media print { display: block; - width: auto; padding: 0; + width: auto; canvas, img { page-break-inside: avoid; @@ -49,30 +49,29 @@ section.problem { } .inline { - display: inline; + display: inline; } .choicegroup { @include clearfix; - - label.choicegroup_correct{ - &:after{ - content: url('../images/correct-icon.png'); - margin-left:15px - } - } - - label.choicegroup_incorrect{ - &:after{ - content: url('../images/incorrect-icon.png'); - margin-left:15px; - } - } - - min-width:100px; + min-width: 100px; width: auto !important; width: 100px; + label.choicegroup_correct { + &:after { + margin-left: 15px; + content: url('../images/correct-icon.png'); + } + } + + label.choicegroup_incorrect { + &:after { + margin-left: 15px; + content: url('../images/incorrect-icon.png'); + } + } + .indicator_container { float: left; width: 25px; @@ -82,9 +81,9 @@ section.problem { fieldset { @include box-sizing(border-box); + margin: 0px 0px $baseline; + padding-left: $baseline; border-left: 1px solid #ddd; - padding-left: 20px; - margin: 0px 0px 20px; } input[type="radio"], @@ -102,21 +101,21 @@ section.problem { ol.enumerate { li { &:before { - content: " "; display: block; - height: 0; visibility: hidden; + height: 0; + content: " "; } } } .solution-span { > span { - margin: 20px 0; + margin: $baseline 0; display: block; border: 1px solid #ddd; - padding: 9px 15px 20px; - background: #FFF; + padding: 9px 15px $baseline; + background: #fff; position: relative; box-shadow: inset 0 0 0 1px #eee; border-radius: 3px; @@ -133,26 +132,26 @@ section.problem { margin-top: -2px; } &.status { + margin: 8px 0 0 $baseline/2; text-indent: -9999px; - margin: 8px 0 0 10px; } } &.unanswered { p.status { @include inline-block(); - background: url('../images/unanswered-icon.png') center center no-repeat; - height: 14px; width: 14px; + height: 14px; + background: url('../images/unanswered-icon.png') center center no-repeat; } } &.correct, &.ui-icon-check { p.status { @include inline-block(); - background: url('../images/correct-icon.png') center center no-repeat; - height: 20px; width: 25px; + height: 20px; + background: url('../images/correct-icon.png') center center no-repeat; } input { @@ -163,9 +162,9 @@ section.problem { &.processing { p.status { @include inline-block(); - background: url('../images/spinner.gif') center center no-repeat; - height: 20px; width: 20px; + height: 20px; + background: url('../images/spinner.gif') center center no-repeat; } input { @@ -176,9 +175,9 @@ section.problem { &.incorrect, &.ui-icon-close { p.status { @include inline-block(); - background: url('../images/incorrect-icon.png') center center no-repeat; - height: 20px; width: 20px; + height: 20px; + background: url('../images/incorrect-icon.png') center center no-repeat; text-indent: -9999px; } @@ -195,12 +194,12 @@ section.problem { p.answer { @include inline-block(); margin-bottom: 0; - margin-left: 10px; + margin-left: $baseline/2; &:before { + display: inline; content: "Answer: "; font-weight: bold; - display: inline; } &:empty { @@ -218,12 +217,12 @@ section.problem { margin-bottom: 0; &.math { - padding: 6px; - background: #f1f1f1; - border: 1px solid #e3e3e3; @include inline-block; - border-radius: 4px; + padding: 6px; min-width: 30px; + border: 1px solid #e3e3e3; + border-radius: 4px; + background: #f1f1f1; } } } @@ -231,98 +230,92 @@ section.problem { span { &.unanswered, &.ui-icon-bullet { @include inline-block(); - background: url('../images/unanswered-icon.png') center center no-repeat; - height: 14px; position: relative; top: 4px; width: 14px; + height: 14px; + background: url('../images/unanswered-icon.png') center center no-repeat; } &.processing, &.ui-icon-processing { @include inline-block(); - background: url('../images/spinner.gif') center center no-repeat; - height: 20px; position: relative; top: 6px; width: 25px; + height: 20px; + background: url('../images/spinner.gif') center center no-repeat; } &.correct, &.ui-icon-check { @include inline-block(); - background: url('../images/correct-icon.png') center center no-repeat; - height: 20px; position: relative; top: 3px; width: 25px; + height: 20px; + background: url('../images/correct-icon.png') center center no-repeat; } &.partially-correct { @include inline-block(); - background: url('../images/partially-correct-icon.png') center center no-repeat; - height: 20px; position: relative; top: 6px; width: 25px; + height: 20px; + background: url('../images/partially-correct-icon.png') center center no-repeat; } &.incorrect, &.ui-icon-close { @include inline-block(); - background: url('../images/incorrect-icon.png') center center no-repeat; - height: 20px; - width: 20px; position: relative; top: 3px; + width: 20px; + height: 20px; + background: url('../images/incorrect-icon.png') center center no-repeat; } } - .reload - { + .reload { float:right; - margin: 10px; + margin: $baseline/2; } .grader-status { + @include clearfix; + margin-bottom: $baseline; padding: 9px; - background: #F6F6F6; border: 1px solid #ddd; border-top: 0; - margin-bottom: 20px; - @include clearfix; + background: #F6F6F6; span { - text-indent: -9999px; - overflow: hidden; display: block; float: left; + overflow: hidden; margin: -7px 7px 0 0; + text-indent: -9999px; } .grading { - background: url('../images/info-icon.png') left center no-repeat; - padding-left: 25px; - text-indent: 0px; margin: 0px 7px 0 0; + padding-left: 25px; + background: url('../images/info-icon.png') left center no-repeat; + text-indent: 0px; } p { - line-height: 20px; - text-transform: capitalize; - margin-bottom: 0; float: left; + margin-bottom: 0; + text-transform: capitalize; + line-height: 20px; } &.file { - background: #FFF; - margin-top: 20px; - padding: 20px 0 0 0; - - border: { - top: 1px solid #eee; - right: 0; - bottom: 0; - left: 0; - } + background: #fff; + margin-top: $baseline; + padding: $baseline 0 0 0; + border: 0; + border-top: 1px solid #eee; p.debug { display: none; @@ -335,54 +328,54 @@ section.problem { } .evaluation { - p { - margin-bottom: 4px; - } + p { + margin-bottom: 4px; + } } .feedback-on-feedback { - height: 100px; - margin-right: 20px; + height: 100px; + margin-right: $baseline; } .evaluation-response { - header { - text-align: right; - a { - font-size: .85em; - } + header { + text-align: right; + a { + font-size: .85em; } + } } .evaluation-scoring { - .scoring-list { - list-style-type: none; - margin-left: 3px; + .scoring-list { + margin-left: 3px; + list-style-type: none; - li { - &:first-child { - margin-left: 0px; - } - display:inline; - margin-left: 50px; + li { + display:inline; + margin-left: 50px; + + &:first-child { + margin-left: 0px; + } - label { - font-size: .9em; - } - - } + label { + font-size: .9em; + } } - + } } + .submit-message-container { - margin: 10px 0px ; + margin: $baseline 0px ; } } form.option-input { - margin: -10px 0 20px; - padding-bottom: 20px; + margin: -$baseline/2 0 $baseline; + padding-bottom: $baseline; select { margin-right: flex-gutter(); @@ -390,17 +383,17 @@ section.problem { } ul { - list-style: disc outside none; margin-bottom: lh(); margin-left: .75em; margin-left: .75rem; + list-style: disc outside none; } ol { - list-style: decimal outside none; margin-bottom: lh(); margin-left: .75em; margin-left: .75rem; + list-style: decimal outside none; } dl { @@ -421,8 +414,8 @@ section.problem { } li { - line-height: 1.4em; margin-bottom: lh(.5); + line-height: 1.4em; &:last-child { margin-bottom: 0; @@ -439,8 +432,8 @@ section.problem { table-layout: auto; th { - font-weight: bold; text-align: left; + font-weight: bold; } td { @@ -453,44 +446,43 @@ section.problem { } caption { - background: #f1f1f1; margin-bottom: .75em; margin-bottom: .75rem; padding: .75em 0; padding: .75rem 0; + background: #f1f1f1; } tr, td, th { vertical-align: middle; } - } code { margin: 0 2px; padding: 0px 5px; - white-space: nowrap; - border: 1px solid #EAEAEA; - background-color: #F8F8F8; + border: 1px solid #eaeaea; border-radius: 3px; + background-color: #f8f8f8; + white-space: nowrap; font-size: .9em; } pre { - background-color: #F8F8F8; - border: 1px solid #CCC; + overflow: auto; + padding: 6px $baseline/2; + border: 1px solid #ccc; + border-radius: 3px; + background-color: #f8f8f8; font-size: .9em; line-height: 1.4; - overflow: auto; - padding: 6px 10px; - border-radius: 3px; > code { margin: 0; padding: 0; - white-space: pre; border: none; background: transparent; + white-space: pre; } } @@ -507,26 +499,25 @@ section.problem { } pre { - border-radius: 0; - border-radius: 0; - border-width: 0; + overflow: hidden; margin: 0; padding: 0; + border-width: 0; + border-radius: 0; background: transparent; - font-family: inherit; - font-size: inherit; white-space: pre; word-wrap: normal; - overflow: hidden; + font-size: inherit; + font-family: inherit; resize: none; &.CodeMirror-cursor { - z-index: 10; position: absolute; + z-index: 10; visibility: hidden; - border-left: 1px solid black; - border-right: none; width: 0; + border-right: none; + border-left: 1px solid black; } } } @@ -536,14 +527,14 @@ section.problem { } hr { - background: #ddd; - border: none; - clear: both; - color: #ddd; float: none; - height: 1px; + clear: both; margin: 0 0 .75rem; width: 100%; + height: 1px; + border: none; + background: #ddd; + color: #ddd; } .hidden { @@ -560,17 +551,17 @@ section.problem { center { display: block; margin: lh() 0; - border: 1px solid #ccc; padding: lh(); + border: 1px solid #ccc; } section.action { - margin-top: 20px; + margin-top: $baseline; .save, .check, .show { height: ($baseline*2); - font-weight: 600; vertical-align: middle; + font-weight: 600; } .save { @@ -580,8 +571,8 @@ section.problem { .show { .show-label { - font-size: 1.0em; font-weight: 600; + font-size: 1.0em; } } @@ -592,20 +583,20 @@ section.problem { // padding: 8px 12px; // margin-top: 10px; @include inline-block; - font-style: italic; - margin: 8px 0 0 10px; + margin: 8px 0 0 $baseline/2; color: #777; + font-style: italic; -webkit-font-smoothing: antialiased; } } .detailed-solution { > p:first-child { - font-size: 0.9em; + color: #aaa; + text-transform: uppercase; font-weight: bold; font-style: normal; - text-transform: uppercase; - color: #AAA; + font-size: 0.9em; } p:last-child { @@ -614,12 +605,12 @@ section.problem { } div.capa_alert { + margin-top: $baseline; padding: 8px 12px; - border: 1px solid #EBE8BF; + border: 1px solid #ebe8bf; border-radius: 3px; - background: #FFFCDD; + background: #fffcdd; font-size: 0.9em; - margin-top: 10px; } div.capa_reset { @@ -628,12 +619,14 @@ section.problem { background-color: lighten($error-red, 25%); border-radius: 3px; font-size: 1em; - margin-top: 10px; - margin-bottom: 10px; + margin-top: $baseline/2; + margin-bottom: $baseline/2; } + .capa_reset>h2 { - color: #AA0000; + color: #aa0000; } + .capa_reset li { font-size: 0.9em; } @@ -642,10 +635,10 @@ section.problem { border: 1px solid #ccc; h3 { - border-bottom: 1px solid #e3e3e3; - text-shadow: 0 1px 0 #fff; padding: 9px; + border-bottom: 1px solid #e3e3e3; background: #eee; + text-shadow: 0 1px 0 #fff; font-weight: bold; font-size: em(16); } @@ -665,7 +658,7 @@ section.problem { a { display: block; padding: 9px; - background: #F6F6F6; + background: #f6f6f6; box-shadow: inset 0 0 0 1px #fff; } } @@ -683,22 +676,22 @@ section.problem { margin-bottom: 12px; h3 { - font-size: 0.9em; + color: #aaa; + text-transform: uppercase; font-weight: bold; font-style: normal; - text-transform: uppercase; - color: #AAA; + font-size: 0.9em; } } > section { - border: 1px solid #ddd; - padding: 9px 9px 20px; - margin-bottom: 10px; - background: #FFF; position: relative; - box-shadow: inset 0 0 0 1px #eee; + margin-bottom: $baseline/2; + padding: 9px 9px $baseline; + border: 1px solid #ddd; border-radius: 3px; + background: #fff; + box-shadow: inset 0 0 0 1px #eee; p:last-of-type { margin-bottom: 0; @@ -709,28 +702,29 @@ section.problem { } a.full { - @include position(absolute, 0 0 1px 0px); - font-size: .8em; - padding: 4px; - text-align: right; - width: 100%; - display: block; - background: #F3F3F3; + @include position(absolute, 0 0 1px 0); @include box-sizing(border-box); + display: block; + padding: 4px; + width: 100%; + background: #f3f3f3; + text-align: right; + font-size: .8em; } } } .external-grader-message { section { - padding-left: 20px; - background-color: #FAFAFA; - color: #2C2C2C; - font-family: monospace; + padding-top: $baseline/2; + padding-left: $baseline; + background-color: #fafafa; + color: #2c2c2c; font-size: 1em; - padding-top: 10px; + font-family: monospace; + header { - font-size: 1.4em; + font-size: 1.4em; } .shortform { @@ -738,35 +732,36 @@ section.problem { } .longform { - padding: 0px; - margin: 0px; + margin: 0; + padding: 0; .result-errors { - margin: 5px; - padding: 10px 10px 10px 40px; + margin: $baseline/4; + padding: $baseline/2 $baseline/2 $baseline/2 $baseline*2; background: url('../images/incorrect-icon.png') center left no-repeat; + li { - color: #B00; - } + color: #b00; + } } .result-output { - margin: 5px; - padding: 20px 0px 15px 50px; - border-top: 1px solid #DDD; - border-left: 20px solid #FAFAFA; + margin: $baseline/4; + padding: $baseline 0 15px 50px; + border-top: 1px solid #ddd; + border-left: $baseline solid #fafafa; h4 { - font-family: monospace; font-size: 1em; + font-family: monospace; } dl { - margin: 0px; + margin: 0; } dt { - margin-top: 20px; + margin-top: $baseline; } dd { @@ -776,6 +771,7 @@ section.problem { .result-correct { background: url('../images/correct-icon.png') left 20px no-repeat; + .result-actual-output { color: #090; } @@ -783,6 +779,7 @@ section.problem { .result-incorrect { background: url('../images/incorrect-icon.png') left 20px no-repeat; + .result-actual-output { color: #B00; } @@ -790,16 +787,16 @@ section.problem { .markup-text{ margin: 5px; - padding: 20px 0px 15px 50px; - border-top: 1px solid #DDD; - border-left: 20px solid #FAFAFA; + padding: $baseline 0 15px 50px; + border-top: 1px solid #ddd; + border-left: 20px solid #fafafa; bs { - color: #BB0000; + color: #bb0000; } bg { - color: #BDA046; + color: #bda046; } } } @@ -807,96 +804,111 @@ section.problem { } .rubric { - tr { - margin:10px 0px; - height: 100%; - } - td { - padding: 20px 0px; - margin: 10px 0px; - height: 100%; - } - th { - padding: 5px; - margin: 5px; - } - label, - .view-only { - margin:3px; - position: relative; - padding: 15px; - width: 150px; - height:100%; - display: inline-block; - min-height: 50px; - min-width: 50px; - background-color: #CCC; - font-size: .9em; - } - .grade { - position: absolute; - bottom:0px; - right:0px; - margin:10px; - } - .selected-grade { - background: #666; - color: white; - } - input[type=radio]:checked + label { - background: #666; - color: white; } - input[class='score-selection'] { - display: none; - } + tr { + margin: $baseline/2 0; + height: 100%; + } + + td { + margin: $baseline/2 0; + padding: $baseline 0; + height: 100%; + } + + th { + margin: $baseline/4; + padding: $baseline/4; + } + + label, + .view-only { + position: relative; + display: inline-block; + margin: 3px; + padding: 15px; + min-width: 50px; + min-height: 50px; + width: 150px; + height: 100%; + background-color: #ccc; + font-size: .9em; + } + + .grade { + position: absolute; + right: 0; + bottom: 0; + margin: $baseline/2; + } + + .selected-grade { + background: #666; + color: white; + } + + input[type=radio]:checked + label { + background: #666; + color: white; + } + + input[class='score-selection'] { + display: none; + } } .annotation-input { $yellow: rgba(255,255,10,0.3); - + margin: 0 0 1em 0; border: 1px solid #ccc; border-radius: 1em; - margin: 0 0 1em 0; .annotation-header { - font-weight: bold; - border-bottom: 1px solid #ccc; padding: .5em 1em; + border-bottom: 1px solid #ccc; + font-weight: bold; } + .annotation-body { padding: .5em 1em; } + a.annotation-return { float: right; font: inherit; font-weight: normal; } + a.annotation-return:after { content: " \2191" } .block, ul.tags { margin: .5em 0; padding: 0; } + .block-highlight { padding: .5em; + border: 1px solid darken($yellow, 10%); + background-color: $yellow; color: #333; font-style: normal; - background-color: $yellow; - border: 1px solid darken($yellow, 10%); } + .block-comment { font-style: italic; } ul.tags { display: block; - list-style-type: none; margin-left: 1em; + list-style-type: none; + li { + position: relative; display: block; margin: 1em 0 0 0; - position: relative; + .tag { display: inline-block; - cursor: pointer; + margin-left: $baseline; border: 1px solid rgb(102,102,102); - margin-left: 40px; + cursor: pointer; + &.selected { background-color: $yellow; } @@ -908,42 +920,49 @@ section.problem { .tag-status, .tag { padding: .25em .5em; } } } + textarea.comment { $num-lines-to-show: 5; $line-height: 1.4em; $padding: .2em; - width: 100%; padding: $padding (2 * $padding); - line-height: $line-height; + width: 100%; height: ($num-lines-to-show * $line-height) + (2*$padding) - (($line-height - 1)/2); + line-height: $line-height; } + .answer-annotation { display: block; margin: 0; } /* for debugging the input value field. enable the debug flag on the inputtype */ .debug-value { - color: #fff; - padding: 1em; - margin: 1em 0; - background-color: #999; - border: 1px solid #000; - input[type="text"] { width: 100%; } - pre { background-color: #CCC; color: #000; } - &:before { - display: block; - content: "debug input value"; - text-transform: uppercase; - font-weight: bold; - font-size: 1.5em; - } + margin: 1em 0; + padding: 1em; + border: 1px solid #000; + background-color: #999; + color: #fff; + + input[type="text"] { width: 100%; } + + pre { background-color: #CCC; color: #000; } + + &:before { + display: block; + content: "debug input value"; + text-transform: uppercase; + font-weight: bold; + font-size: 1.5em; + } } } + .choicetextgroup{ + @extend .choicegroup; + input[type="text"]{ margin-bottom: 0.5em; } - @extend .choicegroup; - label.choicetextgroup_correct, section.choicetextgroup_correct{ + label.choicetextgroup_correct, section.choicetextgroup_correct { @extend label.choicegroup_correct; input[type="text"] { @@ -951,17 +970,18 @@ section.problem { } } - label.choicetextgroup_incorrect, section.choicetextgroup_incorrect{ + label.choicetextgroup_incorrect, section.choicetextgroup_incorrect { @extend label.choicegroup_incorrect; } - label.choicetextgroup_show_correct, section.choicetextgroup_show_correct{ - &:after{ - content: url('../images/correct-icon.png'); + label.choicetextgroup_show_correct, section.choicetextgroup_show_correct { + &:after { margin-left:15px; + content: url('../images/correct-icon.png'); } } - span.mock_label{ + + span.mock_label { cursor : default; } } diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index e91765b50a..61b1901866 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -17,18 +17,17 @@ h2 { } div.name{ - padding-bottom: 15px; - h2{ - display: inline; - } - - .progress-container - { - display: inline; - float: right; - padding-top: 3px; - } + padding-bottom: 15px; + + h2{ + display: inline; + } + .progress-container { + display: inline; + float: right; + padding-top: 3px; + } } .inline-error { @@ -36,177 +35,178 @@ div.name{ } section.combined-open-ended { - @include clearfix; + @include clearfix; } div.problemwrapper { + padding: $baseline/4; border: 1px solid; border-color: lightgray; - -webkit-border-radius: 10px; - -moz-border-radius: 10px; - border-radius: 10px; - padding: 5px; - .status-bar - { - float: right; + border-radius: 10px; + + .status-bar { display: inline-block; + float: right; padding: 5px; .problemtype{ - display: inline; - float: left; - background-color: #d4d4d4; - padding: 10px; - border-radius: 5px; + display: inline; + float: left; + padding: $baseline/2; + border-radius: 5px; + background-color: #d4d4d4; } - .statustable{ - width: 750px; + .statustable { + width: 750px; } - .status-container - { - padding-bottom: 5px; + .status-container { display: inline-block; + padding-bottom: $baseline/4; } - .problemtype-container{ - width: 40%; - padding-top: 12px; + .problemtype-container { + width: 40%; + padding-top: 12px; } - .assessments-container{ - width: 30%; - .assessment-text{ - display: inline-block; - } - } - } - .item-container - { - @clearfix - padding-bottom: 10px; - display: inline-block; - margin: 0px 15px 0px 15px; + .assessments-container { + width: 30%; - h4 { - padding-top: 10px; - border-top: 1px solid; - border-color: lightgray; + .assessment-text { + display: inline-block; } + } + } + .item-container { + @clearfix + padding-bottom: 10px; + display: inline-block; + margin: 0px 15px 0px 15px; + + h4 { + padding-top: $baseline/2; + border-top: 1px solid; + border-color: lightgray; + } } - .result-container - { - float:left; - width: 100%; - position:relative; + .result-container { + float:left; + width: 100%; + position:relative; } } section.legend-container { + margin: 15px; + .legenditem { + display: inline; + padding: 2px; + width: 20%; background-color : #d4d4d4; font-size: .9em; - padding: 2px; - display: inline; - width: 20%; } - margin: 15px; } section.combined-open-ended-status { + padding: $baseline $baseline $baseline 0; - padding: 20px 20px 20px 0px; - .statusitem { - &:first-child{ - border-bottom-left-radius: 10px; - } - &:last-child{ - border-top-right-radius: 10px; - } - color: #2C2C2C; - background-color : #d4d4d4; - font-size: .9em; - padding: 10px; - display: inline; - width: 20%; - .show-results { - margin-top: .3em; - text-align:right; - } - .show-results-button { - font: 1em monospace; - } + .statusitem { + display: inline; + padding: $baseline/2; + width: 20%; + background-color : #d4d4d4; + color: #2c2c2c; + font-size: .9em; + + &:first-child { + border-bottom-left-radius: 10px; } - .statusitem-current { - background-color: #B2B2B2; - color: #222; - } - - span { - &.unanswered { - @include inline-block(); - background: url('../images/unanswered-icon.png') center center no-repeat; - height: 14px; - position: relative; - width: 14px; - float: right; - } - - &.correct { - @include inline-block(); - background: url('../images/correct-icon.png') center center no-repeat; - height: 20px; - position: relative; - width: 25px; - float: right; - } - - &.incorrect { - @include inline-block(); - background: url('../images/incorrect-icon.png') center center no-repeat; - height: 20px; - width: 20px; - position: relative; - float: right; - } + &:last-child { + border-top-right-radius: 10px; + } + + .show-results { + margin-top: .3em; + text-align:right; } - .icon-caret-right { - display: inline-block; - vertical-align: baseline; - margin-right: ($baseline/4); + .show-results-button { + font: 1em monospace; } + } + + .statusitem-current { + background-color: #B2B2B2; + color: #222; + } + + span { + &.unanswered { + @include inline-block(); + position: relative; + float: right; + width: 14px; + height: 14px; + background: url('../images/unanswered-icon.png') center center no-repeat; + } + + &.correct { + @include inline-block(); + position: relative; + float: right; + width: 25px; + height: 20px; + background: url('../images/correct-icon.png') center center no-repeat; + } + + &.incorrect { + @include inline-block(); + position: relative; + float: right; + width: 20px; + height: 20px; + background: url('../images/incorrect-icon.png') center center no-repeat; + } + } + + .icon-caret-right { + display: inline-block; + vertical-align: baseline; + margin-right: ($baseline/4); + } } div.combined-rubric-container { - margin:15px; - ul.rubric-list{ + margin: 15px; + padding-bottom: 5px; + padding-top: 10px; + + ul.rubric-list { + margin: 4px; + padding: 0; list-style-type: none; - padding:0; - margin:4px; li { - &.rubric-list-item{ + &.rubric-list-item { margin-bottom: 2px; padding: 0px; } } } - - h4{ - padding-top: 10px; - border-top: 1px solid; - border-color: lightgray; - } + h4 { + padding-top: 10px; + border-top: 1px solid; + border-color: lightgray; + } span.rubric-category { font-size: .9em; font-weight: bold; } - padding-bottom: 5px; - padding-top: 10px; } div.result-container { @@ -342,8 +342,8 @@ div.result-container { section.open-ended-child { @media print { display: block; - width: auto; padding: 0; + width: auto; canvas, img { page-break-inside: avoid; @@ -351,30 +351,30 @@ section.open-ended-child { } .inline { - display: inline; + display: inline; } ol.enumerate { li { &:before { - content: " "; display: block; - height: 0; visibility: hidden; + height: 0; + content: " "; } } } .solution-span { > span { - margin: 20px 0; - display: block; - border: 1px solid #ddd; - padding: 9px 15px 20px; - background: #FFF; position: relative; - box-shadow: inset 0 0 0 1px #eee; + display: block; + margin: $baseline 0; + padding: 9px 15px $baseline; + border: 1px solid #ddd; border-radius: 3px; + background: #fff; + box-shadow: inset 0 0 0 1px #eee; &:empty { display: none; @@ -387,8 +387,8 @@ section.open-ended-child { margin-top: -2px; } &.status { + margin: 8px 0 0 $baseline/2; text-indent: -9999px; - margin: 8px 0 0 10px; } } @@ -404,9 +404,9 @@ section.open-ended-child { div.correct, div.ui-icon-check { p.status { @include inline-block(); - background: url('../images/correct-icon.png') center center no-repeat; - height: 20px; width: 25px; + height: 20px; + background: url('../images/correct-icon.png') center center no-repeat; } input { @@ -417,9 +417,9 @@ section.open-ended-child { div.processing { p.status { @include inline-block(); - background: url('../images/spinner.gif') center center no-repeat; - height: 20px; width: 20px; + height: 20px; + background: url('../images/spinner.gif') center center no-repeat; } input { @@ -430,9 +430,9 @@ section.open-ended-child { div.incorrect, div.ui-icon-close { p.status { @include inline-block(); - background: url('../images/incorrect-icon.png') center center no-repeat; - height: 20px; width: 20px; + height: 20px; + background: url('../images/incorrect-icon.png') center center no-repeat; text-indent: -9999px; } @@ -467,96 +467,90 @@ section.open-ended-child { span { &.unanswered, &.ui-icon-bullet { @include inline-block(); - background: url('../images/unanswered-icon.png') center center no-repeat; - height: 14px; position: relative; top: 4px; width: 14px; + height: 14px; + background: url('../images/unanswered-icon.png') center center no-repeat; } &.processing, &.ui-icon-processing { @include inline-block(); - background: url('../images/spinner.gif') center center no-repeat; - height: 20px; position: relative; top: 6px; width: 25px; + height: 20px; + background: url('../images/spinner.gif') center center no-repeat; } &.correct, &.ui-icon-check { @include inline-block(); - background: url('../images/correct-icon.png') center center no-repeat; - height: 20px; position: relative; top: 6px; width: 25px; + height: 20px; + background: url('../images/correct-icon.png') center center no-repeat; } &.incorrect, &.ui-icon-close { @include inline-block(); - background: url('../images/incorrect-icon.png') center center no-repeat; - height: 20px; - width: 20px; position: relative; top: 6px; + width: 20px; + height: 20px; + background: url('../images/incorrect-icon.png') center center no-repeat; } } - .reload - { + .reload { float:right; margin: 10px; } div.short-form-response { - background: #F6F6F6; - border: 1px solid #ddd; - margin-bottom: 0px; - overflow-y: auto; - height: 200px; - @include clearfix; - } + @include clearfix; + overflow-y: auto; + margin-bottom: 0; + height: 200px; + border: 1px solid #ddd; + background: #f6f6f6; + } .grader-status { - padding: 9px; - background: #F6F6F6; - border: 1px solid #ddd; - border-top: 0; - margin-bottom: 20px; @include clearfix; + margin-bottom: $baseline; + padding: 9px; + border: 1px solid #ddd; + border-top: 0px; + background: #F6F6F6; span { - text-indent: -9999px; - overflow: hidden; display: block; float: left; + overflow: hidden; margin: -7px 7px 0 0; + text-indent: -9999px; } .grading { - background: url('../images/info-icon.png') left center no-repeat; + margin: 0 7px 0 0; padding-left: 25px; - text-indent: 0px; - margin: 0px 7px 0 0; + background: url('../images/info-icon.png') left center no-repeat; + text-indent: 0; } p { - line-height: 20px; - margin-bottom: 0; float: left; + margin-bottom: 0; + line-height: 20px; } &.file { - background: #FFF; - margin-top: 20px; - padding: 20px 0 0 0; - - border: { - top: 1px solid #eee; - right: 0; - bottom: 0; - left: 0; - } + margin-top: $baseline; + padding: $baseline 0 0 0; + border: 0; + border-top: 1px solid #eee; + background: #fff; p.debug { display: none; @@ -570,8 +564,8 @@ section.open-ended-child { } form.option-input { - margin: -10px 0 20px; - padding-bottom: 20px; + margin: -$baseline/2 0 $baseline; + padding-bottom: $baseline; select { margin-right: flex-gutter(); @@ -579,29 +573,30 @@ section.open-ended-child { } ul { - list-style: disc outside none; margin-bottom: lh(); margin-left: .75em; margin-left: .75rem; + list-style: disc outside none; } ul.rubric-list{ - list-style-type: none; - padding:0; - margin:0; - li { - &.rubric-list-item{ - margin-bottom: 0px; - padding: 0px; - } - } + margin: 0; + padding: 0; + list-style-type: none; + + li { + &.rubric-list-item { + margin-bottom: 0; + padding: 0; + } + } } ol { - list-style: decimal outside none; margin-bottom: lh(); margin-left: .75em; margin-left: .75rem; + list-style: decimal outside none; } dl { @@ -622,8 +617,9 @@ section.open-ended-child { } li { - margin-bottom: 0px; - padding: 0px; + margin-bottom: 0; + padding: 0; + &:last-child { margin-bottom: 0; } @@ -634,14 +630,14 @@ section.open-ended-child { } hr { - background: #ddd; - border: none; - clear: both; - color: #ddd; float: none; - height: 1px; + clear: both; margin: 0 0 .75rem; width: 100%; + height: 1px; + border: none; + background: #ddd; + color: #ddd; } .hidden { @@ -655,7 +651,7 @@ section.open-ended-child { } section.action { - margin-top: 20px; + margin-top: $baseline; input.save { @extend .blue-button !optional; @@ -663,20 +659,20 @@ section.open-ended-child { .submission_feedback { @include inline-block; - font-style: italic; - margin: 8px 0 0 10px; + margin: 8px 0 0 $baseline/2; color: #777; + font-style: italic; -webkit-font-smoothing: antialiased; } } .detailed-solution { > p:first-child { - font-size: 0.9em; + color: #aaa; + text-transform: uppercase; font-weight: bold; font-style: normal; - text-transform: uppercase; - color: #AAA; + font-size: 0.9em; } p:last-child { @@ -686,66 +682,68 @@ section.open-ended-child { div.open-ended-alert, .save_message { + margin-top: $baseline/2; + margin-bottom: $baseline/4; padding: 8px 12px; - border: 1px solid #EBE8BF; + border: 1px solid #ebe8bf; border-radius: 3px; - background: #FFFCDD; + background: #fffcdd; font-size: 0.9em; - margin-top: 10px; - margin-bottom:5px; } div.capa_reset { + margin-top: $baseline/2; + margin-bottom: $baseline/2; padding: 25px; border: 1px solid $error-red; - background-color: lighten($error-red, 25%); border-radius: 3px; + background-color: lighten($error-red, 25%); font-size: 1em; - margin-top: 10px; - margin-bottom: 10px; } - .capa_reset>h2 { - color: #AA0000; + + .capa_reset > h2 { + color: #aa0000; } + .capa_reset li { font-size: 0.9em; } .assessment-container { - margin: 40px 0px 30px 0px; - .scoring-container - { - p - { - margin-bottom: 1em; - } - label { - margin: 10px; - padding: 5px; - display: inline-block; - min-width: 50px; - background-color: #CCC; - text-size: 1.5em; - } - - input[type=radio]:checked + label { - background: #666; - color: white; - } - input[class='grade-selection'] { - display: none; - } + margin: $baseline*2 0px 30px 0px; + .scoring-container { + p { + margin-bottom: 1em; } + + label { + display: inline-block; + margin: $baseline/2; + padding: $baseline/4; + min-width: 50px; + background-color: #ccc; + text-size: 1.5em; + } + + input[type=radio]:checked + label { + background: #666; + color: white; + } + + input[class='grade-selection'] { + display: none; + } + } } - div.prompt{ - background-color:white; + div.prompt { + background-color: white; } - h4{ - padding-top: 10px; - border-top: 1px solid; + h4 { + padding-top: $baseline/2; border-color: lightgray; + border-top: 1px solid; } } From 178be44333659f4d2c178a6ce9b727ea9effe48d Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 1 Aug 2013 16:25:21 -0400 Subject: [PATCH 021/395] Remove legend, start to edit rubric --- .../css/combinedopenended/display.scss | 14 +++++++++ .../js/src/combinedopenended/display.coffee | 31 ++++++++----------- .../combined_open_ended_rubric.py | 10 ++++++ .../openendedchild.py | 6 ++-- .../combined_open_ended_results.html | 2 +- .../openended/open_ended_combined_rubric.html | 25 ++++++++------- 6 files changed, 54 insertions(+), 34 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 61b1901866..5e217e5ee0 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -207,6 +207,20 @@ div.combined-rubric-container { font-size: .9em; font-weight: bold; } + + label.choicegroup_correct { + &:before { + margin-right: 15px; + content: url('../images/correct-icon.png'); + } + } + + label.choicegroup_incorrect { + &:before { + margin-right: 15px; + content: url('../images/incorrect-icon.png'); + } + } } div.result-container { diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 96ad75f088..6022bdd4cc 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -55,7 +55,6 @@ class @Rubric data = {location: location, selection: target_selection, category: category} Logger.log 'rubric_select', data - # finds the scores for each rubric category @get_score_list: () => # find the number of categories: @@ -97,7 +96,6 @@ class @CombinedOpenEnded submit_evaluation_sel: '.submit-evaluation-button' result_container_sel: 'div.result-container' combined_rubric_sel: '.combined-rubric-container' - legend_sel: '.legend-container' open_ended_child_sel: 'section.open-ended-child' error_sel: '.error' answer_area_sel: 'textarea.answer' @@ -114,6 +112,8 @@ class @CombinedOpenEnded sub_id_sel: 'input.submission_id' grader_id_sel: 'input.grader_id' grader_status_sel: '.grader-status' + info_rubric_elements_sel: '.rubric-elements-info' + rubric_collapse_sel: '.rubric-collapse' constructor: (el) -> @el=el @@ -194,10 +194,6 @@ class @CombinedOpenEnded else if @task_number==1 and @child_state!='initial' @prompt_hide() - if @child_state!="initial" - @show_legend_current() - @legend_container= @$(@legend_sel) - @find_assessment_elements() @find_hint_elements() @@ -240,7 +236,10 @@ class @CombinedOpenEnded $.postWithPrefix "#{@ajax_url}/get_combined_rubric", data, (response) => if response.success @combined_rubric_container.after(response.html).remove() - @combined_rubric_container= $(@combined_rubric_sel) + @combined_rubric_container= @$(@combined_rubric_sel) + @toggle_rubric("") + @rubric_collapse = @$(@rubric_collapse_sel) + @rubric_collapse.click @toggle_rubric show_status_current: () => data = {} @@ -249,13 +248,6 @@ class @CombinedOpenEnded @status_container.after(response.html).remove() @status_container= $(@status_container_sel) - show_legend_current: () => - data = {} - $.postWithPrefix "#{@ajax_url}/get_legend", data, (response) => - if response.success - @legend_container.after(response.html).remove() - @legend_container= $(@legend_sel) - get_last_response: () => data = {} $.postWithPrefix "#{@ajax_url}/get_last_response", data, (response) => @@ -302,7 +294,6 @@ class @CombinedOpenEnded @reset_button.hide() @next_problem_button.hide() @hide_file_upload() - @legend_container.show() @hint_area.attr('disabled', false) if @task_number>1 or @child_state!='initial' @show_status_current() @@ -325,7 +316,6 @@ class @CombinedOpenEnded @submit_button.prop('value', 'Submit') @submit_button.click @save_answer @setup_file_upload() - @legend_container.hide() else if @child_state == 'assessing' @answer_area.attr("disabled", true) @replace_text_inputs() @@ -338,7 +328,6 @@ class @CombinedOpenEnded if @task_number==1 and @task_count==1 @grader_status = @$(@grader_status_sel) @grader_status.html("

Response submitted for scoring.

") - @legend_container.hide() else if @child_state == 'post_assessment' if @child_type=="openended" @skip_button.show() @@ -568,7 +557,7 @@ class @CombinedOpenEnded reload: -> location.reload() - collapse_question: () => + collapse_question: (event) => @prompt_container.slideToggle() @prompt_container.toggleClass('open') if @question_header.text() == "(Hide)" @@ -578,6 +567,7 @@ class @CombinedOpenEnded Logger.log 'oe_show_question', {location: @location} new_text = "(Hide)" @question_header.text(new_text) + return false prompt_show: () => if @prompt_container.is(":hidden")==true @@ -628,3 +618,8 @@ class @CombinedOpenEnded @$(@file_upload_preview_sel)[0].height = height_px/scale_factor @$(@file_upload_preview_sel).show() reader.readAsDataURL(@$(@file_upload_box_sel)[0].files[0]) + + toggle_rubric: (event) => + info_rubric_elements = @$(@info_rubric_elements_sel) + info_rubric_elements.slideToggle() + return false diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py index 6245d4d31c..0b2b0fdaec 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py @@ -208,6 +208,7 @@ class CombinedOpenEndedRubric(object): feedback_types) rubric_categories = self.extract_categories(rubric_xml) max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories) + actual_scores = [] max_score = max(max_scores) for i in xrange(0, len(rubric_categories)): category = rubric_categories[i] @@ -217,9 +218,18 @@ class CombinedOpenEndedRubric(object): if tuple[1] == i and tuple[2] == j: for grader_type in tuple[3]: rubric_categories[i]['options'][j]['grader_types'].append(grader_type) + if len(actual_scores)<=i: + actual_scores.append([j]) + else: + actual_scores[i] += [j] + + actual_scores = [sum(i)/len(i) for i in actual_scores] + correct = [int(a>.66) for a in actual_scores] html = self.system.render_template('{0}/open_ended_combined_rubric.html'.format(self.TEMPLATE_DIR), {'categories': rubric_categories, + 'max_scores': max_scores, + 'correct' : correct, 'has_score': True, 'view_only': True, 'max_score': max_score, diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 10f939b270..555505d611 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -58,7 +58,7 @@ class OpenEndedChild(object): 'assessing': 'In progress', 'post_assessment': 'Done', 'done': 'Done', - } + } def __init__(self, system, location, definition, descriptor, static_data, instance_state=None, shared_state=None, **kwargs): @@ -229,7 +229,7 @@ class OpenEndedChild(object): 'max_score': self._max_score, 'child_attempts': self.child_attempts, 'child_created': False, - } + } return json.dumps(state) def _allow_reset(self): @@ -485,4 +485,4 @@ class OpenEndedChild(object): else: eta_string = "" - return eta_string + return eta_string \ No newline at end of file diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 0a03737b8f..f1e9289221 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -1,4 +1,4 @@
-

${task_name}

+

${task_name}

(Show) ${results | n}
diff --git a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html index 61393cdc95..9ae148442f 100644 --- a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html @@ -7,18 +7,19 @@ <% option = category['options'][j] %>
  • - %for grader_type in category['options'][j]['grader_types']: - % if grader_type in grader_type_image_dict: - <% grader_image = grader_type_image_dict[grader_type] %> - % if grader_type in human_grader_types: - <% human_title = human_grader_types[grader_type] %> - % else: - <% human_title = grader_type %> - % endif - - % endif - %endfor - ${option['points']} points : ${option['text']} + %if len(category['options'][j]['grader_types'])>0: + %if correct[i]==1: +
  • % endfor From 0e8c29989c8c4ddc95e06b03abf58a962d183621 Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 2 Aug 2013 00:57:31 -0400 Subject: [PATCH 022/395] style cleanup for open ended assessments, cleaning up workflow header, style definitions for rubrics are commented out momentarily, and an open issue for unicode rendering on the show and hide prompt area still remains --- .../css/combinedopenended/display.scss | 128 ++++++++++++------ .../js/src/combinedopenended/display.coffee | 8 +- .../combined_open_ended.html | 8 +- 3 files changed, 93 insertions(+), 51 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 61b1901866..32e88d23e2 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -39,56 +39,74 @@ section.combined-open-ended { } div.problemwrapper { - padding: $baseline/4; - border: 1px solid; - border-color: lightgray; - border-radius: 10px; + border: 1px solid lightgray; + border-radius: $baseline/2; .status-bar { - display: inline-block; - float: right; - padding: 5px; - - .problemtype{ - display: inline; - float: left; - padding: $baseline/2; - border-radius: 5px; - background-color: #d4d4d4; - } + background-color: #eee; + border-radius: $baseline/2 $baseline/2 0 0; + border-bottom: 1px solid lightgray; .statustable { - width: 750px; + width: 100%; + padding: $baseline; } .status-container { - display: inline-block; - padding-bottom: $baseline/4; + display: table-cell; + text-align: center; + + .status-elements { + border-radius: $baseline/4; + border: 1px solid lightgray; + } } .problemtype-container { - width: 40%; - padding-top: 12px; + padding: $baseline/2; + width: 60%; + } + + .problemtype{ + padding: $baseline/2; } .assessments-container { - width: 30%; + padding: $baseline/2 $baseline $baseline/2 $baseline/2; + float: right; .assessment-text { display: inline-block; + display: table-cell; + padding-right: $baseline/2; } } } .item-container { - @clearfix - padding-bottom: 10px; - display: inline-block; - margin: 0px 15px 0px 15px; + padding-bottom: $baseline/2; + margin: 15px; - h4 { + .visibility-control-prompt { + display: block; + height: 40px; + width: 100%; + + .inner { + float: left; + height: 5px; + margin-top: 17px; + border-top: 1px dotted #ddd; + width: 85%; + } + } + + a { + display: block; + text-align: center; + width: 15%; + float: right; padding-top: $baseline/2; - border-top: 1px solid; - border-color: lightgray; + font-size: .9em; } } @@ -101,33 +119,36 @@ div.problemwrapper { section.legend-container { margin: 15px; + border-radius: $baseline/4; .legenditem { display: inline; - padding: 2px; + padding: $baseline/2; width: 20%; - background-color : #d4d4d4; + background-color : #eee; font-size: .9em; } } section.combined-open-ended-status { - padding: $baseline $baseline $baseline 0; + vertical-align: center; .statusitem { - display: inline; + display: table-cell; padding: $baseline/2; - width: 20%; - background-color : #d4d4d4; + width: 30px; + background-color: #eee; color: #2c2c2c; font-size: .9em; + border-right: 1px solid lightgray; &:first-child { - border-bottom-left-radius: 10px; + border-radius: $baseline/4 0 0 $baseline/4; } &:last-child { - border-top-right-radius: 10px; + border-radius: 0 $baseline/4 $baseline/4 0; + border-right: 0px; } .show-results { @@ -141,8 +162,9 @@ section.combined-open-ended-status { } .statusitem-current { - background-color: #B2B2B2; - color: #222; + background-color: #fff; + box-shadow: inset 0 1px 1px gray; + color: #222; } span { @@ -187,15 +209,29 @@ div.combined-rubric-container { padding-top: 10px; ul.rubric-list { - margin: 4px; + margin: 0 $baseline $baseline/2 $baseline; padding: 0; list-style-type: none; + li { + &.rubric-list-item { margin-bottom: 2px; padding: 0px; } } + + .score-selection { + //display: inline-block; + //padding-right: $baseline/2; + //width: 5%; + //vertical-align: center; + } + + .wrappable { + //display: inline-block; + //width: 94%; + } } h4 { padding-top: 10px; @@ -204,8 +240,11 @@ div.combined-rubric-container { } span.rubric-category { - font-size: .9em; + display: block; + width: 100%; + border-bottom: 1px solid lightgray; font-weight: bold; + font-size: .9em; } } @@ -511,6 +550,7 @@ section.open-ended-child { @include clearfix; overflow-y: auto; margin-bottom: 0; + padding: $baseline/2; height: 200px; border: 1px solid #ddd; background: #f6f6f6; @@ -521,8 +561,8 @@ section.open-ended-child { margin-bottom: $baseline; padding: 9px; border: 1px solid #ddd; - border-top: 0px; - background: #F6F6F6; + border-top: 0; + background: #f6f6f6; span { display: block; @@ -742,8 +782,6 @@ section.open-ended-child { } h4 { - padding-top: $baseline/2; - border-color: lightgray; - border-top: 1px solid; + padding: $baseline/2 0; } } diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 96ad75f088..0a06c29d86 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -572,24 +572,24 @@ class @CombinedOpenEnded @prompt_container.slideToggle() @prompt_container.toggleClass('open') if @question_header.text() == "(Hide)" - new_text = "(Show)" + new_text = "↧ Show Prompt" Logger.log 'oe_hide_question', {location: @location} else Logger.log 'oe_show_question', {location: @location} - new_text = "(Hide)" + new_text = "↥ Hide Prompt" @question_header.text(new_text) prompt_show: () => if @prompt_container.is(":hidden")==true @prompt_container.slideToggle() @prompt_container.toggleClass('open') - @question_header.text("(Hide)") + @question_header.text("↥ Hide Prompt") prompt_hide: () => if @prompt_container.is(":visible")==true @prompt_container.slideToggle() @prompt_container.toggleClass('open') - @question_header.text("(Show)") + @question_header.text("↧ Show Prompt") log_feedback_click: (event) -> link_text = @$(event.target).html() diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index f66088be8a..5f1c80fc5f 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -11,7 +11,7 @@
    - Open Response Assessment + Open Response
    @@ -27,7 +27,11 @@
    -

    Prompt (Hide)

    +
    +
    +
    + ↥ Hide Prompt +
    % for item in items:
    ${item['content'] | n}
    From 30e506777efe6eb33c3989d1fe6950ae757eff1e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 2 Aug 2013 12:12:38 -0400 Subject: [PATCH 023/395] Render multiple rubrics, collapse rubrics --- .../js/src/combinedopenended/display.coffee | 6 +++ .../combined_open_ended_modulev1.py | 42 +++++++++++-------- .../combined_open_ended_results.html | 11 +++-- 3 files changed, 38 insertions(+), 21 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 6022bdd4cc..db41288324 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -622,4 +622,10 @@ class @CombinedOpenEnded toggle_rubric: (event) => info_rubric_elements = @$(@info_rubric_elements_sel) info_rubric_elements.slideToggle() + @rubric_header = @$(@rubric_collapse_sel) + if @rubric_header.text() == "(Hide)" + new_text = "(Show)" + else + new_text = "(Hide)" + @rubric_header.text(new_text) return false diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 3ceafdca65..d9d296175e 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -503,27 +503,35 @@ class CombinedOpenEndedV1Module(): """ all_responses = [] loop_up_to_task = self.current_task_number + 1 + contexts = [] for i in xrange(0, loop_up_to_task): - all_responses.append(self.get_last_response(i)) - rubric_scores = [all_responses[i]['rubric_scores'] for i in xrange(0, len(all_responses)) if - len(all_responses[i]['rubric_scores']) > 0 and all_responses[i]['grader_types'][ - 0] in HUMAN_GRADER_TYPE.keys()] - grader_types = [all_responses[i]['grader_types'] for i in xrange(0, len(all_responses)) if - len(all_responses[i]['grader_types']) > 0 and all_responses[i]['grader_types'][ - 0] in HUMAN_GRADER_TYPE.keys()] - feedback_items = [all_responses[i]['feedback_items'] for i in xrange(0, len(all_responses)) if - len(all_responses[i]['feedback_items']) > 0 and all_responses[i]['grader_types'][ - 0] in HUMAN_GRADER_TYPE.keys()] - rubric_html = self.rubric_renderer.render_combined_rubric(stringify_children(self.static_data['rubric']), - rubric_scores, - grader_types, feedback_items) + response = self.get_last_response(i) + rubric_scores = None + if len(response['rubric_scores']) > 0 and response['grader_types'][0] in HUMAN_GRADER_TYPE.keys(): + rubric_scores = [response['rubric_scores']] + grader_types = None + if len(response['grader_types']) > 0 and response['grader_types'][0] in HUMAN_GRADER_TYPE.keys(): + grader_types = [response['grader_types']] + feedback_items = None + if len(response['feedback_items']) > 0 and response['grader_types'][0] in HUMAN_GRADER_TYPE.keys(): + feedback_items = [response['feedback_items']] + if feedback_items is not None and grader_types is not None and rubric_scores is not None: + rubric_html = self.rubric_renderer.render_combined_rubric(stringify_children(self.static_data['rubric']), + rubric_scores, + grader_types, feedback_items) + contexts.append({ + 'result': rubric_html, + 'task_name': 'Scored Rubric', + 'class_name': 'combined-rubric-container' + }) - response_dict = all_responses[-1] context = { - 'results': rubric_html, - 'task_name': 'Scored Rubric', - 'class_name': 'combined-rubric-container' + 'results': contexts, + 'name' : 'name', } + + log.info(contexts) + html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index f1e9289221..90fca863b1 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -1,4 +1,7 @@ -
    -

    ${task_name}

    (Show) - ${results | n} -
    + +% for (i,result) in enumerate(results): +
    +

    ${result['task_name']}(Hide)

    + ${result['result'] | n} +
    +% endfor From a939c152c80a6d271cf2632ad24f745d296176cf Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 2 Aug 2013 12:56:31 -0400 Subject: [PATCH 024/395] Add in support for multiple rubrics --- .../combined_open_ended_modulev1.py | 4 +--- .../combined_open_ended_results.html | 22 ++++++++++++++----- 2 files changed, 18 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index d9d296175e..a943ccda49 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -521,13 +521,11 @@ class CombinedOpenEndedV1Module(): grader_types, feedback_items) contexts.append({ 'result': rubric_html, - 'task_name': 'Scored Rubric', - 'class_name': 'combined-rubric-container' + 'task_name': 'Scored Rubric' }) context = { 'results': contexts, - 'name' : 'name', } log.info(contexts) diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 90fca863b1..84f186fbb6 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -1,7 +1,19 @@ - % for (i,result) in enumerate(results): -
    -

    ${result['task_name']}(Hide)

    - ${result['result'] | n} -
    + % if 'task_name' in result and 'result' in result: +
    0: + status="shown"> + % else: + status="hidden"> + % endif +

    ${result['task_name']} from grader ${i+1} (Hide)

    + ${result['result'] | n} +
    + %endif + % endfor +% if len(results)>1: + Previous + Next +% endif + From bca308e70efc312139a416d1c1d59ba8fd5b05ab Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 2 Aug 2013 13:10:03 -0400 Subject: [PATCH 025/395] No more log.info --- .../combined_open_ended_modulev1.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index a943ccda49..dd88a23077 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -452,7 +452,6 @@ class CombinedOpenEndedV1Module(): def extract_human_name_from_task(self, task_xml): tree = etree.fromstring(task_xml) - log.info(etree.tostring(tree)) payload = tree.xpath("/openended/openendedparam/grader_payload") if len(payload)==0: task_name = "selfassessment" @@ -527,9 +526,6 @@ class CombinedOpenEndedV1Module(): context = { 'results': contexts, } - - log.info(contexts) - html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} From 932bbd259247f5b69f2d39c0a1945951816fedeb Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 2 Aug 2013 13:23:03 -0400 Subject: [PATCH 026/395] Fix text issue --- .../xmodule/js/src/combinedopenended/display.coffee | 10 +++++----- .../combined_open_ended_modulev1.py | 2 +- .../combinedopenended/combined_open_ended.html | 2 +- 3 files changed, 7 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index e82ebeb4f5..b539c72554 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -560,12 +560,12 @@ class @CombinedOpenEnded collapse_question: (event) => @prompt_container.slideToggle() @prompt_container.toggleClass('open') - if @question_header.text() == "(Hide)" - new_text = "↧ Show Prompt" + if @question_header.text() == "Hide Prompt" + new_text = "Show Prompt" Logger.log 'oe_hide_question', {location: @location} else Logger.log 'oe_show_question', {location: @location} - new_text = "↥ Hide Prompt" + new_text = "Hide Prompt" @question_header.text(new_text) return false @@ -573,13 +573,13 @@ class @CombinedOpenEnded if @prompt_container.is(":hidden")==true @prompt_container.slideToggle() @prompt_container.toggleClass('open') - @question_header.text("↥ Hide Prompt") + @question_header.text("Hide Prompt") prompt_hide: () => if @prompt_container.is(":visible")==true @prompt_container.slideToggle() @prompt_container.toggleClass('open') - @question_header.text("↧ Show Prompt") + @question_header.text("Show Prompt") log_feedback_click: (event) -> link_text = @$(event.target).html() diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index dd88a23077..ae3d01c686 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -520,7 +520,7 @@ class CombinedOpenEndedV1Module(): grader_types, feedback_items) contexts.append({ 'result': rubric_html, - 'task_name': 'Scored Rubric' + 'task_name': 'Scored rubric' }) context = { diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 5f1c80fc5f..b517e60976 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -30,7 +30,7 @@
    % for item in items: From ec99e20a356b778bb35674d05faf155448d6e8b3 Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 2 Aug 2013 13:37:53 -0400 Subject: [PATCH 027/395] styled rubric, dividers between sections --- .../css/combinedopenended/display.scss | 100 +++++++++++------- .../js/src/combinedopenended/display.coffee | 8 +- .../src/staff_grading/test_grading.html | 6 +- .../combined_open_ended.html | 2 +- .../openended/open_ended.html | 6 +- .../openended/open_ended_rubric.html | 8 +- .../self_assessment_prompt.html | 7 +- 7 files changed, 88 insertions(+), 49 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 224f554101..2873e37e73 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -1,3 +1,6 @@ +// lms - xmodule - combinedopenended +// ==================== + h2 { margin-top: 0; margin-bottom: 15px; @@ -16,6 +19,7 @@ h2 { } } + // Problem Header div.name{ padding-bottom: 15px; @@ -38,6 +42,7 @@ section.combined-open-ended { @include clearfix; } + div.problemwrapper { border: 1px solid lightgray; border-radius: $baseline/2; @@ -85,29 +90,6 @@ div.problemwrapper { .item-container { padding-bottom: $baseline/2; margin: 15px; - - .visibility-control-prompt { - display: block; - height: 40px; - width: 100%; - - .inner { - float: left; - height: 5px; - margin-top: 17px; - border-top: 1px dotted #ddd; - width: 85%; - } - } - - a { - display: block; - text-align: center; - width: 15%; - float: right; - padding-top: $baseline/2; - font-size: .9em; - } } .result-container { @@ -163,7 +145,6 @@ section.combined-open-ended-status { .statusitem-current { background-color: #fff; - box-shadow: inset 0 1px 1px gray; color: #222; } @@ -203,6 +184,63 @@ section.combined-open-ended-status { } } + // Problem Section Controls + +.visibility-control, .visibility-control-prompt { + display: block; + height: 40px; + width: 100%; + + .inner { + float: left; + height: 5px; + margin-top: $baseline; + border-top: 1px dotted #ddd; + width: 85%; + } +} + +.section-header { + display: block; + text-align: center; + width: 15%; + float: right; + padding-top: $baseline/2; + font-size: .9em; +} + + +// Rubric Styling + +.wrapper-score-selection { + display: table-cell; + padding: 0 $baseline/2; + width: 20px; + vertical-align: middle; + +} + +.wrappable { + display: table-cell; + padding: $baseline/4 0 ; +} + +.rubric-list-item { + margin-bottom: 2px; + padding: $baseline/2; + + &:hover { + background-color: #eee; + } +} + +span.rubric-category { + display: block; + width: 100%; + border-bottom: 1px solid lightgray; + font-size: .9em; +} + div.combined-rubric-container { margin: 15px; padding-bottom: 5px; @@ -217,21 +255,9 @@ div.combined-rubric-container { &.rubric-list-item { margin-bottom: 2px; - padding: 0px; + padding: $baseline/2; } } - - .score-selection { - //display: inline-block; - //padding-right: $baseline/2; - //width: 5%; - //vertical-align: center; - } - - .wrappable { - //display: inline-block; - //width: 94%; - } } h4 { padding-top: 10px; diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index bbf4a0eacc..eded6b0391 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -561,11 +561,11 @@ class @CombinedOpenEnded @prompt_container.slideToggle() @prompt_container.toggleClass('open') if @question_header.text() == "(Hide)" - new_text = "↧ Show Prompt" + new_text = "Show Prompt" Logger.log 'oe_hide_question', {location: @location} else Logger.log 'oe_show_question', {location: @location} - new_text = "↥ Hide Prompt" + new_text = "Hide Prompt" @question_header.text(new_text) return false @@ -573,13 +573,13 @@ class @CombinedOpenEnded if @prompt_container.is(":hidden")==true @prompt_container.slideToggle() @prompt_container.toggleClass('open') - @question_header.text("↥ Hide Prompt") + @question_header.text("Hide Prompt") prompt_hide: () => if @prompt_container.is(":visible")==true @prompt_container.slideToggle() @prompt_container.toggleClass('open') - @question_header.text("↧ Show Prompt") + @question_header.text("Show Prompt") log_feedback_click: (event) -> link_text = @$(event.target).html() diff --git a/lms/static/coffee/src/staff_grading/test_grading.html b/lms/static/coffee/src/staff_grading/test_grading.html index 9b84d0703b..6d3a6e3637 100644 --- a/lms/static/coffee/src/staff_grading/test_grading.html +++ b/lms/static/coffee/src/staff_grading/test_grading.html @@ -23,7 +23,11 @@
    -

    Rubric

    +
    +
    +
    + Rubric +
    diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 5f1c80fc5f..60bdd38a1b 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -30,7 +30,7 @@
    - ↥ Hide Prompt + Prompt
    % for item in items: diff --git a/lms/templates/combinedopenended/openended/open_ended.html b/lms/templates/combinedopenended/openended/open_ended.html index 909ef15838..f2d59c4049 100644 --- a/lms/templates/combinedopenended/openended/open_ended.html +++ b/lms/templates/combinedopenended/openended/open_ended.html @@ -3,7 +3,11 @@
    ${prompt|n}
    -

    Response

    +
    +
    +
    + Response +
    diff --git a/lms/templates/combinedopenended/openended/open_ended_rubric.html b/lms/templates/combinedopenended/openended/open_ended_rubric.html index 144cd829d9..c015a32d2d 100644 --- a/lms/templates/combinedopenended/openended/open_ended_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_rubric.html @@ -1,5 +1,9 @@
    -

    Rubric

    +
    +
    +
    + Rubric +

    Select the criteria you feel best represents this submission in each category.

    % for i in range(len(categories)): @@ -14,7 +18,7 @@
  • % endif
  • diff --git a/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html b/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html index 5347e23844..5755eeec3a 100644 --- a/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html +++ b/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html @@ -3,10 +3,11 @@
    ${prompt} +
    +
    +
    + Response
    - -

    Response

    -
    From 5da5d556303f4d6fa86eedd5b11aa46431e00e75 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 2 Aug 2013 13:41:37 -0400 Subject: [PATCH 028/395] Fix peer grading feedback issue --- .../xmodule/js/src/peergrading/peer_grading_problem.coffee | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index 050d525a13..47b2652020 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -482,7 +482,7 @@ class @PeerGradingProblem if response.actual_rubric != undefined calibration_wrapper.append("
    Instructor Scored Rubric: #{response.actual_rubric}
    ") - if response.actual_feedback!=undefined + if response.actual_feedback.feedback!=undefined calibration_wrapper.append("
    Instructor Feedback: #{response.actual_feedback}
    ") # disable score selection and submission from the grading interface From aa09006247210f73ea112e04861aca82cc5e9d6e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 2 Aug 2013 13:53:07 -0400 Subject: [PATCH 029/395] Hide and show multiple rubrics --- .../js/src/combinedopenended/display.coffee | 4 +++ .../combined_open_ended_modulev1.py | 30 +++++++++---------- .../combined_open_ended_results.html | 4 +-- 3 files changed, 20 insertions(+), 18 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index b539c72554..0db6521d27 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -569,6 +569,10 @@ class @CombinedOpenEnded @question_header.text(new_text) return false + hide_rubrics: () => + @$(combined_rubric_sel + ' > [data-status="hidden"]').hide() + @$(combined_rubric_sel + ' > [data-status="shown"]').show() + prompt_show: () => if @prompt_container.is(":hidden")==true @prompt_container.slideToggle() diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index ae3d01c686..ad9d7a287f 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -506,22 +506,20 @@ class CombinedOpenEndedV1Module(): for i in xrange(0, loop_up_to_task): response = self.get_last_response(i) rubric_scores = None - if len(response['rubric_scores']) > 0 and response['grader_types'][0] in HUMAN_GRADER_TYPE.keys(): - rubric_scores = [response['rubric_scores']] - grader_types = None - if len(response['grader_types']) > 0 and response['grader_types'][0] in HUMAN_GRADER_TYPE.keys(): - grader_types = [response['grader_types']] - feedback_items = None - if len(response['feedback_items']) > 0 and response['grader_types'][0] in HUMAN_GRADER_TYPE.keys(): - feedback_items = [response['feedback_items']] - if feedback_items is not None and grader_types is not None and rubric_scores is not None: - rubric_html = self.rubric_renderer.render_combined_rubric(stringify_children(self.static_data['rubric']), - rubric_scores, - grader_types, feedback_items) - contexts.append({ - 'result': rubric_html, - 'task_name': 'Scored rubric' - }) + score_length = len(response['grader_types']) + log.info(response) + for z in xrange(0,score_length): + if response['grader_types'][z] in HUMAN_GRADER_TYPE.keys(): + rubric_scores = [[response['rubric_scores'][z]]] + grader_types = [[response['grader_types'][z]]] + feedback_items = [[response['feedback_items'][z]]] + rubric_html = self.rubric_renderer.render_combined_rubric(stringify_children(self.static_data['rubric']), + rubric_scores, + grader_types, feedback_items) + contexts.append({ + 'result': rubric_html, + 'task_name': 'Scored rubric' + }) context = { 'results': contexts, diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 84f186fbb6..24934be3d2 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -2,9 +2,9 @@ % if 'task_name' in result and 'result' in result:
    0: - status="shown"> + data-status="shown" data-number="${i}"> % else: - status="hidden"> + data-status="hidden" data-number="${i}"> % endif

    ${result['task_name']} from grader ${i+1} (Hide)

    ${result['result'] | n} From ea2b9191c14b39f521d40fbfb33dc98ed2c26437 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 2 Aug 2013 15:30:17 -0400 Subject: [PATCH 030/395] Allow student to switch between rubrics, tell user once peer grading required amount is done --- .../js/src/combinedopenended/display.coffee | 37 ++++++++++++++++++- .../peergrading/peer_grading_problem.coffee | 6 ++- .../xmodule/xmodule/peer_grading_module.py | 5 +++ .../combined_open_ended_results.html | 4 +- 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 0db6521d27..8aa1af96f6 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -114,6 +114,8 @@ class @CombinedOpenEnded grader_status_sel: '.grader-status' info_rubric_elements_sel: '.rubric-elements-info' rubric_collapse_sel: '.rubric-collapse' + next_rubric_sel: '.rubric-next-button' + previous_rubric_sel: '.rubric-previous-button' constructor: (el) -> @el=el @@ -240,6 +242,9 @@ class @CombinedOpenEnded @toggle_rubric("") @rubric_collapse = @$(@rubric_collapse_sel) @rubric_collapse.click @toggle_rubric + @hide_rubrics() + @$(@previous_rubric_sel).click @previous_rubric + @$(@next_rubric_sel).click @next_rubric show_status_current: () => data = {} @@ -570,8 +575,36 @@ class @CombinedOpenEnded return false hide_rubrics: () => - @$(combined_rubric_sel + ' > [data-status="hidden"]').hide() - @$(combined_rubric_sel + ' > [data-status="shown"]').show() + rubrics = @$(@combined_rubric_sel) + for rub in rubrics + if @$(rub).data('status')=="shown" + @$(rub).show() + else + @$(rub).hide() + + next_rubric: => + @shift_rubric(1) + return false + + previous_rubric: => + @shift_rubric(-1) + return false + + shift_rubric: (i) => + rubrics = @$(@combined_rubric_sel) + number = 0 + for rub in rubrics + if @$(rub).data('status')=="shown" + number = @$(rub).data('number') + @$(rub).data('status','hidden') + if i==1 and number < rubrics.length - 1 + number = number + i + + if i==-1 and number>0 + number = number + i + + @$(rubrics[number]).data('status', 'shown') + @hide_rubrics() prompt_show: () => if @prompt_container.is(":hidden")==true diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index 47b2652020..c02fa3f390 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -344,7 +344,11 @@ class @PeerGradingProblem if response.success @is_calibrated_check() @grading_message.fadeIn() - @grading_message.html("

    Successfully saved your feedback. Fetched the next essay.

    ") + message = "

    Successfully saved your feedback. Fetched the next essay." + if response.required_done + message = message + " You have completed the required number of gradings." + message = message + "

    " + @grading_message.html(message) else if response.error @render_error(response.error) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index 44114136aa..b21e4865ec 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -327,6 +327,11 @@ class PeerGradingModule(PeerGradingFields, XModule): try: response = self.peer_gs.save_grade(location, grader_id, submission_id, score, feedback, submission_key, rubric_scores, submission_flagged, answer_unknown) + + success, location_data = self.query_data_for_location() + response.update({'required_done' : False}) + if 'count_graded' in location_data and 'count_required' in location_data: + response['required_done'] = True return response except GradingServiceError: # This is a dev_facing_error diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 24934be3d2..71dbea4ec5 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -2,9 +2,9 @@ % if 'task_name' in result and 'result' in result:
    0: - data-status="shown" data-number="${i}"> - % else: data-status="hidden" data-number="${i}"> + % else: + data-status="shown" data-number="${i}"> % endif

    ${result['task_name']} from grader ${i+1} (Hide)

    ${result['result'] | n} From 562456ade7f1d26bf8e2509c84a006b9017ee1b5 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Fri, 2 Aug 2013 15:36:15 -0400 Subject: [PATCH 031/395] Make descriptorsystem inherit from Runtime --- common/lib/xmodule/xmodule/x_module.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index d399001a6a..7e4802d208 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -808,7 +808,7 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): return Fragment(self.get_html()) -class DescriptorSystem(object): +class DescriptorSystem(Runtime): def __init__(self, load_item, resources_fs, error_tracker, **kwargs): """ load_item: Takes a Location and returns an XModuleDescriptor From 7bfb0804f8034dcad9381f600356705c7c064b2c Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Fri, 2 Aug 2013 16:09:29 -0400 Subject: [PATCH 032/395] Switch to studio_view --- cms/djangoapps/contentstore/views/preview.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index f2a07abe32..2a0d71d569 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -75,9 +75,13 @@ def preview_component(request, location): component = modulestore().get_item(location) + # wrap_xmodule expects a function, so make a constant function + def get_render(): + return component.runtime.render(component, None, "studio_view").content + return render_to_response('component.html', { 'preview': get_module_previews(request, component)[0], - 'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(), + 'editor': wrap_xmodule(get_render, component, 'xmodule_edit.html')(), }) From 332703c8568e668998e5dcfefc328bdcb3b19d47 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Fri, 2 Aug 2013 16:23:25 -0400 Subject: [PATCH 033/395] Fix repeated doc builds --- rakelib/docs.rake | 5 ----- rakelib/tests.rake | 4 +--- 2 files changed, 1 insertion(+), 8 deletions(-) diff --git a/rakelib/docs.rake b/rakelib/docs.rake index ce6c65f7fb..ddd2549f2b 100644 --- a/rakelib/docs.rake +++ b/rakelib/docs.rake @@ -13,11 +13,6 @@ task :builddocs, [:options] do |t, args| path = "docs" end - Dir.chdir(path) do - sh('make html') - end - path = "docs" - Dir.chdir(path) do sh('make html') end diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 57861902bc..8b308efa15 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -44,8 +44,6 @@ task :test_docs do (You shouldn't fix rst warnings and errors for this to pass, just get rid of exceptions.)" puts (test_message % ["rake doc"]).colorize( :light_green ) test_sh('rake builddocs') - puts (test_message % ["rake doc[pub]"]).colorize( :light_green ) - test_sh('rake builddocs[pub]') end task :clean_test_files do @@ -161,4 +159,4 @@ task :coverage => :report_dirs do if not found_coverage_info puts "No coverage info found. Run `rake test` before running `rake coverage`." end -end \ No newline at end of file +end From 5c477a90045f49bc4d6cf120809dd62da5123d42 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Fri, 2 Aug 2013 16:24:07 -0400 Subject: [PATCH 034/395] Make builds quiet by default --- docs/course_authors/Makefile | 2 +- docs/data/Makefile | 2 +- docs/developers/Makefile | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/docs/course_authors/Makefile b/docs/course_authors/Makefile index a9054b9932..0321201267 100644 --- a/docs/course_authors/Makefile +++ b/docs/course_authors/Makefile @@ -15,7 +15,7 @@ endif # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees -c source $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +ALLSPHINXOPTS = -q -d $(BUILDDIR)/doctrees -c source $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source diff --git a/docs/data/Makefile b/docs/data/Makefile index fd08639327..281415b978 100644 --- a/docs/data/Makefile +++ b/docs/data/Makefile @@ -10,7 +10,7 @@ BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees -c source $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +ALLSPHINXOPTS = -q -d $(BUILDDIR)/doctrees -c source $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source diff --git a/docs/developers/Makefile b/docs/developers/Makefile index 168add14d6..868fc4a95b 100644 --- a/docs/developers/Makefile +++ b/docs/developers/Makefile @@ -10,7 +10,7 @@ BUILDDIR = build # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -d $(BUILDDIR)/doctrees -c source $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +ALLSPHINXOPTS = -q -d $(BUILDDIR)/doctrees -c source $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source From b38750e15d41e8830f484f14ba5e5f577cea1075 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 5 Aug 2013 11:09:43 -0400 Subject: [PATCH 035/395] Refactor wrap_xmodule call --- cms/djangoapps/contentstore/views/preview.py | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 2a0d71d569..e801faa4f1 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -75,13 +75,15 @@ def preview_component(request, location): component = modulestore().get_item(location) - # wrap_xmodule expects a function, so make a constant function - def get_render(): - return component.runtime.render(component, None, "studio_view").content + component.get_html = wrap_xmodule( + component.get_html, + component, + 'xmodule_edit.html' + ) return render_to_response('component.html', { 'preview': get_module_previews(request, component)[0], - 'editor': wrap_xmodule(get_render, component, 'xmodule_edit.html')(), + 'editor': component.runtime.render(component, None, 'studio_view').content, }) From cb54081d2eb4795b334d812edc76433d36d11935 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Tue, 6 Aug 2013 09:55:49 -0400 Subject: [PATCH 036/395] Fix notification problem --- .../open_ended_grading/open_ended_notifications.py | 14 +------------- 1 file changed, 1 insertion(+), 13 deletions(-) diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py index 1d6fa22929..44ff41be22 100644 --- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py +++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py @@ -146,19 +146,7 @@ def combined_notifications(course, user): #Get the time of the last login of the user last_login = user.last_login - - #Find the modules they have seen since they logged in - last_module_seen = StudentModule.objects.filter(student=user, course_id=course_id, - modified__gt=last_login).values('modified').order_by( - '-modified') - last_module_seen_count = last_module_seen.count() - - if last_module_seen_count > 0: - #The last time they viewed an updated notification (last module seen minus how long notifications are cached) - last_time_viewed = last_module_seen[0]['modified'] - datetime.timedelta(seconds=(NOTIFICATION_CACHE_TIME + 60)) - else: - #If they have not seen any modules since they logged in, then don't refresh - return {'pending_grading': False, 'img_path': img_path, 'response': notifications} + last_time_viewed = last_login - datetime.timedelta(seconds=(NOTIFICATION_CACHE_TIME + 60)) try: #Get the notifications from the grading controller From 0bbd80fedfea5f80f674c8c1dcefd18d80012df2 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Tue, 6 Aug 2013 14:00:31 -0400 Subject: [PATCH 037/395] Add verbose option --- docs/Makefile | 12 +++++++++--- docs/course_authors/Makefile | 6 ++++++ docs/data/Makefile | 11 +++++++++++ docs/developers/Makefile | 13 ++++++++++++- rakelib/docs.rake | 19 ++++++++++++------- rakelib/tests.rake | 4 ++-- 6 files changed, 52 insertions(+), 13 deletions(-) diff --git a/docs/Makefile b/docs/Makefile index 8e5e368266..3f441a2ef2 100644 --- a/docs/Makefile +++ b/docs/Makefile @@ -1,7 +1,13 @@ .PHONY: html +Q_FLAG = + +ifeq ($(quiet), true) +Q_FLAG = quiet=true +endif + html: - @cd $(CURDIR)/data && make html - @cd $(CURDIR)/course_authors && make html - @cd $(CURDIR)/developers && make html + @cd $(CURDIR)/data && make html $(Q_FLAG) + @cd $(CURDIR)/course_authors && make html $(Q_FLAG) + @cd $(CURDIR)/developers && make html $(Q_FLAG) diff --git a/docs/course_authors/Makefile b/docs/course_authors/Makefile index 0321201267..b05c2d944a 100644 --- a/docs/course_authors/Makefile +++ b/docs/course_authors/Makefile @@ -12,6 +12,12 @@ ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) $(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) endif +Q_FLAG = + +ifeq ($(quiet), true) +Q_FLAG = -q +endif + # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter diff --git a/docs/data/Makefile b/docs/data/Makefile index 281415b978..272527a445 100644 --- a/docs/data/Makefile +++ b/docs/data/Makefile @@ -7,6 +7,17 @@ SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +Q_FLAG = + +ifeq ($(quiet), true) +Q_FLAG = -q +endif + # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter diff --git a/docs/developers/Makefile b/docs/developers/Makefile index 868fc4a95b..65fea6d46e 100644 --- a/docs/developers/Makefile +++ b/docs/developers/Makefile @@ -7,10 +7,21 @@ SPHINXBUILD = sphinx-build PAPER = BUILDDIR = build +# User-friendly check for sphinx-build +ifeq ($(shell which $(SPHINXBUILD) >/dev/null 2>&1; echo $$?), 1) +$(error The '$(SPHINXBUILD)' command was not found. Make sure you have Sphinx installed, then set the SPHINXBUILD environment variable to point to the full path of the '$(SPHINXBUILD)' executable. Alternatively you can add the directory with the executable to your PATH. If you don't have Sphinx installed, grab it from http://sphinx-doc.org/) +endif + +Q_FLAG = + +ifeq ($(quiet), true) +Q_FLAG = -q +endif + # Internal variables. PAPEROPT_a4 = -D latex_paper_size=a4 PAPEROPT_letter = -D latex_paper_size=letter -ALLSPHINXOPTS = -q -d $(BUILDDIR)/doctrees -c source $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source +ALLSPHINXOPTS = $(Q_FLAG) -d $(BUILDDIR)/doctrees -c source $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source # the i18n builder cannot share the environment and doctrees with the others I18NSPHINXOPTS = $(PAPEROPT_$(PAPER)) $(SPHINXOPTS) source diff --git a/rakelib/docs.rake b/rakelib/docs.rake index ddd2549f2b..3af50e2492 100644 --- a/rakelib/docs.rake +++ b/rakelib/docs.rake @@ -2,19 +2,24 @@ require 'launchy' # --- Develop and public documentation --- desc "Invoke sphinx 'make build' to generate docs." -task :builddocs, [:options] do |t, args| - if args.options == 'dev' +task :builddocs, [:type, :quiet] do |t, args| + args.with_defaults(:quiet => "quiet") + if args.type == 'dev' path = "docs/developer" - elsif args.options == 'author' + elsif args.type == 'author' path = "docs/course_authors" - elsif args.options == 'data' + elsif args.type == 'data' path = "docs/data" else path = "docs" end Dir.chdir(path) do - sh('make html') + if args.quiet == 'verbose' + sh('make html quiet=false') + else + sh('make html') + end end end @@ -34,7 +39,7 @@ task :showdocs, [:options] do |t, args| end desc "Build docs and show them in browser" -task :doc, [:options] => :builddocs do |t, args| - Rake::Task["showdocs"].invoke(args.options) +task :doc, [:type, :quiet] => :builddocs do |t, args| + Rake::Task["showdocs"].invoke(args.type, args.quiet) end # --- Develop and public documentation --- diff --git a/rakelib/tests.rake b/rakelib/tests.rake index 8b308efa15..f940c8f9c4 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -40,9 +40,9 @@ end desc "Run documentation tests" task :test_docs do # Be sure that sphinx can build docs w/o exceptions. - test_message = "If test fails, you shoud run %s and look at whole output and fix exceptions. + test_message = "If test fails, you shoud run '%s' and look at whole output and fix exceptions. (You shouldn't fix rst warnings and errors for this to pass, just get rid of exceptions.)" - puts (test_message % ["rake doc"]).colorize( :light_green ) + puts (test_message % ["rake doc[docs,verbose]"]).colorize( :light_green ) test_sh('rake builddocs') end From 67167830093bad746fb45e32235cb7366dc916da Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Tue, 6 Aug 2013 14:01:22 -0400 Subject: [PATCH 038/395] Fix import mocks. Prevents sphinx from bailing on builds that involve unavailable imports. --- docs/developers/source/conf.py | 63 ++++++++++++++++++++++------------ 1 file changed, 41 insertions(+), 22 deletions(-) diff --git a/docs/developers/source/conf.py b/docs/developers/source/conf.py index 12e9cf822b..8d33e07e9d 100644 --- a/docs/developers/source/conf.py +++ b/docs/developers/source/conf.py @@ -3,7 +3,7 @@ #pylint: disable=W0622 #pylint: disable=W0212 #pylint: disable=W0613 - + import sys, os on_rtd = os.environ.get('READTHEDOCS', None) == 'True' @@ -26,7 +26,7 @@ html_static_path.append('source/_static') # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -#sys.path.insert(0, os.path.abspath('../../..')) +sys.path.insert(0, os.path.abspath('../../..')) root = os.path.abspath('../../..') sys.path.append(root) @@ -64,6 +64,45 @@ exclude_patterns = ['build'] # Output file base name for HTML help builder. htmlhelp_basename = 'edXDocs' +# --- Mock modules ------------------------------------------------------------ + +# Mock all the modules that the readthedocs build can't import +import mock + +class Mock(object): + def __init__(self, *args, **kwargs): + pass + + def __call__(self, *args, **kwargs): + return Mock() + + @classmethod + def __getattr__(cls, name): + if name in ('__file__', '__path__'): + return '/dev/null' + elif name[0] == name[0].upper(): + mockType = type(name, (), {}) + mockType.__module__ = __name__ + return mockType + else: + return Mock() + +# The list of modules and submodules that we know give RTD trouble. +# Make sure you've tried including the relevant package in +# docs/share/requirements.txt before adding to this list. +MOCK_MODULES = [ + 'numpy', + 'matplotlib', + 'matplotlib.pyplot', + 'scipy.interpolate', + 'scipy.constants', + 'scipy.optimize', + ] + +for mod_name in MOCK_MODULES: + sys.modules[mod_name] = Mock() + +# ----------------------------------------------------------------------------- # from http://djangosnippets.org/snippets/2533/ # autogenerate models definitions @@ -109,27 +148,7 @@ def strip_tags(html): s.feed(html) return s.get_data() -class Mock(object): - def __init__(self, *args, **kwargs): - pass - def __call__(self, *args, **kwargs): - return Mock() - - @classmethod - def __getattr__(cls, name): - if name in ('__file__', '__path__'): - return '/dev/null' - elif name[0] == name[0].upper(): - mockType = type(name, (), {}) - mockType.__module__ = __name__ - return mockType - else: - return Mock() - -MOCK_MODULES = ['scipy', 'numpy'] -for mod_name in MOCK_MODULES: - sys.modules[mod_name] = Mock() def process_docstring(app, what, name, obj, options, lines): """Autodoc django models""" From a19c1a3c202c6fcc006ea0b6c25df90c14b330fb Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Wed, 7 Aug 2013 15:03:23 -0400 Subject: [PATCH 039/395] Make the virtualenvs on jenkins use site-packages for numpy, scipy, etc. --- jenkins/test.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jenkins/test.sh b/jenkins/test.sh index 60dd59f7c0..0c12602bed 100755 --- a/jenkins/test.sh +++ b/jenkins/test.sh @@ -55,7 +55,7 @@ VIRTUALENV_DIR="/mnt/virtualenvs/${JOB_NAME}${WORKSPACE_SUFFIX}" if [ ! -d "$VIRTUALENV_DIR" ]; then mkdir -p "$VIRTUALENV_DIR" - virtualenv "$VIRTUALENV_DIR" + virtualenv --system-site-packages "$VIRTUALENV_DIR" fi export PIP_DOWNLOAD_CACHE=/mnt/pip-cache From 56dee21142d6a453370983cc9373e9cb93b99523 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 7 Aug 2013 16:35:14 -0400 Subject: [PATCH 040/395] partial cleanup for submitted rubric state, along with sass format cleanup for rubric.scss --- .../css/combinedopenended/display.scss | 10 ++- .../js/src/combinedopenended/display.coffee | 6 +- lms/static/sass/course/_rubric.scss | 76 ++++++++++++------- .../combined_open_ended.html | 6 +- .../combined_open_ended_results.html | 10 ++- .../openended/open_ended_combined_rubric.html | 19 ++--- .../openended/open_ended_rubric.html | 2 +- 7 files changed, 80 insertions(+), 49 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 2873e37e73..b63e04d20e 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -222,7 +222,7 @@ section.combined-open-ended-status { .wrappable { display: table-cell; - padding: $baseline/4 0 ; + padding: $baseline/4; } .rubric-list-item { @@ -238,7 +238,9 @@ span.rubric-category { display: block; width: 100%; border-bottom: 1px solid lightgray; - font-size: .9em; + font-size: 1.1em; + padding-top: $baseline/2; + margin-bottom: $baseline/2; } div.combined-rubric-container { @@ -591,7 +593,8 @@ section.open-ended-child { overflow-y: auto; margin-bottom: 0; padding: $baseline/2; - height: 200px; + height: auto; + min-height: 20px; border: 1px solid #ddd; background: #f6f6f6; } @@ -668,6 +671,7 @@ section.open-ended-child { &.rubric-list-item { margin-bottom: 0; padding: 0; + border-radius: $baseline/4; } } } diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index b539c72554..4ae1f9156a 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -623,9 +623,9 @@ class @CombinedOpenEnded info_rubric_elements = @$(@info_rubric_elements_sel) info_rubric_elements.slideToggle() @rubric_header = @$(@rubric_collapse_sel) - if @rubric_header.text() == "(Hide)" - new_text = "(Show)" + if @rubric_header.text() == "Show Score Only" + new_text = "Show Full Rubric" else - new_text = "(Hide)" + new_text = "Show Score Only" @rubric_header.text(new_text) return false diff --git a/lms/static/sass/course/_rubric.scss b/lms/static/sass/course/_rubric.scss index 294ac86d78..6c1467bfb8 100644 --- a/lms/static/sass/course/_rubric.scss +++ b/lms/static/sass/course/_rubric.scss @@ -1,56 +1,78 @@ +.rubric-header { + .rubric-collapse { + float: right; + } +} + .rubric { - margin: 0px 0px; + margin: 0; color: #3C3C3C; + tr { - margin:0px 0px; - height: 100%; + margin: 0; + height: 100%; } + td { - height: 100%; - border: 1px black solid; - text-align: center; + height: 100%; + border: 1px black solid; + text-align: center; } + th { - padding: 5px; - margin: 5px; - text-align: center; + margin: $baseline/4; + padding: $baseline/4; + text-align: center; } + .points-header th { - padding: 0px; + padding: 0px; } - .rubric-label - { - position: relative; - font-size: .9em; - display: block; + + .rubric-label { + position: relative; + display: block; + font-size: .9em; + + .choicegroup-correct { + //nothing + } + + .choicegroup-incorrect { + display:none; + } } + .grade { position: absolute; - bottom:0px; - right:0px; + bottom: 0; + right: 0; } .selected-grade, .selected-grade .rubric-label { background: #666; color: white; } - input[type=radio]:checked + .rubric-label { + + input[type=radio]:checked + .rubric-label { background: white; color: $base-font-color; white-space:nowrap; - } + } + .wrappable { - white-space:normal; + white-space:normal; } + input[class='score-selection'] { - position: relative; - font-size: 16px; + position: relative; + font-size: 16px; } - ul.rubric-list - { - list-style-type: none; - padding:0; - margin:0; + + ul.rubric-list { + margin: 0; + padding: 0; + list-style-type: none; } } diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 7668312e0e..ef73a09bcc 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -30,11 +30,7 @@
    -<<<<<<< HEAD - Prompt -======= - Hide Prompt ->>>>>>> 932bbd259247f5b69f2d39c0a1945951816fedeb + Hide Prompt
    % for item in items: diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 84f186fbb6..125bab2444 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -6,7 +6,15 @@ % else: status="hidden"> % endif -

    ${result['task_name']} from grader ${i+1} (Hide)

    +
    +
    +
    + Submitted Rubric +
    +
    + ${result['task_name']} from grader ${i+1} + +
    ${result['result'] | n}
    %endif diff --git a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html index 9ae148442f..ab9e751672 100644 --- a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html @@ -8,17 +8,18 @@
  • %if len(category['options'][j]['grader_types'])>0: - %if correct[i]==1: -
  • diff --git a/lms/templates/combinedopenended/openended/open_ended_rubric.html b/lms/templates/combinedopenended/openended/open_ended_rubric.html index c015a32d2d..df0704a263 100644 --- a/lms/templates/combinedopenended/openended/open_ended_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_rubric.html @@ -8,7 +8,7 @@
    % for i in range(len(categories)): <% category = categories[i] %> - ${category['description']}
    + ${category['description']}
      % for j in range(len(category['options'])): <% option = category['options'][j] %> From 1be6ce3ee387ead051b001112c0ef68a7a172b03 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 08:34:08 -0400 Subject: [PATCH 041/395] Add in and route control options --- .../xmodule/combined_open_ended_module.py | 33 +++++++++++++++++-- .../combined_open_ended_modulev1.py | 11 +++++++ .../open_ended_module.py | 1 + .../openendedchild.py | 1 + 4 files changed, 44 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index e01ae49149..f8ae7a3f13 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -14,7 +14,8 @@ import textwrap log = logging.getLogger("mitx.courseware") V1_SETTINGS_ATTRIBUTES = ["display_name", "max_attempts", "graded", "accept_file_upload", - "skip_spelling_checks", "due", "graceperiod", "weight"] + "skip_spelling_checks", "due", "graceperiod", "weight", "min_to_calibrate", + "max_to_calibrate", "peer_grader_count", "required_peer_grading"] V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state", "student_attempts", "ready_to_reset"] @@ -37,7 +38,7 @@ DEFAULT_DATA = textwrap.dedent("""\

      - Write a persuasive essay to a newspaper reflecting your vies on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading. + Write a persuasive essay to a newspaper reflecting your views on censorship in libraries. Do you believe that certain materials, such as books, music, movies, magazines, etc., should be removed from the shelves if they are found offensive? Support your position with convincing arguments from your own experience, observations, and/or reading.

      @@ -244,6 +245,34 @@ class CombinedOpenEndedFields(object): values={"min" : 0 , "step": ".1"}, default=1 ) + min_to_calibrate = Integer( + display_name="Minimum Peer Grading Calibrations", + help="The minimum number of calibration essays each student will need to complete for peer grading.", + default=1, + scope=Scope.settings, + values={"min" : 1, "step" : "1"} + ) + max_to_calibrate = Integer( + display_name="Maximum Peer Grading Calibrations", + help="The maximum number of calibration essays each student will need to complete for peer grading.", + default=1, + scope=Scope.settings, + values={"max" : 20, "step" : "1"} + ) + peer_grader_count = Integer( + display_name="Peer Graders per Response", + help="The number of peers who will grade each submission.", + default=1, + scope=Scope.settings, + values={"min" : 1, "step" : "1", "max" : 5} + ) + required_peer_grading = Integer( + display_name="Required Peer Grading", + help="The number of other students each student making a submission will have to grade.", + default=1, + scope=Scope.settings, + values={"min" : 1, "step" : "1", "max" : 5} + ) markdown = String( help="Markdown source of this module", default=textwrap.dedent("""\ diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 933eb0b5bb..c65d30968d 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -106,6 +106,11 @@ class CombinedOpenEndedV1Module(): self.accept_file_upload = instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT self.skip_basic_checks = instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT + self.required_peer_grading = instance_state.get('required_peer_grading', 3) + self.peer_grader_count = instance_state.get('peer_grader_count', 3) + self.min_to_calibrate = instance_state.get('min_to_calibrate', 3) + self.max_to_calibrate = instance_state.get('max_to_calibrate', 6) + due_date = instance_state.get('due', None) grace_period_string = instance_state.get('graceperiod', None) @@ -131,6 +136,12 @@ class CombinedOpenEndedV1Module(): 'close_date': self.timeinfo.close_date, 's3_interface': self.system.s3_interface, 'skip_basic_checks': self.skip_basic_checks, + 'control': { + 'required_peer_grading': self.required_peer_grading, + 'peer_grader_count': self.peer_grader_count, + 'min_to_calibrate': self.min_to_calibrate, + 'max_to_calibrate': self.max_to_calibrate, + } } self.task_xml = definition['task_xml'] diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index 2e7a3eaf89..924ca2c23d 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -118,6 +118,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): 'answer': self.answer, 'problem_id': self.display_name, 'skip_basic_checks': self.skip_basic_checks, + 'control': json.dumps(self.control), }) updated_grader_payload = json.dumps(parsed_grader_payload) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 10f939b270..7138dcc723 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -92,6 +92,7 @@ class OpenEndedChild(object): self.s3_interface = static_data['s3_interface'] self.skip_basic_checks = static_data['skip_basic_checks'] self._max_score = static_data['max_score'] + self.control = static_data['control'] # Used for progress / grading. Currently get credit just for # completion (doesn't matter if you self-assessed correct/incorrect). From 4896444d10ec650f1c44e4c9a4c01178b61114c7 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Fri, 2 Aug 2013 09:29:55 -0400 Subject: [PATCH 042/395] Clean up item views, use JsonResponse class --- cms/djangoapps/contentstore/views/item.py | 19 +++++++++++-------- 1 file changed, 11 insertions(+), 8 deletions(-) diff --git a/cms/djangoapps/contentstore/views/item.py b/cms/djangoapps/contentstore/views/item.py index efebded9b9..ff347a2878 100644 --- a/cms/djangoapps/contentstore/views/item.py +++ b/cms/djangoapps/contentstore/views/item.py @@ -1,15 +1,13 @@ -import json from uuid import uuid4 from django.core.exceptions import PermissionDenied -from django.http import HttpResponse from django.contrib.auth.decorators import login_required from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.inheritance import own_metadata -from util.json_request import expect_json +from util.json_request import expect_json, JsonResponse from ..utils import get_modulestore from .access import has_access from .requests import _xmodule_recurse @@ -20,6 +18,7 @@ __all__ = ['save_item', 'create_item', 'delete_item'] # cdodge: these are categories which should not be parented, they are detached from the hierarchy DETACHED_CATEGORIES = ['about', 'static_tab', 'course_info'] + @login_required @expect_json def save_item(request): @@ -80,7 +79,7 @@ def save_item(request): # commit to datastore store.update_metadata(item_location, own_metadata(existing_item)) - return HttpResponse() + return JsonResponse() # [DHM] A hack until we implement a permanent soln. Proposed perm solution is to make namespace fields also top level @@ -139,13 +138,17 @@ def create_item(request): if display_name is not None: metadata['display_name'] = display_name - get_modulestore(category).create_and_save_xmodule(dest_location, definition_data=data, - metadata=metadata, system=parent.system) + get_modulestore(category).create_and_save_xmodule( + dest_location, + definition_data=data, + metadata=metadata, + system=parent.system, + ) if category not in DETACHED_CATEGORIES: get_modulestore(parent.location).update_children(parent_location, parent.children + [dest_location.url()]) - return HttpResponse(json.dumps({'id': dest_location.url()})) + return JsonResponse({'id': dest_location.url()}) @login_required @@ -184,4 +187,4 @@ def delete_item(request): parent.children = children modulestore('direct').update_children(parent.location, parent.children) - return HttpResponse() + return JsonResponse() From be103dfa0d3e758cec8c25eb08181d0570e315a9 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 31 Jul 2013 12:50:22 -0400 Subject: [PATCH 043/395] improving code style --- common/lib/xmodule/xmodule/x_module.py | 70 +++++++++++--------------- 1 file changed, 29 insertions(+), 41 deletions(-) diff --git a/common/lib/xmodule/xmodule/x_module.py b/common/lib/xmodule/xmodule/x_module.py index 3556f3f0f3..310a871b72 100644 --- a/common/lib/xmodule/xmodule/x_module.py +++ b/common/lib/xmodule/xmodule/x_module.py @@ -711,20 +711,20 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): # =============================== BUILTIN METHODS ========================== def __eq__(self, other): - eq = (self.__class__ == other.__class__ and + return (self.__class__ == other.__class__ and all(getattr(self, attr, None) == getattr(other, attr, None) for attr in self.equality_attributes)) - return eq - def __repr__(self): - return ("{class_}({system!r}, location={location!r}," - " model_data={model_data!r})".format( - class_=self.__class__.__name__, - system=self.system, - location=self.location, - model_data=self._model_data, - )) + return ( + "{class_}({system!r}, location={location!r}," + " model_data={model_data!r})".format( + class_=self.__class__.__name__, + system=self.system, + location=self.location, + model_data=self._model_data, + ) + ) @property def non_editable_metadata_fields(self): @@ -785,15 +785,17 @@ class XModuleDescriptor(XModuleFields, HTMLSnippet, ResourceTemplates, XBlock): editor_type = "Float" elif isinstance(field, List): editor_type = "List" - metadata_fields[field.name] = {'field_name': field.name, - 'type': editor_type, - 'display_name': field.display_name, - 'value': field.to_json(value), - 'options': [] if values is None else values, - 'default_value': field.to_json(default_value), - 'inheritable': inheritable, - 'explicitly_set': explicitly_set, - 'help': field.help} + metadata_fields[field.name] = { + 'field_name': field.name, + 'type': editor_type, + 'display_name': field.display_name, + 'value': field.to_json(value), + 'options': [] if values is None else values, + 'default_value': field.to_json(default_value), + 'inheritable': inheritable, + 'explicitly_set': explicitly_set, + 'help': field.help, + } return metadata_fields @@ -885,28 +887,14 @@ class ModuleSystem(Runtime): Note that these functions can be closures over e.g. a django request and user, or other environment-specific info. ''' - def __init__(self, - ajax_url, - track_function, - get_module, - render_template, - replace_urls, - xblock_model_data, - user=None, - filestore=None, - debug=False, - xqueue=None, - publish=None, - node_path="", - anonymous_student_id='', - course_id=None, - open_ended_grading_interface=None, - s3_interface=None, - cache=None, - can_execute_unsafe_code=None, - replace_course_urls=None, - replace_jump_to_id_urls=None - ): + def __init__( + self, ajax_url, track_function, get_module, render_template, + replace_urls, xblock_model_data, user=None, filestore=None, + debug=False, xqueue=None, publish=None, node_path="", + anonymous_student_id='', course_id=None, + open_ended_grading_interface=None, s3_interface=None, + cache=None, can_execute_unsafe_code=None, replace_course_urls=None, + replace_jump_to_id_urls=None): ''' Create a closure around the system environment. From 9634e222bed244d3310f449faa137a029493977b Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 31 Jul 2013 12:51:01 -0400 Subject: [PATCH 044/395] Refactored get_module_previews function --- cms/djangoapps/contentstore/views/preview.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index f2a07abe32..a9c9757d1d 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -163,6 +163,11 @@ def load_preview_module(request, preview_id, descriptor): return module +def get_preview_html(request, descriptor, idx): + module = load_preview_module(request, str(idx), descriptor) + return module.get_html() + + def get_module_previews(request, descriptor): """ Returns a list of preview XModule html contents. One preview is returned for each @@ -170,8 +175,5 @@ def get_module_previews(request, descriptor): descriptor: An XModuleDescriptor """ - preview_html = [] - for idx, (_instance_state, _shared_state) in enumerate(descriptor.get_sample_state()): - module = load_preview_module(request, str(idx), descriptor) - preview_html.append(module.get_html()) - return preview_html + return tuple(get_preview_html(request, descriptor, idx) + for idx in range(len(descriptor.get_sample_state()))) From 8a95d7e6f0c72fe9836f01a209e6eb1d4ca4bab4 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Fri, 2 Aug 2013 13:51:46 -0400 Subject: [PATCH 045/395] XBlock integration: replaced `get_html` with `runtime.render()` Currently calls the same machinery, but re-routes the logic in preparation of deeper integration with XBlock --- cms/djangoapps/contentstore/views/preview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index a9c9757d1d..fa55cb2c24 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -165,7 +165,7 @@ def load_preview_module(request, preview_id, descriptor): def get_preview_html(request, descriptor, idx): module = load_preview_module(request, str(idx), descriptor) - return module.get_html() + return module.runtime.render(module, None, "student_view") def get_module_previews(request, descriptor): From 3fa990ea60dc3c704c82eea3539d1a71a5eafbd3 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 5 Aug 2013 09:38:51 -0400 Subject: [PATCH 046/395] Made some tests more general, less fragile --- .../contentstore/tests/test_contentstore.py | 30 +++++++++---------- .../contentstore/tests/test_item.py | 2 +- 2 files changed, 16 insertions(+), 16 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 838af2cafa..64fa53433e 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1237,7 +1237,7 @@ class ContentStoreTest(ModuleStoreTestCase): 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) self.assertContains(resp, 'Chapter 2') # go to various pages @@ -1247,92 +1247,92 @@ class ContentStoreTest(ModuleStoreTestCase): kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # export page resp = self.client.get(reverse('export_course', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # manage users resp = self.client.get(reverse('manage_users', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # course info resp = self.client.get(reverse('course_info', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # settings_details resp = self.client.get(reverse('settings_details', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # settings_details resp = self.client.get(reverse('settings_grading', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # static_pages resp = self.client.get(reverse('static_pages', kwargs={'org': loc.org, 'course': loc.course, 'coursename': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # static_pages resp = self.client.get(reverse('asset_index', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # go look at a subsection page subsection_location = loc.replace(category='sequential', name='test_sequence') resp = self.client.get(reverse('edit_subsection', kwargs={'location': subsection_location.url()})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # go look at the Edit page unit_location = loc.replace(category='vertical', name='test_vertical') resp = self.client.get(reverse('edit_unit', kwargs={'location': unit_location.url()})) - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # delete a component del_loc = loc.replace(category='html', name='test_html') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # delete a unit del_loc = loc.replace(category='vertical', name='test_vertical') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # delete a unit del_loc = loc.replace(category='sequential', name='test_sequence') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) # delete a chapter del_loc = loc.replace(category='chapter', name='chapter_2') resp = self.client.post(reverse('delete_item'), json.dumps({'id': del_loc.url()}), "application/json") - self.assertEqual(200, resp.status_code) + self.assert2XX(resp.status_code) def test_import_metadata_with_attempts_empty_string(self): module_store = modulestore('direct') diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 827dd1b054..260444a8f7 100644 --- a/cms/djangoapps/contentstore/tests/test_item.py +++ b/cms/djangoapps/contentstore/tests/test_item.py @@ -34,7 +34,7 @@ class DeleteItem(CourseTestCase): resp.content, "application/json" ) - self.assertEqual(resp.status_code, 200) + self.assert2XX(resp.status_code) class TestCreateItem(CourseTestCase): From baa9bd5bdca69c358f2f4e81e4632febe2f6019f Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Mon, 5 Aug 2013 11:04:23 -0400 Subject: [PATCH 047/395] Make sure to return the content, not the fragment --- cms/djangoapps/contentstore/views/preview.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index fa55cb2c24..202927bdfb 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -165,7 +165,7 @@ def load_preview_module(request, preview_id, descriptor): def get_preview_html(request, descriptor, idx): module = load_preview_module(request, str(idx), descriptor) - return module.runtime.render(module, None, "student_view") + return module.runtime.render(module, None, "student_view").content def get_module_previews(request, descriptor): From a87a1bfcdab0fa42d169f630e9eab85137e50e29 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Wed, 7 Aug 2013 16:14:07 -0400 Subject: [PATCH 048/395] Docstrings --- cms/djangoapps/contentstore/views/preview.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 202927bdfb..1fef30dd99 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -164,13 +164,16 @@ def load_preview_module(request, preview_id, descriptor): def get_preview_html(request, descriptor, idx): + """ + Returns the HTML for the XModule specified by the descriptor and idx. + """ module = load_preview_module(request, str(idx), descriptor) return module.runtime.render(module, None, "student_view").content def get_module_previews(request, descriptor): """ - Returns a list of preview XModule html contents. One preview is returned for each + Returns a tuple of preview XModule html contents. One preview is returned for each pair of states returned by get_sample_state() for the supplied descriptor. descriptor: An XModuleDescriptor From 4b5aba29ca3fc0b24ea4195bf5cf5f50065ff7db Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 09:52:13 -0400 Subject: [PATCH 049/395] Fix defaults --- common/lib/xmodule/xmodule/combined_open_ended_module.py | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index f8ae7a3f13..faf22d1926 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -248,28 +248,28 @@ class CombinedOpenEndedFields(object): min_to_calibrate = Integer( display_name="Minimum Peer Grading Calibrations", help="The minimum number of calibration essays each student will need to complete for peer grading.", - default=1, + default=3, scope=Scope.settings, values={"min" : 1, "step" : "1"} ) max_to_calibrate = Integer( display_name="Maximum Peer Grading Calibrations", help="The maximum number of calibration essays each student will need to complete for peer grading.", - default=1, + default=6, scope=Scope.settings, values={"max" : 20, "step" : "1"} ) peer_grader_count = Integer( display_name="Peer Graders per Response", help="The number of peers who will grade each submission.", - default=1, + default=3, scope=Scope.settings, values={"min" : 1, "step" : "1", "max" : 5} ) required_peer_grading = Integer( display_name="Required Peer Grading", help="The number of other students each student making a submission will have to grade.", - default=1, + default=3, scope=Scope.settings, values={"min" : 1, "step" : "1", "max" : 5} ) From 7aec95c3100132149e9f84f6d8e58927f78655e7 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 8 Aug 2013 09:52:39 -0400 Subject: [PATCH 050/395] Removed get_module_previews function According to @cpennington, no modules return anything for `get_sample_state`, so this function is extraneous. --- cms/djangoapps/contentstore/views/preview.py | 13 +------------ 1 file changed, 1 insertion(+), 12 deletions(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index 1fef30dd99..a325dd3b34 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -76,7 +76,7 @@ def preview_component(request, location): component = modulestore().get_item(location) return render_to_response('component.html', { - 'preview': get_module_previews(request, component)[0], + 'preview': get_preview_html(request, component, 0), 'editor': wrap_xmodule(component.get_html, component, 'xmodule_edit.html')(), }) @@ -169,14 +169,3 @@ def get_preview_html(request, descriptor, idx): """ module = load_preview_module(request, str(idx), descriptor) return module.runtime.render(module, None, "student_view").content - - -def get_module_previews(request, descriptor): - """ - Returns a tuple of preview XModule html contents. One preview is returned for each - pair of states returned by get_sample_state() for the supplied descriptor. - - descriptor: An XModuleDescriptor - """ - return tuple(get_preview_html(request, descriptor, idx) - for idx in range(len(descriptor.get_sample_state()))) From 32f76988c6bb3128da5b3c804bc305f8bc7281d0 Mon Sep 17 00:00:00 2001 From: David Baumgold Date: Thu, 8 Aug 2013 09:53:19 -0400 Subject: [PATCH 051/395] Update docstring --- cms/djangoapps/contentstore/views/preview.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/preview.py b/cms/djangoapps/contentstore/views/preview.py index a325dd3b34..121bf98393 100644 --- a/cms/djangoapps/contentstore/views/preview.py +++ b/cms/djangoapps/contentstore/views/preview.py @@ -165,7 +165,8 @@ def load_preview_module(request, preview_id, descriptor): def get_preview_html(request, descriptor, idx): """ - Returns the HTML for the XModule specified by the descriptor and idx. + Returns the HTML returned by the XModule's student_view, + specified by the descriptor and idx. """ module = load_preview_module(request, str(idx), descriptor) return module.runtime.render(module, None, "student_view").content From aaceb288a70de89f118e545c5220eaae9c809b04 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Wed, 31 Jul 2013 15:59:35 -0400 Subject: [PATCH 052/395] fix mysql indexing validity in migrations --- .../student/migrations/0023_add_test_center_registration.py | 4 ++-- .../student/migrations/0024_add_allow_certificate.py | 2 +- ...025_auto__add_field_courseenrollmentallowed_auto_enroll.py | 2 +- common/djangoapps/student/models.py | 2 +- 4 files changed, 5 insertions(+), 5 deletions(-) diff --git a/common/djangoapps/student/migrations/0023_add_test_center_registration.py b/common/djangoapps/student/migrations/0023_add_test_center_registration.py index 4c7de6dcd9..6186f5deef 100644 --- a/common/djangoapps/student/migrations/0023_add_test_center_registration.py +++ b/common/djangoapps/student/migrations/0023_add_test_center_registration.py @@ -21,7 +21,7 @@ class Migration(SchemaMigration): ('eligibility_appointment_date_first', self.gf('django.db.models.fields.DateField')(db_index=True)), ('eligibility_appointment_date_last', self.gf('django.db.models.fields.DateField')(db_index=True)), ('accommodation_code', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), - ('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=1024, blank=True)), + ('accommodation_request', self.gf('django.db.models.fields.CharField')(db_index=False, max_length=1024, blank=True)), ('uploaded_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), ('processed_at', self.gf('django.db.models.fields.DateTimeField')(null=True, db_index=True)), ('upload_status', self.gf('django.db.models.fields.CharField')(db_index=True, max_length=20, blank=True)), @@ -163,7 +163,7 @@ class Migration(SchemaMigration): 'student.testcenterregistration': { 'Meta': {'object_name': 'TestCenterRegistration'}, 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), diff --git a/common/djangoapps/student/migrations/0024_add_allow_certificate.py b/common/djangoapps/student/migrations/0024_add_allow_certificate.py index 56eccf8d70..5753f0176e 100644 --- a/common/djangoapps/student/migrations/0024_add_allow_certificate.py +++ b/common/djangoapps/student/migrations/0024_add_allow_certificate.py @@ -93,7 +93,7 @@ class Migration(SchemaMigration): 'student.testcenterregistration': { 'Meta': {'object_name': 'TestCenterRegistration'}, 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), diff --git a/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py index 8ce1d0cda1..1cb21e9b33 100644 --- a/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py +++ b/common/djangoapps/student/migrations/0025_auto__add_field_courseenrollmentallowed_auto_enroll.py @@ -94,7 +94,7 @@ class Migration(SchemaMigration): 'student.testcenterregistration': { 'Meta': {'object_name': 'TestCenterRegistration'}, 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), - 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '1024', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'db_index': 'False', 'max_length': '1024', 'blank': 'True'}), 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 4c41427ca6..34278c5581 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -370,7 +370,7 @@ class TestCenterRegistration(models.Model): accommodation_code = models.CharField(max_length=64, blank=True) # store the original text of the accommodation request. - accommodation_request = models.CharField(max_length=1024, blank=True, db_index=True) + accommodation_request = models.CharField(max_length=1024, blank=True, db_index=False) # time at which edX sent the registration to the test center uploaded_at = models.DateTimeField(null=True, db_index=True) From 090f8d812f75690f31c031d36d7af99262801255 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Wed, 7 Aug 2013 15:24:09 -0400 Subject: [PATCH 053/395] add migration to remove index --- ...enterregistration_accommodation_request.py | 180 ++++++++++++++++++ 1 file changed, 180 insertions(+) create mode 100644 common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py diff --git a/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py b/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py new file mode 100644 index 0000000000..23fc476348 --- /dev/null +++ b/common/djangoapps/student/migrations/0026_auto__remove_index_student_testcenterregistration_accommodation_request.py @@ -0,0 +1,180 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models +from django.db.utils import DatabaseError + + +class Migration(SchemaMigration): + """ + Remove an unwanted index from environments that have it. + This is a one-way migration in that backwards is a no-op and will not undo the removal. + This migration is only relevant to dev environments that existed before a migration rewrite + which removed the creation of this index. + """ + + def forwards(self, orm): + try: + # Removing index on 'TestCenterRegistration', fields ['accommodation_request'] + db.delete_index('student_testcenterregistration', ['accommodation_request']) + except DatabaseError: + print "-- skipping delete_index of student_testcenterregistration.accommodation_request (index does not exist)" + + + def backwards(self, orm): + pass + + + models = { + 'auth.group': { + 'Meta': {'object_name': 'Group'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '80'}), + 'permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}) + }, + 'auth.permission': { + 'Meta': {'ordering': "('content_type__app_label', 'content_type__model', 'codename')", 'unique_together': "(('content_type', 'codename'),)", 'object_name': 'Permission'}, + 'codename': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'content_type': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['contenttypes.ContentType']"}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '50'}) + }, + 'auth.user': { + 'Meta': {'object_name': 'User'}, + 'date_joined': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'email': ('django.db.models.fields.EmailField', [], {'max_length': '75', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'groups': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Group']", 'symmetrical': 'False', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'is_staff': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'is_superuser': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'last_login': ('django.db.models.fields.DateTimeField', [], {'default': 'datetime.datetime.now'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'password': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'user_permissions': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.Permission']", 'symmetrical': 'False', 'blank': 'True'}), + 'username': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '30'}) + }, + 'contenttypes.contenttype': { + 'Meta': {'ordering': "('name',)", 'unique_together': "(('app_label', 'model'),)", 'object_name': 'ContentType', 'db_table': "'django_content_type'"}, + 'app_label': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) + }, + 'student.courseenrollment': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'CourseEnrollment'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'student.courseenrollmentallowed': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'CourseEnrollmentAllowed'}, + 'auto_enroll': ('django.db.models.fields.BooleanField', [], {'default': 'False'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'null': 'True', 'db_index': 'True', 'blank': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + 'student.pendingemailchange': { + 'Meta': {'object_name': 'PendingEmailChange'}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_email': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.pendingnamechange': { + 'Meta': {'object_name': 'PendingNameChange'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'new_name': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'rationale': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.registration': { + 'Meta': {'object_name': 'Registration', 'db_table': "'auth_registration'"}, + 'activation_key': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '32', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'unique': 'True'}) + }, + 'student.testcenterregistration': { + 'Meta': {'object_name': 'TestCenterRegistration'}, + 'accommodation_code': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'accommodation_request': ('django.db.models.fields.CharField', [], {'max_length': '1024', 'blank': 'True'}), + 'authorization_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'client_authorization_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '20', 'db_index': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'eligibility_appointment_date_first': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'eligibility_appointment_date_last': ('django.db.models.fields.DateField', [], {'db_index': 'True'}), + 'exam_series_code': ('django.db.models.fields.CharField', [], {'max_length': '15', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'testcenter_user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['student.TestCenterUser']"}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.testcenteruser': { + 'Meta': {'object_name': 'TestCenterUser'}, + 'address_1': ('django.db.models.fields.CharField', [], {'max_length': '40'}), + 'address_2': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'address_3': ('django.db.models.fields.CharField', [], {'max_length': '40', 'blank': 'True'}), + 'candidate_id': ('django.db.models.fields.IntegerField', [], {'null': 'True', 'db_index': 'True'}), + 'city': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'client_candidate_id': ('django.db.models.fields.CharField', [], {'unique': 'True', 'max_length': '50', 'db_index': 'True'}), + 'company_name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '50', 'blank': 'True'}), + 'confirmed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'country': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'created_at': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'db_index': 'True', 'blank': 'True'}), + 'extension': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '8', 'blank': 'True'}), + 'fax': ('django.db.models.fields.CharField', [], {'max_length': '35', 'blank': 'True'}), + 'fax_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'blank': 'True'}), + 'first_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'last_name': ('django.db.models.fields.CharField', [], {'max_length': '50', 'db_index': 'True'}), + 'middle_name': ('django.db.models.fields.CharField', [], {'max_length': '30', 'blank': 'True'}), + 'phone': ('django.db.models.fields.CharField', [], {'max_length': '35'}), + 'phone_country_code': ('django.db.models.fields.CharField', [], {'max_length': '3', 'db_index': 'True'}), + 'postal_code': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '16', 'blank': 'True'}), + 'processed_at': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'db_index': 'True'}), + 'salutation': ('django.db.models.fields.CharField', [], {'max_length': '50', 'blank': 'True'}), + 'state': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'suffix': ('django.db.models.fields.CharField', [], {'max_length': '255', 'blank': 'True'}), + 'updated_at': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'db_index': 'True', 'blank': 'True'}), + 'upload_error_message': ('django.db.models.fields.CharField', [], {'max_length': '512', 'blank': 'True'}), + 'upload_status': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '20', 'blank': 'True'}), + 'uploaded_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'default': 'None', 'to': "orm['auth.User']", 'unique': 'True'}), + 'user_updated_at': ('django.db.models.fields.DateTimeField', [], {'db_index': 'True'}) + }, + 'student.userprofile': { + 'Meta': {'object_name': 'UserProfile', 'db_table': "'auth_userprofile'"}, + 'allow_certificate': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'courseware': ('django.db.models.fields.CharField', [], {'default': "'course.xml'", 'max_length': '255', 'blank': 'True'}), + 'gender': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'goals': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'language': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'level_of_education': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '6', 'null': 'True', 'blank': 'True'}), + 'location': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'mailing_address': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'meta': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'db_index': 'True', 'max_length': '255', 'blank': 'True'}), + 'user': ('django.db.models.fields.related.OneToOneField', [], {'related_name': "'profile'", 'unique': 'True', 'to': "orm['auth.User']"}), + 'year_of_birth': ('django.db.models.fields.IntegerField', [], {'db_index': 'True', 'null': 'True', 'blank': 'True'}) + }, + 'student.usertestgroup': { + 'Meta': {'object_name': 'UserTestGroup'}, + 'description': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'name': ('django.db.models.fields.CharField', [], {'max_length': '32', 'db_index': 'True'}), + 'users': ('django.db.models.fields.related.ManyToManyField', [], {'to': "orm['auth.User']", 'db_index': 'True', 'symmetrical': 'False'}) + } + } + + complete_apps = ['student'] From 203b1176cdee75f4e1096db6d906d36cb082ce01 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 12:14:57 -0400 Subject: [PATCH 054/395] Don't check for ETA, or check peer grading data in self assessment --- .../open_ended_grading_classes/open_ended_module.py | 3 +-- .../self_assessment_module.py | 11 +++-------- 2 files changed, 4 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index 2e7a3eaf89..bd873cb5ae 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -649,7 +649,6 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # add new history element with answer and empty score and hint. success, data = self.append_image_to_student_answer(data) - error_message = "" if success: success, allowed_to_submit, error_message = self.check_if_student_can_submit() if allowed_to_submit: @@ -698,7 +697,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): score = self.latest_score() correct = 'correct' if self.is_submission_correct(score) else 'incorrect' if self.child_state == self.ASSESSING: - eta_string = self.get_eta() + eta_string = "" else: post_assessment = "" correct = "" diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py index 1262e1f68f..baba66eb23 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py @@ -184,14 +184,9 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): # add new history element with answer and empty score and hint. success, data = self.append_image_to_student_answer(data) if success: - success, allowed_to_submit, error_message = self.check_if_student_can_submit() - if allowed_to_submit: - data['student_answer'] = SelfAssessmentModule.sanitize_html(data['student_answer']) - self.new_history_entry(data['student_answer']) - self.change_state(self.ASSESSING) - else: - # Error message already defined - success = False + data['student_answer'] = SelfAssessmentModule.sanitize_html(data['student_answer']) + self.new_history_entry(data['student_answer']) + self.change_state(self.ASSESSING) else: # This is a student_facing_error error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box." From 45d94c17f7d024c4717a2e593b2979d96be91037 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 12:46:43 -0400 Subject: [PATCH 055/395] Add in next button, get rid of error with deleted peer grading module --- .../js/src/combinedopenended/display.coffee | 4 +- .../combined_open_ended_modulev1.py | 40 +++++++++++++++++++ .../open_ended_module.py | 13 ++---- .../openendedchild.py | 32 --------------- .../xmodule/xmodule/peer_grading_module.py | 9 ++++- 5 files changed, 53 insertions(+), 45 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 68c1f17ef6..b67b6dca93 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -297,8 +297,8 @@ class @CombinedOpenEnded @submit_button.unbind('click') @submit_button.show() @reset_button.hide() - @next_problem_button.hide() @hide_file_upload() + @next_problem_button.hide() @hint_area.attr('disabled', false) if @task_number>1 or @child_state!='initial' @show_status_current() @@ -353,7 +353,7 @@ class @CombinedOpenEnded if @child_type=="openended" @skip_button.hide() if @task_number<@task_count - @next_problem() + @next_problem_button.show() else if @task_number==1 and @task_count==1 @show_combined_rubric_current() diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index ad9d7a287f..eee2e93312 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -9,6 +9,7 @@ import self_assessment_module import open_ended_module from functools import partial from .combined_open_ended_rubric import CombinedOpenEndedRubric, GRADER_TYPE_IMAGE_DICT, HUMAN_GRADER_TYPE, LEGEND_LIST +from peer_grading_service import PeerGradingService, MockPeerGradingService log = logging.getLogger("mitx.courseware") @@ -116,6 +117,11 @@ class CombinedOpenEndedV1Module(): self.accept_file_upload = instance_state.get('accept_file_upload', ACCEPT_FILE_UPLOAD) in TRUE_DICT self.skip_basic_checks = instance_state.get('skip_spelling_checks', SKIP_BASIC_CHECKS) in TRUE_DICT + if system.open_ended_grading_interface: + self.peer_gs = PeerGradingService(system.open_ended_grading_interface, system) + else: + self.peer_gs = MockPeerGradingService() + due_date = instance_state.get('due', None) grace_period_string = instance_state.get('graceperiod', None) @@ -147,6 +153,8 @@ class CombinedOpenEndedV1Module(): self.location = location self.setup_next_task() + + def get_tag_name(self, xml): """ Gets the tag name of a given xml block. @@ -494,6 +502,34 @@ class CombinedOpenEndedV1Module(): pass return return_html + def check_if_student_has_done_needed_grading(self): + student_id = self.system.anonymous_student_id + success = False + allowed_to_submit = True + error_string = ("You need to peer grade {0} more in order to make another submission. " + "You have graded {1}, and {2} are required. You have made {3} successful peer grading submissions.") + try: + response = self.peer_gs.get_data_for_location(self.location, student_id) + count_graded = response['count_graded'] + count_required = response['count_required'] + student_sub_count = response['student_sub_count'] + success = True + except: + # This is a dev_facing_error + log.error("Could not contact external open ended graders for location {0} and student {1}".format( + self.location, student_id)) + # This is a student_facing_error + error_message = "Could not contact the graders. Please notify course staff." + return success, allowed_to_submit, error_message + if count_graded >= count_required: + return success, allowed_to_submit, "" + else: + allowed_to_submit = False + # This is a student_facing_error + error_message = error_string.format(count_required - count_graded, count_graded, count_required, + student_sub_count) + return success, allowed_to_submit, error_message + def get_rubric(self, _data): """ Gets the results of a given grader via ajax. @@ -501,6 +537,7 @@ class CombinedOpenEndedV1Module(): Output: Dictionary to be rendered via ajax that contains the result html. """ all_responses = [] + loop_up_to_task = self.current_task_number + 1 contexts = [] for i in xrange(0, loop_up_to_task): @@ -546,6 +583,9 @@ class CombinedOpenEndedV1Module(): Output: Dictionary to be rendered via ajax that contains the result html. """ self.update_task_states() + success, can_see_rubric, error = self.check_if_student_has_done_needed_grading() + if not can_see_rubric: + return {'html' : error, 'success' : False} loop_up_to_task = self.current_task_number + 1 all_responses = [] for i in xrange(0, loop_up_to_task): diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index bd873cb5ae..ec3bebac7f 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -650,15 +650,10 @@ class OpenEndedModule(openendedchild.OpenEndedChild): # add new history element with answer and empty score and hint. success, data = self.append_image_to_student_answer(data) if success: - success, allowed_to_submit, error_message = self.check_if_student_can_submit() - if allowed_to_submit: - data['student_answer'] = OpenEndedModule.sanitize_html(data['student_answer']) - self.new_history_entry(data['student_answer']) - self.send_to_grader(data['student_answer'], system) - self.change_state(self.ASSESSING) - else: - # Error message already defined - success = False + data['student_answer'] = OpenEndedModule.sanitize_html(data['student_answer']) + self.new_history_entry(data['student_answer']) + self.send_to_grader(data['student_answer'], system) + self.change_state(self.ASSESSING) else: # This is a student_facing_error error_message = "There was a problem saving the image in your submission. Please try a different image, or try pasting a link to an image into the answer box." diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 555505d611..71b48aed54 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -433,38 +433,6 @@ class OpenEndedChild(object): return success, string - def check_if_student_can_submit(self): - location = self.location_string - - student_id = self.system.anonymous_student_id - success = False - allowed_to_submit = True - response = {} - # This is a student_facing_error - error_string = ("You need to peer grade {0} more in order to make another submission. " - "You have graded {1}, and {2} are required. You have made {3} successful peer grading submissions.") - try: - response = self.peer_gs.get_data_for_location(self.location_string, student_id) - count_graded = response['count_graded'] - count_required = response['count_required'] - student_sub_count = response['student_sub_count'] - success = True - except: - # This is a dev_facing_error - log.error("Could not contact external open ended graders for location {0} and student {1}".format( - self.location_string, student_id)) - # This is a student_facing_error - error_message = "Could not contact the graders. Please notify course staff." - return success, allowed_to_submit, error_message - if count_graded >= count_required: - return success, allowed_to_submit, "" - else: - allowed_to_submit = False - # This is a student_facing_error - error_message = error_string.format(count_required - count_graded, count_graded, count_required, - student_sub_count) - return success, allowed_to_submit, error_message - def get_eta(self): if self.controller_qs: response = self.controller_qs.check_for_eta(self.location_string) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index b21e4865ec..059b66b615 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -535,9 +535,13 @@ class PeerGradingModule(PeerGradingFields, XModule): log.error("Problem {0} does not exist in this course".format(location)) raise + good_problem_list = [] for problem in problem_list: problem_location = problem['location'] - descriptor = _find_corresponding_module_for_location(problem_location) + try: + descriptor = _find_corresponding_module_for_location(problem_location) + except: + continue if descriptor: problem['due'] = descriptor._model_data.get('due', None) grace_period_string = descriptor._model_data.get('graceperiod', None) @@ -554,13 +558,14 @@ class PeerGradingModule(PeerGradingFields, XModule): # if we can't find the due date, assume that it doesn't have one problem['due'] = None problem['closed'] = False + good_problem_list.append(problem) ajax_url = self.ajax_url html = self.system.render_template('peer_grading/peer_grading.html', { 'course_id': self.system.course_id, 'ajax_url': ajax_url, 'success': success, - 'problem_list': problem_list, + 'problem_list': good_problem_list, 'error_text': error_text, # Checked above 'staff_access': False, From bd9097596cac8085e3bc6cdb4e4956a68c7acd94 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 13:16:32 -0400 Subject: [PATCH 056/395] Remove annoying rubric parsing error, redo how notifications work --- .../xmodule/xmodule/js/src/combinedopenended/display.coffee | 4 ---- .../open_ended_grading_classes/self_assessment_module.py | 2 -- lms/djangoapps/open_ended_grading/open_ended_notifications.py | 3 +-- 3 files changed, 1 insertion(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index b67b6dca93..ee2c3589df 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -201,10 +201,6 @@ class @CombinedOpenEnded @rebind() - if @task_number>1 - @show_combined_rubric_current() - @show_results_current() - show_results_current: () => data = {'task_number' : @task_number-1} $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py index baba66eb23..674fab0d30 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py @@ -267,8 +267,6 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): try: rubric_scores = json.loads(latest_post_assessment) except: - # This is a dev_facing_error - log.error("Cannot parse rubric scores in self assessment module from {0}".format(latest_post_assessment)) rubric_scores = [] return [rubric_scores] diff --git a/lms/djangoapps/open_ended_grading/open_ended_notifications.py b/lms/djangoapps/open_ended_grading/open_ended_notifications.py index 1d6fa22929..b1617bbe53 100644 --- a/lms/djangoapps/open_ended_grading/open_ended_notifications.py +++ b/lms/djangoapps/open_ended_grading/open_ended_notifications.py @@ -93,7 +93,6 @@ def peer_grading_notifications(course, user): log.info( "Problem with getting notifications from peer grading service for course {0} user {1}.".format(course_id, student_id)) - if pending_grading: img_path = "/static/images/grading_notification.png" @@ -166,7 +165,7 @@ def combined_notifications(course, user): last_time_viewed) notifications = json.loads(controller_response) if notifications['success']: - if notifications['overall_need_to_check']: + if notifications['staff_needs_to_grade'] or notifications['student_needs_to_peer_grade']: pending_grading = True except: #Non catastrophic error, so no real action From fd38223244056975ad5eea80067061ea4661277b Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 13:31:43 -0400 Subject: [PATCH 057/395] Fix grading submitted messages --- .../xmodule/js/src/combinedopenended/display.coffee | 10 ++++------ .../open_ended_grading_classes/open_ended_module.py | 3 ++- .../combinedopenended/openended/open_ended.html | 10 +++++----- 3 files changed, 11 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index ee2c3589df..8c29af2c0b 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -326,9 +326,8 @@ class @CombinedOpenEnded if @child_type == "openended" @submit_button.hide() @queueing() - if @task_number==1 and @task_count==1 - @grader_status = @$(@grader_status_sel) - @grader_status.html("

      Response submitted for scoring.

      ") + @grader_status = @$(@grader_status_sel) + @grader_status.html("Your response has been submitted. Please check back later for your grade. ") else if @child_state == 'post_assessment' if @child_type=="openended" @skip_button.show() @@ -341,6 +340,8 @@ class @CombinedOpenEnded else @submit_button.click @message_post else if @child_state == 'done' + @show_combined_rubric_current() + @show_results_current() @rubric_wrapper.hide() @answer_area.attr("disabled", true) @replace_text_inputs() @@ -351,9 +352,6 @@ class @CombinedOpenEnded if @task_number<@task_count @next_problem_button.show() else - if @task_number==1 and @task_count==1 - @show_combined_rubric_current() - @show_results_current() @reset_button.show() diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index ec3bebac7f..e1e4643afe 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -640,6 +640,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): """ # Once we close the problem, we should not allow students # to save answers + error_message = "" closed, msg = self.check_if_closed() if closed: return msg @@ -692,7 +693,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): score = self.latest_score() correct = 'correct' if self.is_submission_correct(score) else 'incorrect' if self.child_state == self.ASSESSING: - eta_string = "" + eta_string = "Your response has been submitted. Please check back later for your grade." else: post_assessment = "" correct = "" diff --git a/lms/templates/combinedopenended/openended/open_ended.html b/lms/templates/combinedopenended/openended/open_ended.html index f2d59c4049..b76db8cecf 100644 --- a/lms/templates/combinedopenended/openended/open_ended.html +++ b/lms/templates/combinedopenended/openended/open_ended.html @@ -3,11 +3,11 @@
      ${prompt|n}
      -
      -
      +
      +
      +
      + Response
      - Response -
      @@ -15,7 +15,7 @@ % if state == 'initial': Unanswered % elif state == 'assessing': - Submitted for grading. + % if eta_message is not None: ${eta_message} % endif From 15b0e120686179276110c08a095e4d2f7bce0879 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 14:02:33 -0400 Subject: [PATCH 058/395] Remove progress indicators, make rubric display properly --- .../xmodule/xmodule/js/src/combinedopenended/display.coffee | 2 +- lms/templates/combinedopenended/combined_open_ended.html | 3 +-- .../openended/open_ended_combined_rubric.html | 6 +++++- 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 8c29af2c0b..49c056172a 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -112,7 +112,7 @@ class @CombinedOpenEnded sub_id_sel: 'input.submission_id' grader_id_sel: 'input.grader_id' grader_status_sel: '.grader-status' - info_rubric_elements_sel: '.rubric-elements-info' + info_rubric_elements_sel: '.rubric-info-item' rubric_collapse_sel: '.rubric-collapse' next_rubric_sel: '.rubric-next-button' previous_rubric_sel: '.rubric-previous-button' diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index ef73a09bcc..1a5c7e3c7d 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -2,7 +2,6 @@

      ${display_name}

      - Progress: ${human_state}
      @@ -30,7 +29,7 @@
      % for item in items: diff --git a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html index ab9e751672..158ec1f981 100644 --- a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html @@ -5,7 +5,11 @@
        % for j in range(len(category['options'])): <% option = category['options'][j] %> -
      • + %if len(category['options'][j]['grader_types'])>0: +
      • + %else: +
      • + %endif
        %if len(category['options'][j]['grader_types'])>0: %if correct[i]==1: From 69bbc3eeb0a6d5e9576275d495ad41e1413eb8b1 Mon Sep 17 00:00:00 2001 From: lapentab Date: Thu, 8 Aug 2013 14:37:19 -0400 Subject: [PATCH 059/395] Add system site packages to acceptance tests --- jenkins/test_acceptance.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/jenkins/test_acceptance.sh b/jenkins/test_acceptance.sh index 1d11265d08..b7a244fe99 100755 --- a/jenkins/test_acceptance.sh +++ b/jenkins/test_acceptance.sh @@ -13,7 +13,7 @@ export PYTHONIOENCODING=UTF-8 if [ ! -d /mnt/virtualenvs/"$JOB_NAME" ]; then mkdir -p /mnt/virtualenvs/"$JOB_NAME" - virtualenv /mnt/virtualenvs/"$JOB_NAME" + virtualenv --system-site-packages /mnt/virtualenvs/"$JOB_NAME" fi export PIP_DOWNLOAD_CACHE=/mnt/pip-cache From 803b7eb2af4a833bf78c1c7b1164e2142575e1a2 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 16:31:36 -0400 Subject: [PATCH 060/395] Fix peer grading rubric issue. Redo peer grading JS to scope locally --- .../css/combinedopenended/display.scss | 19 ++- .../js/src/peergrading/peer_grading.coffee | 32 +++-- .../peergrading/peer_grading_problem.coffee | 130 ++++++++++++------ 3 files changed, 125 insertions(+), 56 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index b63e04d20e..32558e32bb 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -419,6 +419,22 @@ div.result-container { } } +div.rubric { + ul.rubric-list{ + margin: 0; + padding: 0; + list-style-type: none; + list-style: none; + li { + &.rubric-list-item { + margin-bottom: 0; + padding: 0; + border-radius: $baseline/4; + } + } + } +} + section.open-ended-child { @media print { @@ -659,14 +675,13 @@ section.open-ended-child { margin-bottom: lh(); margin-left: .75em; margin-left: .75rem; - list-style: disc outside none; } ul.rubric-list{ margin: 0; padding: 0; list-style-type: none; - + list-style: none; li { &.rubric-list-item { margin-bottom: 0; diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading.coffee index 48980c7d88..7196a5d7a6 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading.coffee @@ -3,10 +3,20 @@ # Can (and should be) expanded upon when our problem list # becomes more sophisticated class @PeerGrading + + peer_grading_sel: '.peer-grading' + peer_grading_container_sel: '.peer-grading-container' + error_container_sel: '.error-container' + message_container_sel: '.message-container' + problem_button_sel: '.problem-button' + problem_list_sel: '.problem-list' + progress_bar_sel: '.progress-bar' + constructor: (element) -> - @peer_grading_container = $('.peer-grading') + @el = element + @peer_grading_container = @$(@peer_grading_sel) @use_single_location = @peer_grading_container.data('use-single-location') - @peer_grading_outer_container = $('.peer-grading-container') + @peer_grading_outer_container = @$(@peer_grading_container_sel) @ajax_url = @peer_grading_container.data('ajax-url') if @use_single_location.toLowerCase() == "true" @@ -14,23 +24,27 @@ class @PeerGrading @activate_problem() else #Otherwise, activate the panel view. - @error_container = $('.error-container') + @error_container = @$(@error_container_sel) @error_container.toggle(not @error_container.is(':empty')) - @message_container = $('.message-container') + @message_container = @$(@message_container_sel) @message_container.toggle(not @message_container.is(':empty')) - @problem_button = $('.problem-button') + @problem_button = @$(@problem_button_sel) @problem_button.click @show_results - @problem_list = $('.problem-list') + @problem_list = @$(@problem_list_sel) @construct_progress_bar() + # locally scoped jquery. + $: (selector) -> + $(selector, @el) + construct_progress_bar: () => problems = @problem_list.find('tr').next() problems.each( (index, element) => problem = $(element) - progress_bar = problem.find('.progress-bar') + progress_bar = problem.find(@progress_bar_sel) bar_value = parseInt(problem.data('graded')) bar_max = parseInt(problem.data('required')) + bar_value progress_bar.progressbar({value: bar_value, max: bar_max}) @@ -43,10 +57,10 @@ class @PeerGrading if response.success @peer_grading_outer_container.after(response.html).remove() backend = new PeerGradingProblemBackend(@ajax_url, false) - new PeerGradingProblem(backend) + new PeerGradingProblem(backend, @el) else @gentle_alert response.error activate_problem: () => backend = new PeerGradingProblemBackend(@ajax_url, false) - new PeerGradingProblem(backend) \ No newline at end of file + new PeerGradingProblem(backend, @el) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index c02fa3f390..b60f363649 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -158,11 +158,47 @@ class @PeerGradingProblemBackend return response class @PeerGradingProblem - constructor: (backend) -> - @prompt_wrapper = $('.prompt-wrapper') + + prompt_wrapper_sel: '.prompt-wrapper' + peer_grading_container_sel: '.peer-grading-container' + submission_container_sel: '.submission-container' + prompt_container_sel: '.prompt-container' + rubric_container_sel: '.rubric-container' + flag_student_container_sel: '.flag-student-container' + answer_unknown_container_sel: '.answer-unknown-container' + calibration_panel_sel: '.calibration-panel' + grading_panel_sel: '.grading-panel' + content_panel_sel: '.content-panel' + grading_message_sel: '.grading-message' + question_header_sel: '.question-header' + flag_submission_confirmation_sel: '.flag-submission-confirmation' + flag_submission_confirmation_button_sel: '.flag-submission-confirmation-button' + flag_submission_removal_button_sel: '.flag-submission-removal-button' + grading_wrapper_sel: '.grading-wrapper' + calibration_feedback_sel: '.calibration-feedback' + interstitial_page_sel: '.interstitial-page' + calibration_interstitial_page_sel: '.calibration-interstitial-page' + error_container_sel: '.error-container' + feedback_area_sel: '.feedback-area' + score_selection_container_sel: '.score-selection-container' + rubric_selection_container_sel: '.rubric-selection-container' + submit_button_sel: '.submit-button' + action_button_sel: '.action-button' + calibration_feedback_button_sel: '.calibration-feedback-button' + interstitial_page_button_sel: '.interstitial-page-button' + calibration_interstitial_page_button_sel: '.calibration-interstitial-page-button' + flag_checkbox_sel: '.flag-checkbox' + answer_unknown_checkbox_sel: '.answer-unknown-checkbox' + calibration_text_sel: '.calibration-text' + grading_text_sel: '.grading-text' + calibration_feedback_wrapper_sel: '.calibration-feedback-wrapper' + + constructor: (backend, el) -> + @el = el + @prompt_wrapper = $(@prompt_wrapper_sel) @backend = backend @is_ctrl = false - @el = $('.peer-grading-container') + @el = $(@peer_grading_container_sel) # get the location of the problem @location = $('.peer-grading').data('location') @@ -172,51 +208,51 @@ class @PeerGradingProblem return # get the other elements we want to fill in - @submission_container = $('.submission-container') - @prompt_container = $('.prompt-container') - @rubric_container = $('.rubric-container') - @flag_student_container = $('.flag-student-container') - @answer_unknown_container = $('.answer-unknown-container') - @calibration_panel = $('.calibration-panel') - @grading_panel = $('.grading-panel') - @content_panel = $('.content-panel') - @grading_message = $('.grading-message') + @submission_container = @$(@submission_container_sel) + @prompt_container = @$(@prompt_container_sel) + @rubric_container = @$(@rubric_container_sel) + @flag_student_container = @$(@flag_student_container_sel) + @answer_unknown_container = @$(@answer_unknown_container_sel) + @calibration_panel = @$(@calibration_panel_sel) + @grading_panel = @$(@grading_panel_sel) + @content_panel = @$(@content_panel_sel) + @grading_message = @$(@grading_message_sel) @grading_message.hide() - @question_header = $('.question-header') + @question_header = @$(@question_header_sel) @question_header.click @collapse_question - @flag_submission_confirmation = $('.flag-submission-confirmation') - @flag_submission_confirmation_button = $('.flag-submission-confirmation-button') - @flag_submission_removal_button = $('.flag-submission-removal-button') + @flag_submission_confirmation = @$(@flag_submission_confirmation_sel) + @flag_submission_confirmation_button = @$(@flag_submission_confirmation_button_sel) + @flag_submission_removal_button = @$(@flag_submission_removal_button_sel) @flag_submission_confirmation_button.click @close_dialog_box @flag_submission_removal_button.click @remove_flag - @grading_wrapper =$('.grading-wrapper') - @calibration_feedback_panel = $('.calibration-feedback') - @interstitial_page = $('.interstitial-page') + @grading_wrapper = @$(@grading_wrapper_sel) + @calibration_feedback_panel = @$(@calibration_feedback_sel) + @interstitial_page = @$(@interstitial_page_sel) @interstitial_page.hide() - @calibration_interstitial_page = $('.calibration-interstitial-page') + @calibration_interstitial_page = @$(@calibration_interstitial_page_sel) @calibration_interstitial_page.hide() - @error_container = $('.error-container') + @error_container = @$(@error_container_sel) @submission_key_input = $("input[name='submission-key']") - @essay_id_input = $("input[name='essay-id']") - @feedback_area = $('.feedback-area') + @essay_id_input = @$("input[name='essay-id']") + @feedback_area = @$(@feedback_area_sel) - @score_selection_container = $('.score-selection-container') - @rubric_selection_container = $('.rubric-selection-container') + @score_selection_container = @$(@score_selection_container_sel) + @rubric_selection_container = @$(@rubric_selection_container_sel) @grade = null @calibration = null - @submit_button = $('.submit-button') - @action_button = $('.action-button') - @calibration_feedback_button = $('.calibration-feedback-button') - @interstitial_page_button = $('.interstitial-page-button') - @calibration_interstitial_page_button = $('.calibration-interstitial-page-button') - @flag_student_checkbox = $('.flag-checkbox') - @answer_unknown_checkbox = $('.answer-unknown-checkbox') + @submit_button = @$(@submit_button_sel) + @action_button = @$(@action_button_sel) + @calibration_feedback_button = @$(@calibration_feedback_button_sel) + @interstitial_page_button = @$(@interstitial_page_button_sel) + @calibration_interstitial_page_button = @$(@calibration_interstitial_page_button_sel) + @flag_student_checkbox = @$(@flag_checkbox_sel) + @answer_unknown_checkbox = @$(@answer_unknown_checkbox_sel) $(window).keydown @keydown_handler $(window).keyup @keyup_handler @@ -251,6 +287,10 @@ class @PeerGradingProblem @is_calibrated_check() + # locally scoped jquery. + $: (selector) -> + $(selector, @el) + ########## # @@ -300,11 +340,11 @@ class @PeerGradingProblem @close_dialog_box() close_dialog_box: () => - $( ".flag-submission-confirmation" ).dialog('close') + @$(@flag_submission_confirmation_sel).dialog('close') flag_box_checked: () => if @flag_student_checkbox.is(':checked') - $( ".flag-submission-confirmation" ).dialog({ height: 400, width: 400 }) + @$(@flag_submission_confirmation_sel).dialog({ height: 400, width: 400 }) # called after we perform an is_student_calibrated check calibration_check_callback: (response) => @@ -399,10 +439,10 @@ class @PeerGradingProblem # Display the right text # both versions of the text are written into the template itself # we only need to show/hide the correct ones at the correct time - @calibration_panel.find('.calibration-text').show() - @grading_panel.find('.calibration-text').show() - @calibration_panel.find('.grading-text').hide() - @grading_panel.find('.grading-text').hide() + @calibration_panel.find(@calibration_text_sel).show() + @grading_panel.find(@calibration_text_sel).show() + @calibration_panel.find(@grading_text_sel).hide() + @grading_panel.find(@grading_text_sel).hide() @flag_student_container.hide() @answer_unknown_container.hide() @@ -429,10 +469,10 @@ class @PeerGradingProblem # Display the correct text # both versions of the text are written into the template itself # we only need to show/hide the correct ones at the correct time - @calibration_panel.find('.calibration-text').hide() - @grading_panel.find('.calibration-text').hide() - @calibration_panel.find('.grading-text').show() - @grading_panel.find('.grading-text').show() + @calibration_panel.find(@calibration_text_sel).hide() + @grading_panel.find(@calibration_text_sel).hide() + @calibration_panel.find(@grading_text_sel).show() + @grading_panel.find(@grading_text_sel).show() @flag_student_container.show() @answer_unknown_container.show() @feedback_area.val("") @@ -473,7 +513,7 @@ class @PeerGradingProblem render_calibration_feedback: (response) => # display correct grade @calibration_feedback_panel.slideDown() - calibration_wrapper = $('.calibration-feedback-wrapper') + calibration_wrapper = @$(@calibration_feedback_wrapper_sel) calibration_wrapper.html("

        The score you gave was: #{@grade}. The actual score is: #{response.actual_score}

        ") score = parseInt(@grade) @@ -490,7 +530,7 @@ class @PeerGradingProblem calibration_wrapper.append("
        Instructor Feedback: #{response.actual_feedback}
        ") # disable score selection and submission from the grading interface - $("input[name='score-selection']").attr('disabled', true) + @$("input[name='score-selection']").attr('disabled', true) @submit_button.hide() @calibration_feedback_button.show() @@ -516,7 +556,7 @@ class @PeerGradingProblem setup_score_selection: (max_score) => # And now hook up an event handler again - $("input[class='score-selection']").change @graded_callback + @$("input[class='score-selection']").change @graded_callback gentle_alert: (msg) => @grading_message.fadeIn() From f59045c3f9cc81365a3c521f0ee1fa7252968a96 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 16:54:39 -0400 Subject: [PATCH 061/395] Scope rubrics locally, make them into classes --- .../js/src/combinedopenended/display.coffee | 58 +++++++++++-------- .../peergrading/peer_grading_problem.coffee | 11 ++-- 2 files changed, 40 insertions(+), 29 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 49c056172a..7d299b5f68 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -1,20 +1,28 @@ class @Rubric - constructor: () -> - @initialize: (location,el) -> + rubric_category_sel: '.rubric-category' + rubric_sel: '.rubric' + + constructor: (el) -> @el = el - $('.rubric',@el).data("location", location) - $('input[class="score-selection"]',@el).change @tracking_callback + + initialize: (location) -> + @$(@rubric_sel).data("location", location) + @$('input[class="score-selection"]').change @tracking_callback # set up the hotkeys $(window).unbind('keydown', @keypress_callback) $(window).keydown @keypress_callback # display the 'current' carat - @categories = $('.rubric-category',el) - @category = $(@categories.first(),el) + @categories = @$(@rubric_category_sel) + @category = @$(@categories.first()) @category.prepend('> ') @category_index = 0 - @keypress_callback: (event) => + # locally scoped jquery. + $: (selector) -> + $(selector, @el) + + keypress_callback: (event) => # don't try to do this when user is typing in a text input if $(event.target).is('input, textarea') return @@ -31,7 +39,7 @@ class @Rubric # if we actually have a current category (not past the end) if(@category_index <= @categories.length) # find the valid selections for this category - inputs = $("input[name='score-selection-#{@category_index}']") + inputs = @$("input[name='score-selection-#{@category_index}']") max_score = inputs.length - 1 if selected > max_score or selected < 0 @@ -42,44 +50,44 @@ class @Rubric old_category_text = @category.html().substring(5) @category.html(old_category_text) @category_index++ - @category = $(@categories[@category_index],@el) + @category = @$(@categories[@category_index]) @category.prepend('> ') - @tracking_callback: (event) -> + tracking_callback: (event) -> target_selection = $(event.target).val() # chop off the beginning of the name so that we can get the number of the category category = $(event.target).data("category") - location = $('.rubric',@el).data('location') + location = @$(@rubric_sel).data('location') # probably want the original problem location as well data = {location: location, selection: target_selection, category: category} Logger.log 'rubric_select', data # finds the scores for each rubric category - @get_score_list: () => + get_score_list: () => # find the number of categories: - num_categories = $('.rubric-category',@el).length + num_categories = @$(@rubric_category_sel).length score_lst = [] # get the score for each one for i in [0..(num_categories-1)] - score = $("input[name='score-selection-#{i}']:checked").val() + score = @$("input[name='score-selection-#{i}']:checked").val() score_lst.push(score) return score_lst - @get_total_score: () -> + get_total_score: () -> score_lst = @get_score_list() tot = 0 for score in score_lst tot += parseInt(score) return tot - @check_complete: () -> + check_complete: () -> # check to see whether or not any categories have not been scored - num_categories = $('.rubric-category',@el).length + num_categories = @$(@rubric_category_sel).length for i in [0..(num_categories-1)] - score = $("input[name='score-selection-#{i}']:checked",@el).val() + score = @$("input[name='score-selection-#{i}']:checked").val() if score == undefined return false return true @@ -143,7 +151,8 @@ class @CombinedOpenEnded @location = @coe.data('location') # set up handlers for click tracking - Rubric.initialize(@location,@coe) + @rub = new Rubric(@coe) + @rub.initialize(@location) @is_ctrl = false #Setup reset @reset_button = @$(@reset_button_sel) @@ -390,7 +399,8 @@ class @CombinedOpenEnded if response.success @rubric_wrapper.html(response.rubric_html) @rubric_wrapper.show() - Rubric.initialize(@location,@coe) + @rub = new Rubric(@coe) + @rub.initialize(@location) @answer_area.html(response.student_response) @child_state = 'assessing' @find_assessment_elements() @@ -408,7 +418,7 @@ class @CombinedOpenEnded #Previously, responses were submitted when hitting enter. Add in a modifier that ensures that ctrl+enter is needed. if event.which == 17 && @is_ctrl==false @is_ctrl=true - else if @is_ctrl==true && event.which == 13 && @child_state == 'assessing' && Rubric.check_complete() + else if @is_ctrl==true && event.which == 13 && @child_state == 'assessing' && @rub.check_complete() @save_assessment(event) keyup_handler: (event) => @@ -418,9 +428,9 @@ class @CombinedOpenEnded save_assessment: (event) => event.preventDefault() - if @child_state == 'assessing' && Rubric.check_complete() - checked_assessment = Rubric.get_total_score() - score_list = Rubric.get_score_list() + if @child_state == 'assessing' && @rub.check_complete() + checked_assessment = @rub.get_total_score() + score_list = @rub.get_score_list() data = {'assessment' : checked_assessment, 'score_list' : score_list} $.postWithPrefix "#{@ajax_url}/save_assessment", data, (response) => if response.success diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index b60f363649..4f644a4ec1 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -309,8 +309,8 @@ class @PeerGradingProblem construct_data: () -> data = - rubric_scores: Rubric.get_score_list() - score: Rubric.get_total_score() + rubric_scores: @rub.get_score_list() + score: @rub.get_total_score() location: @location submission_id: @essay_id_input.val() submission_key: @submission_key_input.val() @@ -398,11 +398,11 @@ class @PeerGradingProblem # called after a grade is selected on the interface graded_callback: (event) => # check to see whether or not any categories have not been scored - if Rubric.check_complete() + if @rub.check_complete() # show button if we have scores for all categories @grading_message.hide() @show_submit_button() - @grade = Rubric.get_total_score() + @grade = @rub.get_total_score() keydown_handler: (event) => #Previously, responses were submitted when hitting enter. Add in a modifier that ensures that ctrl+enter is needed. @@ -507,7 +507,8 @@ class @PeerGradingProblem @submit_button.hide() @action_button.hide() @calibration_feedback_panel.hide() - Rubric.initialize(@location,@el) + @rub = new Rubric(@el) + @rub.initialize(@location) render_calibration_feedback: (response) => From d44b2e62a00f20bd01b42bf4a16571dbb65f0f30 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 17:19:06 -0400 Subject: [PATCH 062/395] Proper partial credit checks --- .../xmodule/xmodule/css/combinedopenended/display.scss | 7 +++++++ .../combined_open_ended_rubric.py | 9 ++++++++- .../open_ended_grading_classes/open_ended_module.py | 2 +- .../openended/open_ended_combined_rubric.html | 2 ++ 4 files changed, 18 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 32558e32bb..8c49417af9 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -282,6 +282,13 @@ div.combined-rubric-container { } } + label.choicegroup_partialcorrect { + &:before { + margin-right: 15px; + content: url('../images/partially-correct-icon.png'); + } + } + label.choicegroup_incorrect { &:before { margin-right: 15px; diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py index 0b2b0fdaec..a072d5ad5e 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_rubric.py @@ -224,7 +224,14 @@ class CombinedOpenEndedRubric(object): actual_scores[i] += [j] actual_scores = [sum(i)/len(i) for i in actual_scores] - correct = [int(a>.66) for a in actual_scores] + correct = [] + for (i,a) in enumerate(actual_scores): + if int(a)/max_scores[i]==1: + correct.append(1) + elif int(a)==0: + correct.append(0) + else: + correct.append(.5) html = self.system.render_template('{0}/open_ended_combined_rubric.html'.format(self.TEMPLATE_DIR), {'categories': rubric_categories, diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index e1e4643afe..afaa657937 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -697,7 +697,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): else: post_assessment = "" correct = "" - previous_answer = self.initial_display + previous_answer = "" context = { 'prompt': self.child_prompt, diff --git a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html index 158ec1f981..1ad11be024 100644 --- a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html @@ -14,6 +14,8 @@ %if len(category['options'][j]['grader_types'])>0: %if correct[i]==1: + %elif correct[i]==.5: + %else: %endif From 264e3b246b20f38efc558cc50fba7bcd8535f9c1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Thu, 8 Aug 2013 17:38:20 -0400 Subject: [PATCH 063/395] Change console logging stream from stdout to stderr --- common/lib/logsettings.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/logsettings.py b/common/lib/logsettings.py index 8fc2bb9db1..26ef3c8478 100644 --- a/common/lib/logsettings.py +++ b/common/lib/logsettings.py @@ -72,7 +72,7 @@ def get_logger_config(log_dir, 'level': console_loglevel, 'class': 'logging.StreamHandler', 'formatter': 'standard', - 'stream': sys.stdout, + 'stream': sys.stderr, }, 'syslogger-remote': { 'level': 'INFO', From 900377e4eecea5520b7c886a431e3c0ef7e5c616 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 17:53:29 -0400 Subject: [PATCH 064/395] Return written feedback --- .../combined_open_ended_modulev1.py | 9 ++++++--- .../combinedopenended/combined_open_ended_results.html | 3 +++ 2 files changed, 9 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index eee2e93312..54cfda514c 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -537,15 +537,17 @@ class CombinedOpenEndedV1Module(): Output: Dictionary to be rendered via ajax that contains the result html. """ all_responses = [] + success, can_see_rubric, error = self.check_if_student_has_done_needed_grading() + if not can_see_rubric: + return {'html' : error, 'success' : False} loop_up_to_task = self.current_task_number + 1 contexts = [] for i in xrange(0, loop_up_to_task): response = self.get_last_response(i) - rubric_scores = None score_length = len(response['grader_types']) - log.info(response) for z in xrange(0,score_length): + feedback = response['feedback_dicts'][z].get('feedback', '') if response['grader_types'][z] in HUMAN_GRADER_TYPE.keys(): rubric_scores = [[response['rubric_scores'][z]]] grader_types = [[response['grader_types'][z]]] @@ -555,7 +557,8 @@ class CombinedOpenEndedV1Module(): grader_types, feedback_items) contexts.append({ 'result': rubric_html, - 'task_name': 'Scored rubric' + 'task_name': 'Scored rubric', + 'feedback' : feedback }) context = { diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 688354ff4c..7bbf992137 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -16,6 +16,9 @@
        ${result['result'] | n} +
        + ${result['feedback'] | n} +
      %endif From a9926f04604c4dc1f4d1ab141a0eab50594976ee Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 18:43:54 -0400 Subject: [PATCH 065/395] Display message to student when they cannot see their peer grading feedback yet. --- .../xmodule/js/src/combinedopenended/display.coffee | 3 +++ .../combined_open_ended_modulev1.py | 13 ++++++++----- .../combined_open_ended_hidden_results.html | 10 ++++++++++ 3 files changed, 21 insertions(+), 5 deletions(-) create mode 100644 lms/templates/combinedopenended/combined_open_ended_hidden_results.html diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 7d299b5f68..dc6cd57730 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -250,6 +250,9 @@ class @CombinedOpenEnded @hide_rubrics() @$(@previous_rubric_sel).click @previous_rubric @$(@next_rubric_sel).click @next_rubric + if response.hide_reset + @reset_button.hide() + show_status_current: () => data = {} diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 54cfda514c..19c9aeb6df 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -506,10 +506,13 @@ class CombinedOpenEndedV1Module(): student_id = self.system.anonymous_student_id success = False allowed_to_submit = True - error_string = ("You need to peer grade {0} more in order to make another submission. " - "You have graded {1}, and {2} are required. You have made {3} successful peer grading submissions.") + error_string = ("

      Feedback not available yet

      " + "

      You need to peer grade {0} more submissions in order to see your feedback.

      " + "

      You have graded responses from {1} students, and {2} students have graded your submissions.

      " + "

      You have made {3} submissions.

      ") try: - response = self.peer_gs.get_data_for_location(self.location, student_id) + response = self.peer_gs.get_data_for_location(self.location.url(), student_id) + log.info(response) count_graded = response['count_graded'] count_required = response['count_required'] student_sub_count = response['student_sub_count'] @@ -539,7 +542,7 @@ class CombinedOpenEndedV1Module(): all_responses = [] success, can_see_rubric, error = self.check_if_student_has_done_needed_grading() if not can_see_rubric: - return {'html' : error, 'success' : False} + return {'html' : self.system.render_template('{0}/combined_open_ended_hidden_results.html'.format(self.TEMPLATE_DIR), {'error' : error}), 'success' : True, 'hide_reset' : True} loop_up_to_task = self.current_task_number + 1 contexts = [] @@ -565,7 +568,7 @@ class CombinedOpenEndedV1Module(): 'results': contexts, } html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context) - return {'html': html, 'success': True} + return {'html': html, 'success': True, 'hide_reset' : False} def get_legend(self, _data): """ diff --git a/lms/templates/combinedopenended/combined_open_ended_hidden_results.html b/lms/templates/combinedopenended/combined_open_ended_hidden_results.html new file mode 100644 index 0000000000..396a657273 --- /dev/null +++ b/lms/templates/combinedopenended/combined_open_ended_hidden_results.html @@ -0,0 +1,10 @@ +
      +
      +
      +
      + Submitted Rubric +
      +
      + ${error} +
      +
      From e6288ad19ce08ea82bf13c1ef467a06c67391db3 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Carlos=20Andr=C3=A9s=20Rocha?= Date: Thu, 8 Aug 2013 19:06:41 -0400 Subject: [PATCH 066/395] Fix manage.py to ouput the help of the django command if requested Commands like the following were not working correctly: ``` $ python manage.py lms runserver --lms ``` --- manage.py | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/manage.py b/manage.py index d6b74025f5..ebaebe8b66 100755 --- a/manage.py +++ b/manage.py @@ -20,7 +20,7 @@ from argparse import ArgumentParser def parse_args(): """Parse edx specific arguments to manage.py""" parser = ArgumentParser() - subparsers = parser.add_subparsers(title='system', description='edx service to run') + subparsers = parser.add_subparsers(title='system', description='edX service to run') lms = subparsers.add_parser( 'lms', @@ -31,8 +31,8 @@ def parse_args(): lms.add_argument('-h', '--help', action='store_true', help='show this help message and exit') lms.add_argument( '--settings', - help="Which django settings module to use from inside of lms.envs. If not provided, the DJANGO_SETTINGS_MODULE " - "environment variable will be used if it is set, otherwise will default to lms.envs.dev") + help="Which django settings module to use under lms.envs. If not provided, the DJANGO_SETTINGS_MODULE " + "environment variable will be used if it is set, otherwise it will default to lms.envs.dev") lms.add_argument( '--service-variant', choices=['lms', 'lms-xml', 'lms-preview'], @@ -52,8 +52,8 @@ def parse_args(): ) cms.add_argument( '--settings', - help="Which django settings module to use from inside cms.envs. If not provided, the DJANGO_SETTINGS_MODULE " - "environment variable will be used if it is set, otherwise will default to cms.envs.dev") + help="Which django settings module to use under cms.envs. If not provided, the DJANGO_SETTINGS_MODULE " + "environment variable will be used if it is set, otherwise it will default to cms.envs.dev") cms.add_argument('-h', '--help', action='store_true', help='show this help message and exit') cms.set_defaults( help_string=cms.format_help(), @@ -62,7 +62,6 @@ def parse_args(): service_variant='cms' ) - edx_args, django_args = parser.parse_known_args() if edx_args.help: @@ -79,11 +78,13 @@ if __name__ == "__main__": os.environ["DJANGO_SETTINGS_MODULE"] = edx_args.settings_base.replace('/', '.') + "." + edx_args.settings else: os.environ.setdefault("DJANGO_SETTINGS_MODULE", edx_args.default_settings) + os.environ.setdefault("SERVICE_VARIANT", edx_args.service_variant) + if edx_args.help: print "Django:" # This will trigger django-admin.py to print out its help - django_args.insert(0, '--help') + django_args.append('--help') from django.core.management import execute_from_command_line From e4bcfa5c219eb2270eaea15754e82e3103245c60 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 19:31:34 -0400 Subject: [PATCH 067/395] Fix indents and copy behavior --- common/lib/xmodule/xmodule/combined_open_ended_module.py | 6 ++---- common/lib/xmodule/xmodule/peer_grading_module.py | 9 ++++----- lms/djangoapps/courseware/model_data.py | 3 ++- 3 files changed, 8 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index e01ae49149..256f96a3a7 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -8,7 +8,7 @@ from .x_module import XModule from xblock.core import Integer, Scope, String, List, Float, Boolean from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor from collections import namedtuple -from .fields import Date +from .fields import Date, Timedelta import textwrap log = logging.getLogger("mitx.courseware") @@ -226,12 +226,10 @@ class CombinedOpenEndedFields(object): ) due = Date( help="Date that this problem is due by", - default=None, scope=Scope.settings ) - graceperiod = String( + graceperiod = Timedelta( help="Amount of time after the due date that submissions will be accepted", - default=None, scope=Scope.settings ) version = VersionInteger(help="Current version number", default=DEFAULT_VERSION, scope=Scope.settings) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index c29b6a8069..aa742f6a0b 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -46,7 +46,6 @@ class PeerGradingFields(object): ) due = Date( help="Due date that should be displayed.", - default=None, scope=Scope.settings) graceperiod = Timedelta( help="Amount of grace to give on the due date.", @@ -525,10 +524,10 @@ class PeerGradingModule(PeerGradingFields, XModule): good_problem_list = [] for problem in problem_list: problem_location = problem['location'] - try: - descriptor = _find_corresponding_module_for_location(problem_location) - except: - continue + try: + descriptor = _find_corresponding_module_for_location(problem_location) + except: + continue if descriptor: problem['due'] = descriptor.lms.due grace_period = descriptor.lms.graceperiod diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index 44be16e441..b72c0e90c6 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -353,7 +353,8 @@ class LmsKeyValueStore(KeyValueStore): for field in kv_dict: # Check field for validity if field.field_name in self._descriptor_model_data: - raise InvalidWriteError("Not allowed to overwrite descriptor model data", field.field_name) + if field.field_name not in ["due","graceperiod"]: + raise InvalidWriteError("Not allowed to overwrite descriptor model data", field.field_name) if field.scope not in self._allowed_scopes: raise InvalidScopeError(field.scope) From e39abe11db79440d3cb0ace2c6abec98b95bf998 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 19:50:55 -0400 Subject: [PATCH 068/395] Fix rubric with staff grading --- .../js/src/combinedopenended/display.coffee | 8 ++++---- .../src/staff_grading/staff_grading.coffee | 16 ++++++++-------- 2 files changed, 12 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index dc6cd57730..6b79ec9e3b 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -6,7 +6,7 @@ class @Rubric constructor: (el) -> @el = el - initialize: (location) -> + initialize: (location) => @$(@rubric_sel).data("location", location) @$('input[class="score-selection"]').change @tracking_callback # set up the hotkeys @@ -53,7 +53,7 @@ class @Rubric @category = @$(@categories[@category_index]) @category.prepend('> ') - tracking_callback: (event) -> + tracking_callback: (event) => target_selection = $(event.target).val() # chop off the beginning of the name so that we can get the number of the category category = $(event.target).data("category") @@ -76,14 +76,14 @@ class @Rubric return score_lst - get_total_score: () -> + get_total_score: () => score_lst = @get_score_list() tot = 0 for score in score_lst tot += parseInt(score) return tot - check_complete: () -> + check_complete: () => # check to see whether or not any categories have not been scored num_categories = @$(@rubric_category_sel).length for i in [0..(num_categories-1)] diff --git a/lms/static/coffee/src/staff_grading/staff_grading.coffee b/lms/static/coffee/src/staff_grading/staff_grading.coffee index f22fe9c7fd..e7e7c189ac 100644 --- a/lms/static/coffee/src/staff_grading/staff_grading.coffee +++ b/lms/static/coffee/src/staff_grading/staff_grading.coffee @@ -223,12 +223,12 @@ class @StaffGrading setup_score_selection: => @score_selection_container.html(@rubric) $('input[class="score-selection"]').change => @graded_callback() - Rubric.initialize(@location, @el) - + @rub = new Rubric(@el) + @rub.initialize(@location) graded_callback: () => # show button if we have scores for all categories - if Rubric.check_complete() + if @rub.check_complete() @state = state_graded @submit_button.show() @@ -236,7 +236,7 @@ class @StaffGrading #Previously, responses were submitted when hitting enter. Add in a modifier that ensures that ctrl+enter is needed. if event.which == 17 && @is_ctrl==false @is_ctrl=true - else if @is_ctrl==true && event.which == 13 && !@list_view && Rubric.check_complete() + else if @is_ctrl==true && event.which == 13 && !@list_view && @rub.check_complete() @submit_and_get_next() keyup_handler: (event) => @@ -271,8 +271,8 @@ class @StaffGrading skip_and_get_next: () => data = - score: Rubric.get_total_score() - rubric_scores: Rubric.get_score_list() + score: @rub.get_total_score() + rubric_scores: @rub.get_score_list() feedback: @feedback_area.val() submission_id: @submission_id location: @location @@ -286,8 +286,8 @@ class @StaffGrading submit_and_get_next: () -> data = - score: Rubric.get_total_score() - rubric_scores: Rubric.get_score_list() + score: @rub.get_total_score() + rubric_scores: @rub.get_score_list() feedback: @feedback_area.val() submission_id: @submission_id location: @location From adbfdccee8cdb7127600eefe4ae7f2ace37a1c6e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 08:18:23 -0400 Subject: [PATCH 069/395] Prevent student from resetting problem unless they have completed required peer grading. Grant an exemption if there is no available peer grading. --- .../combined_open_ended_modulev1.py | 7 +++++-- .../open_ended_grading_classes/peer_grading_service.py | 2 +- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py index 19c9aeb6df..c0a19ae2ab 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/combined_open_ended_modulev1.py @@ -516,6 +516,7 @@ class CombinedOpenEndedV1Module(): count_graded = response['count_graded'] count_required = response['count_required'] student_sub_count = response['student_sub_count'] + count_available = response['count_available'] success = True except: # This is a dev_facing_error @@ -524,7 +525,7 @@ class CombinedOpenEndedV1Module(): # This is a student_facing_error error_message = "Could not contact the graders. Please notify course staff." return success, allowed_to_submit, error_message - if count_graded >= count_required: + if count_graded >= count_required or count_available==0: return success, allowed_to_submit, "" else: allowed_to_submit = False @@ -687,7 +688,9 @@ class CombinedOpenEndedV1Module(): if self.state != self.DONE: if not self.ready_to_reset: return self.out_of_sync_error(data) - + success, can_reset, error = self.check_if_student_has_done_needed_grading() + if not can_reset: + return {'error' : error, 'success' : False} if self.student_attempts >= self.max_attempts-1: if self.student_attempts==self.max_attempts-1: self.student_attempts +=1 diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py index 3c25b301ab..0e5c9cdda1 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/peer_grading_service.py @@ -124,4 +124,4 @@ class MockPeerGradingService(object): ]} def get_data_for_location(self, problem_location, student_id): - return {"version": 1, "count_graded": 3, "count_required": 3, "success": True, "student_sub_count": 1} + return {"version": 1, "count_graded": 3, "count_required": 3, "success": True, "student_sub_count": 1, 'submissions_available' : 0} From f929b4930acbdc72adf6d3128bc65535275134d8 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 08:42:02 -0400 Subject: [PATCH 070/395] Add in jasmine test for rubric, fix combinedopenended jasmine failures --- .../xmodule/xmodule/js/fixtures/rubric.html | 325 ++++++++++++++++++ .../combinedopenended/display_spec.coffee | 33 +- 2 files changed, 356 insertions(+), 2 deletions(-) create mode 100644 common/lib/xmodule/xmodule/js/fixtures/rubric.html diff --git a/common/lib/xmodule/xmodule/js/fixtures/rubric.html b/common/lib/xmodule/xmodule/js/fixtures/rubric.html new file mode 100644 index 0000000000..9802e3bf8d --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/rubric.html @@ -0,0 +1,325 @@ +
      +
      +

      Open Response Assessment

      +
      +
      +
      +
      +
      + + + + + +
      +
      + Open Response +
      +
      +
      + Assessments: +
      +
      + + + +
      +
      + +
      + Peer +
      +
      +
      + + + +
      +
      +
      + +
      +
      +
      +
      + Show Prompt +
      +
      +
      +
      +
      + +
      +
      +
      + Response +
      +
      + +
      +
      + +
      + +
      + + + + +
      + + +
      +
      +
      + + + +
      + +
      +
      +
      +
      + Submitted Rubric +
      +
      + Scored rubric from grader 1 + +
      +
      + + +Ideas +
      +
        + +
      • +
        + + 0 points : +Difficult for the reader to discern the main idea. Too brief or too repetitive to establish or maintain a focus. + + +
        +
      • + + + + + + +
      + + +Content +
      +
        + +
      • +
        + + 0 points : +Includes little information with few or no details or unrelated details. Unsuccessful in attempts to explore any facets of the topic. + + +
        +
      • + + + + + + +
      + + +Organization +
      +
        + +
      • +
        + + 0 points : +Ideas organized illogically, transitions weak, and response difficult to follow. + + +
        +
      • + + + + +
      + + +Style +
      +
        + +
      • +
        + + 0 points : +Contains limited vocabulary, with many words used incorrectly. Demonstrates problems with sentence patterns. + + +
        +
      • + + + + +
      + + +Voice +
      +
        + +
      • +
        + + 0 points : +Demonstrates language and tone that may be inappropriate to task and reader. + + +
        +
      • + + + + +
      +
      + + +
      + +
      +
      + + + + +
      +
      + +
      +
      +
      +
      \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee index f2e8da7990..293d6405ad 100644 --- a/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/combinedopenended/display_spec.coffee @@ -1,3 +1,30 @@ +describe 'Rubric', -> + beforeEach -> + spyOn Logger, 'log' + # load up some fixtures + loadFixtures 'rubric.html' + jasmine.Clock.useMock() + @element = $('.combined-open-ended') + @location = @element.data('location') + + describe 'constructor', -> + beforeEach -> + @rub = new Rubric @element + + it 'rubric should properly grab the element', -> + expect(@rub.el).toEqual @element + + describe 'initialize', -> + beforeEach -> + @rub = new Rubric @element + @rub.initialize @location + + it 'rubric correctly sets location', -> + expect($(@rub.rubric_sel).data('location')).toEqual @location + + it 'rubric correctly read', -> + expect(@rub.categories.length).toEqual 5 + describe 'CombinedOpenEnded', -> beforeEach -> spyOn Logger, 'log' @@ -13,7 +40,7 @@ describe 'CombinedOpenEnded', -> @combined = new CombinedOpenEnded @element it 'set the element', -> - expect(@combined.element).toEqual @element + expect(@combined.el).toEqual @element it 'get the correct values from data fields', -> expect(@combined.ajax_url).toEqual '/courses/MITx/6.002x/2012_Fall/modx/i4x://MITx/6.002x/combinedopenended/CombinedOE' @@ -77,7 +104,7 @@ describe 'CombinedOpenEnded', -> @combined.child_state = 'done' @combined.rebind() expect(@combined.answer_area.attr("disabled")).toBe("disabled") - expect(@combined.next_problem).toHaveBeenCalled() + expect(@combined.next_problem_button).toBe(":visible") describe 'next_problem', -> beforeEach -> @@ -109,3 +136,5 @@ describe 'CombinedOpenEnded', -> + + From 5d16bc9eb613a4f268eeb4ca7824b5394cc051a7 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 11:24:07 -0400 Subject: [PATCH 071/395] Remove ad-hoc patch for xblock issue --- lms/djangoapps/courseware/model_data.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/model_data.py b/lms/djangoapps/courseware/model_data.py index b72c0e90c6..44be16e441 100644 --- a/lms/djangoapps/courseware/model_data.py +++ b/lms/djangoapps/courseware/model_data.py @@ -353,8 +353,7 @@ class LmsKeyValueStore(KeyValueStore): for field in kv_dict: # Check field for validity if field.field_name in self._descriptor_model_data: - if field.field_name not in ["due","graceperiod"]: - raise InvalidWriteError("Not allowed to overwrite descriptor model data", field.field_name) + raise InvalidWriteError("Not allowed to overwrite descriptor model data", field.field_name) if field.scope not in self._allowed_scopes: raise InvalidScopeError(field.scope) From e1cba4b0b4927ad7b44876d472135cb191371976 Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 9 Aug 2013 11:26:59 -0400 Subject: [PATCH 072/395] cleaned up html structure for various pages, style fixed for show/hide prompt item --- .../css/combinedopenended/display.scss | 4 + .../js/fixtures/combined-open-ended.html | 121 +++++++++--------- lms/static/sass/course/_rubric.scss | 1 + .../combined_open_ended.html | 3 +- .../combined_open_ended_results.html | 10 +- .../openended/open_ended_combined_rubric.html | 2 +- 6 files changed, 77 insertions(+), 64 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index b63e04d20e..4565f73bc8 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -132,6 +132,10 @@ section.combined-open-ended-status { border-radius: 0 $baseline/4 $baseline/4 0; border-right: 0px; } + + &:only-child { + border-radius: $baseline/4; + } .show-results { margin-top: .3em; diff --git a/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html b/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html index abea783ae8..5cd1113103 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html +++ b/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html @@ -1,75 +1,78 @@
      -
      +
      - -

      Problem 1

      -
      -

      Status

      -
      -
      - -
      - - Step 1 (Problem complete) : 1 / 1 +

      Problem 1

      +
      +

      Status

      +
      +
      +
      + Step 1 (Problem complete) : 1 / 1 - -
      - -
      - - Step 2 (Being scored) : None / 1 +
      +
      + Step 2 (Being scored) : None / 1 - +
      +
      +
      -
      -
      - -
      - -
      -

      Problem

      +
      +

      Problem

      -
      -
      - - Some prompt. - -
      -
      -
      - Submitted for grading. - -
      - -
      - - -
      +
      +
      +
      +
      +
      + Some prompt. +
      + +
      +
      + Submitted for grading. +
      + + +
      + +
      - - - - +
      + +
      - -
      -
      -
      - - -
      - - + +
      + + +
      + + + - + })" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log">QA + +
      +
      Submitted Rubric
    -
    - ${result['task_name']} from grader ${i+1} +
    + % if len(results)>1: + Previous + % endif + ${result['task_name']} from grader ${i+1} + % if len(results)>1: + Next + % endif
    ${result['result'] | n} diff --git a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html index 158ec1f981..5f763f371d 100644 --- a/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_combined_rubric.html @@ -1,7 +1,7 @@
    % for i in range(len(categories)): <% category = categories[i] %> - ${category['description']}
    + ${category['description']}
      % for j in range(len(category['options'])): <% option = category['options'][j] %> From 9769f364e1ff5e95f0ab65090d42aca544f9b187 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Fri, 9 Aug 2013 12:10:53 -0400 Subject: [PATCH 073/395] Updated tests to weaken "number" input field requirement, which isn't supported in Firefox. --- .../coffee/spec/views/metadata_edit_spec.coffee | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cms/static/coffee/spec/views/metadata_edit_spec.coffee b/cms/static/coffee/spec/views/metadata_edit_spec.coffee index 926e5be315..2327779b8a 100644 --- a/cms/static/coffee/spec/views/metadata_edit_spec.coffee +++ b/cms/static/coffee/spec/views/metadata_edit_spec.coffee @@ -113,6 +113,13 @@ describe "Test Metadata Editor", -> verifyEntry = (index, display_name, type) -> expect(childModels[index].get('display_name')).toBe(display_name) + + # Some browsers (e.g. FireFox) do not support the "number" + # input type. We can accept a "text" input instead + # and still get acceptable behavior in the UI. + if type == 'number' and childViews[index].type != 'number' + type = 'text' + expect(childViews[index].type).toBe(type) verifyEntry(0, 'Display Name', 'text') @@ -164,6 +171,13 @@ describe "Test Metadata Editor", -> assertInputType = (view, expectedType) -> input = view.$el.find('.setting-input') expect(input.length).toEqual(1) + + # Some browsers (e.g. FireFox) do not support the "number" + # input type. We can accept a "text" input instead + # and still get acceptable behavior in the UI. + if expectedType == 'number' and input[0].type != 'number' + expectedType = 'text' + expect(input[0].type).toEqual(expectedType) assertValueInView = (view, expectedValue) -> From 6e41c082b11a89ec4f54fe5b8e362dd460dff570 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 12:30:05 -0400 Subject: [PATCH 074/395] Fix tests --- .../xmodule/tests/test_combined_open_ended.py | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py index 4fd0ddccf7..268f8f0b69 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -64,6 +64,12 @@ class OpenEndedChildTest(unittest.TestCase): 's3_interface': "", 'open_ended_grading_interface': {}, 'skip_basic_checks': False, + 'control': { + 'required_peer_grading': 1, + 'peer_grader_count': 1, + 'min_to_calibrate': 3, + 'max_to_calibrate': 6, + } } definition = Mock() descriptor = Mock() @@ -180,6 +186,12 @@ class OpenEndedModuleTest(unittest.TestCase): 's3_interface': test_util_open_ended.S3_INTERFACE, 'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, 'skip_basic_checks': False, + 'control': { + 'required_peer_grading': 1, + 'peer_grader_count': 1, + 'min_to_calibrate': 3, + 'max_to_calibrate': 6, + } } oeparam = etree.XML(''' From 58c6b9bb6145f2ee0be447b47bc430790eb38685 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Thu, 8 Aug 2013 10:09:03 -0400 Subject: [PATCH 075/395] add privilege copy --- .../instructor_dashboard_2/membership.html | 22 +++++++++++++------ 1 file changed, 15 insertions(+), 7 deletions(-) diff --git a/lms/templates/instructor/instructor_dashboard_2/membership.html b/lms/templates/instructor/instructor_dashboard_2/membership.html index c226f74215..b22d31e190 100644 --- a/lms/templates/instructor/instructor_dashboard_2/membership.html +++ b/lms/templates/instructor/instructor_dashboard_2/membership.html @@ -59,10 +59,10 @@ data-rolename="staff" data-display-name="Course Staff" data-info-text=" - Course staff can help you manage limited aspects of your course. Staff can - enroll and unenroll students, as well as modify their grades and see all - course data. Course staff are not given access to Studio will not be able to - edit your course." + Course staff can help you manage limited aspects of your course. Staff + can enroll and unenroll students, as well as modify their grades and + see all course data. Course staff are not automatically given access + to Studio and will not be able to edit your course." data-list-endpoint="${ section_data['list_course_role_members_url'] }" data-modify-endpoint="${ section_data['modify_access_url'] }" data-add-button-label="Add Staff" @@ -74,8 +74,7 @@ data-display-name="Instructors" data-info-text=" Instructors are the core administration of your course. Instructors can - add and remove course staff, as well as administer forum access. - " + add and remove course staff, as well as administer forum access." data-list-endpoint="${ section_data['list_course_role_members_url'] }" data-modify-endpoint="${ section_data['modify_access_url'] }" data-add-button-label="Add Instructor" @@ -88,7 +87,7 @@ data-info-text=" Beta testers can see course content before the rest of the students. They can make sure that the content works, but have no additional - privelages." + privileges." data-list-endpoint="${ section_data['list_course_role_members_url'] }" data-modify-endpoint="${ section_data['modify_access_url'] }" data-add-button-label="Add Beta Tester" @@ -99,6 +98,9 @@
      Date: Thu, 8 Aug 2013 11:05:59 -0400 Subject: [PATCH 076/395] hide empty management list selector, add explanation text --- .../instructor_dashboard/membership.coffee | 2 + .../instructor_dashboard_2/membership.html | 52 ++++++++++--------- 2 files changed, 30 insertions(+), 24 deletions(-) diff --git a/lms/static/coffee/src/instructor_dashboard/membership.coffee b/lms/static/coffee/src/instructor_dashboard/membership.coffee index 733480e268..a50cd2c3dd 100644 --- a/lms/static/coffee/src/instructor_dashboard/membership.coffee +++ b/lms/static/coffee/src/instructor_dashboard/membership.coffee @@ -463,6 +463,8 @@ class Membership text: auth_list.$container.data 'display-name' data: auth_list: auth_list + if @auth_lists.length is 0 + @$list_selector.hide() @$list_selector.change => $opt = @$list_selector.children('option:selected') diff --git a/lms/templates/instructor/instructor_dashboard_2/membership.html b/lms/templates/instructor/instructor_dashboard_2/membership.html index b22d31e190..0a96d23a27 100644 --- a/lms/templates/instructor/instructor_dashboard_2/membership.html +++ b/lms/templates/instructor/instructor_dashboard_2/membership.html @@ -54,6 +54,14 @@
      + %if not section_data['access']['instructor']: +

      + Staff cannot modify staff or beta tester lists. To modify these lists, + contact your instructor and ask them to add you as an instructor for staff + and beta lists, or a forum admin for forum management. +

      + %endif + %if section_data['access']['instructor']:
      - %if section_data['access']['instructor']: -
      - %endif +
      - %endif - %if section_data['access']['instructor']: -
      +
      %endif %if section_data['access']['instructor'] or section_data['access']['forum_admin']: From e9aca1363641481f450683300c4f00f5efc78573 Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Thu, 8 Aug 2013 10:09:47 -0400 Subject: [PATCH 077/395] enable beta dashboard --- lms/envs/common.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/envs/common.py b/lms/envs/common.py index 0cbcbb774a..de78816a10 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -145,7 +145,7 @@ MITX_FEATURES = { 'ENABLE_INSTRUCTOR_BACKGROUND_TASKS': True, # Enable instructor dash beta version link - 'ENABLE_INSTRUCTOR_BETA_DASHBOARD': False, + 'ENABLE_INSTRUCTOR_BETA_DASHBOARD': True, # Allow use of the hint managment instructor view. 'ENABLE_HINTER_INSTRUCTOR_VIEW': False, From 8cf82f297371df61f93b346bf736b53daec9f381 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 14:38:30 -0400 Subject: [PATCH 078/395] Fix self assessment test --- common/lib/xmodule/xmodule/tests/test_self_assessment.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/common/lib/xmodule/xmodule/tests/test_self_assessment.py b/common/lib/xmodule/xmodule/tests/test_self_assessment.py index 0ccc6864cd..c9140d643a 100644 --- a/common/lib/xmodule/xmodule/tests/test_self_assessment.py +++ b/common/lib/xmodule/xmodule/tests/test_self_assessment.py @@ -49,6 +49,12 @@ class SelfAssessmentTest(unittest.TestCase): 's3_interface': test_util_open_ended.S3_INTERFACE, 'open_ended_grading_interface': test_util_open_ended.OPEN_ENDED_GRADING_INTERFACE, 'skip_basic_checks': False, + 'control': { + 'required_peer_grading': 1, + 'peer_grader_count': 1, + 'min_to_calibrate': 3, + 'max_to_calibrate': 6, + } } self.module = SelfAssessmentModule(get_test_system(), self.location, From 835edbf32f5554f1e5cb7829f0ce988f4ec8fa8d Mon Sep 17 00:00:00 2001 From: cahrens Date: Fri, 9 Aug 2013 14:46:18 -0400 Subject: [PATCH 079/395] Change locators to a restful interface. Don't use ; @ and # as separators. --- .../xmodule/xmodule/modulestore/locator.py | 39 +++++----- .../xmodule/xmodule/modulestore/parsers.py | 34 +++++---- .../modulestore/tests/test_locators.py | 74 ++++++++++++------- 3 files changed, 86 insertions(+), 61 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/locator.py b/common/lib/xmodule/xmodule/modulestore/locator.py index 591ef3115f..3e20f3e1b4 100644 --- a/common/lib/xmodule/xmodule/modulestore/locator.py +++ b/common/lib/xmodule/xmodule/modulestore/locator.py @@ -1,8 +1,7 @@ """ -Created on Mar 13, 2013 +Identifier for course resources. +""" -@author: dmitchell -""" from __future__ import absolute_import import logging import inspect @@ -15,6 +14,7 @@ from bson.errors import InvalidId from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError from .parsers import parse_url, parse_course_id, parse_block_ref +from .parsers import BRANCH_PREFIX, BLOCK_PREFIX, URL_VERSION_PREFIX log = logging.getLogger(__name__) @@ -37,9 +37,6 @@ class Locator(object): """ raise InsufficientSpecificationError() - def quoted_url(self): - return quote(self.url(), '@;#') - def __eq__(self, other): return self.__dict__ == other.__dict__ @@ -90,11 +87,11 @@ class CourseLocator(Locator): Examples of valid CourseLocator specifications: CourseLocator(version_guid=ObjectId('519665f6223ebd6980884f2b')) CourseLocator(course_id='mit.eecs.6002x') - CourseLocator(course_id='mit.eecs.6002x;published') + CourseLocator(course_id='mit.eecs.6002x/branch/published') CourseLocator(course_id='mit.eecs.6002x', branch='published') - CourseLocator(url='edx://@519665f6223ebd6980884f2b') + CourseLocator(url='edx://version/519665f6223ebd6980884f2b') CourseLocator(url='edx://mit.eecs.6002x') - CourseLocator(url='edx://mit.eecs.6002x;published') + CourseLocator(url='edx://mit.eecs.6002x/branch/published') Should have at lease a specific course_id (id for the course as if it were a project w/ versions) with optional 'branch', @@ -115,10 +112,10 @@ class CourseLocator(Locator): if self.course_id: result = self.course_id if self.branch: - result += ';' + self.branch + result += BRANCH_PREFIX + self.branch return result elif self.version_guid: - return '@' + str(self.version_guid) + return URL_VERSION_PREFIX + str(self.version_guid) else: # raise InsufficientSpecificationError("missing course_id or version_guid") return '' @@ -224,7 +221,7 @@ class CourseLocator(Locator): """ url must be a string beginning with 'edx://' and containing either a valid version_guid or course_id (with optional branch) - If a block ('#HW3') is present, it is ignored. + If a block ('/block/HW3') is present, it is ignored. """ if isinstance(url, Locator): url = url.url() @@ -253,14 +250,14 @@ class CourseLocator(Locator): def init_from_course_id(self, course_id, explicit_branch=None): """ - Course_id is a string like 'mit.eecs.6002x' or 'mit.eecs.6002x;published'. + Course_id is a string like 'mit.eecs.6002x' or 'mit.eecs.6002x/branch/published'. Revision (optional) is a string like 'published'. It may be provided explicitly (explicit_branch) or embedded into course_id. - If branch is part of course_id ("...;published"), parse it out separately. + If branch is part of course_id (".../branch/published"), parse it out separately. If branch is provided both ways, that's ok as long as they are the same value. - If a block ('#HW3') is a part of course_id, it is ignored. + If a block ('/block/HW3') is a part of course_id, it is ignored. """ @@ -411,9 +408,9 @@ class BlockUsageLocator(CourseLocator): rep = CourseLocator.__unicode__(self) if self.usage_id is None: # usage_id has not been initialized - return rep + '#NONE' + return rep + BLOCK_PREFIX + 'NONE' else: - return rep + '#' + self.usage_id + return rep + BLOCK_PREFIX + self.usage_id class DescriptionLocator(Locator): @@ -427,14 +424,14 @@ class DescriptionLocator(Locator): def __unicode__(self): ''' Return a string representing this location. - unicode(self) returns something like this: "@519665f6223ebd6980884f2b" + unicode(self) returns something like this: "version/519665f6223ebd6980884f2b" ''' - return '@' + str(self.definition_guid) + return URL_VERSION_PREFIX + str(self.definition_id) def url(self): """ Return a string containing the URL for this location. - url(self) returns something like this: 'edx://@519665f6223ebd6980884f2b' + url(self) returns something like this: 'edx://version/519665f6223ebd6980884f2b' """ return 'edx://' + unicode(self) @@ -442,7 +439,7 @@ class DescriptionLocator(Locator): """ Returns the ObjectId referencing this specific location. """ - return self.definition_guid + return self.definition_id class VersionTree(object): diff --git a/common/lib/xmodule/xmodule/modulestore/parsers.py b/common/lib/xmodule/xmodule/modulestore/parsers.py index 8e5b685cec..efdf1c9e18 100644 --- a/common/lib/xmodule/xmodule/modulestore/parsers.py +++ b/common/lib/xmodule/xmodule/modulestore/parsers.py @@ -1,5 +1,12 @@ import re +# Prefix for the branch portion of a locator URL +BRANCH_PREFIX = "/branch/" +# Prefix for the block portion of a locator URL +BLOCK_PREFIX = "/block/" +# Prefix for when a course URL begins with a version ID +URL_VERSION_PREFIX = 'version/' + URL_RE = re.compile(r'^edx://(.+)$', re.IGNORECASE) @@ -9,10 +16,10 @@ def parse_url(string): followed by either a version_guid or a course_id. Examples: - 'edx://@0123FFFF' + 'edx://version/0123FFFF' 'edx://edu.mit.eecs.6002x' - 'edx://edu.mit.eecs.6002x;published' - 'edx://edu.mit.eecs.6002x;published#HW3' + 'edx://edu.mit.eecs.6002x/branch/published' + 'edx://edu.mit.eecs.6002x/branch/published/block/HW3' This returns None if string cannot be parsed. @@ -27,8 +34,8 @@ def parse_url(string): if not match: return None path = match.group(1) - if path[0] == '@': - return parse_guid(path[1:]) + if path.startswith(URL_VERSION_PREFIX): + return parse_guid(path[len(URL_VERSION_PREFIX):]) return parse_course_id(path) @@ -52,8 +59,7 @@ def parse_block_ref(string): return None -GUID_RE = re.compile(r'^(?P[A-F0-9]+)(#(?P\w+))?$', re.IGNORECASE) - +GUID_RE = re.compile(r'^(?P[A-F0-9]+)(' + BLOCK_PREFIX + '(?P\w+))?$', re.IGNORECASE) def parse_guid(string): """ @@ -69,27 +75,27 @@ def parse_guid(string): return None -COURSE_ID_RE = re.compile(r'^(?P(\w+)(\.\w+\w*)*)(;(?P\w+))?(#(?P\w+))?$', re.IGNORECASE) +COURSE_ID_RE = re.compile(r'^(?P(\w+)(\.\w+\w*)*)('+ BRANCH_PREFIX + '(?P\w+))?(' + BLOCK_PREFIX + '(?P\w+))?$', re.IGNORECASE) def parse_course_id(string): r""" A course_id has a main id component. - There may also be an optional branch (;published or ;draft). - There may also be an optional block (#HW3 or #Quiz2). + There may also be an optional branch (/branch/published or /branch/draft). + There may also be an optional block (/block/HW3 or /block/Quiz2). Examples of valid course_ids: 'edu.mit.eecs.6002x' - 'edu.mit.eecs.6002x;published' - 'edu.mit.eecs.6002x#HW3' - 'edu.mit.eecs.6002x;published#HW3' + 'edu.mit.eecs.6002x/branch/published' + 'edu.mit.eecs.6002x/block/HW3' + 'edu.mit.eecs.6002x/branch/published/block/HW3' Syntax: - course_id = main_id [; branch] [# block] + course_id = main_id [/branch/ branch] [/block/ block] main_id = name [. name]* diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py index bb41131234..0f39a4c66f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_locators.py @@ -1,12 +1,11 @@ -''' -Created on Mar 14, 2013 - -@author: dmitchell -''' +""" +Tests for xmodule.modulestore.locator. +""" from unittest import TestCase from bson.objectid import ObjectId -from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator +from xmodule.modulestore.locator import Locator, CourseLocator, BlockUsageLocator, DescriptionLocator +from xmodule.modulestore.parsers import BRANCH_PREFIX, BLOCK_PREFIX, URL_VERSION_PREFIX from xmodule.modulestore.exceptions import InsufficientSpecificationError, OverSpecificationError @@ -32,12 +31,12 @@ class LocatorTest(TestCase): self.assertRaises( OverSpecificationError, CourseLocator, - url='edx://mit.eecs.6002x;published', + url='edx://mit.eecs.6002x' + BRANCH_PREFIX + 'published', branch='draft') self.assertRaises( OverSpecificationError, CourseLocator, - course_id='mit.eecs.6002x;published', + course_id='mit.eecs.6002x' + BRANCH_PREFIX + 'published', branch='draft') def test_course_constructor_underspecified(self): @@ -55,8 +54,8 @@ class LocatorTest(TestCase): testobj_1 = CourseLocator(version_guid=test_id_1) self.check_course_locn_fields(testobj_1, 'version_guid', version_guid=test_id_1) self.assertEqual(str(testobj_1.version_guid), test_id_1_loc) - self.assertEqual(str(testobj_1), '@' + test_id_1_loc) - self.assertEqual(testobj_1.url(), 'edx://@' + test_id_1_loc) + self.assertEqual(str(testobj_1), URL_VERSION_PREFIX + test_id_1_loc) + self.assertEqual(testobj_1.url(), 'edx://' + URL_VERSION_PREFIX + test_id_1_loc) # Test using a given string test_id_2_loc = '519665f6223ebd6980884f2b' @@ -64,8 +63,8 @@ class LocatorTest(TestCase): testobj_2 = CourseLocator(version_guid=test_id_2) self.check_course_locn_fields(testobj_2, 'version_guid', version_guid=test_id_2) self.assertEqual(str(testobj_2.version_guid), test_id_2_loc) - self.assertEqual(str(testobj_2), '@' + test_id_2_loc) - self.assertEqual(testobj_2.url(), 'edx://@' + test_id_2_loc) + self.assertEqual(str(testobj_2), URL_VERSION_PREFIX + test_id_2_loc) + self.assertEqual(testobj_2.url(), 'edx://'+ URL_VERSION_PREFIX + test_id_2_loc) def test_course_constructor_bad_course_id(self): """ @@ -74,20 +73,20 @@ class LocatorTest(TestCase): for bad_id in ('mit.', ' mit.eecs', 'mit.eecs ', - '@mit.eecs', - '#mit.eecs', + URL_VERSION_PREFIX + 'mit.eecs', + BLOCK_PREFIX + 'block/mit.eecs', 'mit.ee cs', 'mit.ee,cs', 'mit.ee/cs', 'mit.ee$cs', 'mit.ee&cs', 'mit.ee()cs', - ';this', - 'mit.eecs;', - 'mit.eecs;this;that', - 'mit.eecs;this;', - 'mit.eecs;this ', - 'mit.eecs;th%is ', + BRANCH_PREFIX + 'this', + 'mit.eecs' + BRANCH_PREFIX, + 'mit.eecs' + BRANCH_PREFIX + 'this' + BRANCH_PREFIX +'that', + 'mit.eecs' + BRANCH_PREFIX + 'this' + BRANCH_PREFIX , + 'mit.eecs' + BRANCH_PREFIX + 'this ', + 'mit.eecs' + BRANCH_PREFIX + 'th%is ', ): self.assertRaises(AssertionError, CourseLocator, course_id=bad_id) self.assertRaises(AssertionError, CourseLocator, url='edx://' + bad_id) @@ -106,7 +105,7 @@ class LocatorTest(TestCase): self.check_course_locn_fields(testobj, 'course_id', course_id=testurn) def test_course_constructor_redundant_002(self): - testurn = 'mit.eecs.6002x;published' + testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' expected_urn = 'mit.eecs.6002x' expected_rev = 'published' testobj = CourseLocator(course_id=testurn, url='edx://' + testurn) @@ -114,6 +113,17 @@ class LocatorTest(TestCase): course_id=expected_urn, branch=expected_rev) + def test_course_constructor_url(self): + # Test parsing a url when it starts with a version ID and there is also a block ID. + # This hits the parsers parse_guid method. + test_id_loc = '519665f6223ebd6980884f2b' + testobj = CourseLocator(url="edx://" + URL_VERSION_PREFIX + test_id_loc + BLOCK_PREFIX + "hw3") + self.check_course_locn_fields( + testobj, + 'test_block constructor', + version_guid=ObjectId(test_id_loc) + ) + def test_course_constructor_course_id_no_branch(self): testurn = 'mit.eecs.6002x' testobj = CourseLocator(course_id=testurn) @@ -123,7 +133,7 @@ class LocatorTest(TestCase): self.assertEqual(testobj.url(), 'edx://' + testurn) def test_course_constructor_course_id_with_branch(self): - testurn = 'mit.eecs.6002x;published' + testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' expected_id = 'mit.eecs.6002x' expected_branch = 'published' testobj = CourseLocator(course_id=testurn) @@ -139,7 +149,7 @@ class LocatorTest(TestCase): def test_course_constructor_course_id_separate_branch(self): test_id = 'mit.eecs.6002x' test_branch = 'published' - expected_urn = 'mit.eecs.6002x;published' + expected_urn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' testobj = CourseLocator(course_id=test_id, branch=test_branch) self.check_course_locn_fields(testobj, 'course_id with separate branch', course_id=test_id, @@ -154,10 +164,10 @@ class LocatorTest(TestCase): """ The same branch appears in the course_id and the branch field. """ - test_id = 'mit.eecs.6002x;published' + test_id = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' test_branch = 'published' expected_id = 'mit.eecs.6002x' - expected_urn = 'mit.eecs.6002x;published' + expected_urn = test_id testobj = CourseLocator(course_id=test_id, branch=test_branch) self.check_course_locn_fields(testobj, 'course_id with repeated branch', course_id=expected_id, @@ -169,7 +179,7 @@ class LocatorTest(TestCase): self.assertEqual(testobj.url(), 'edx://' + expected_urn) def test_block_constructor(self): - testurn = 'mit.eecs.6002x;published#HW3' + testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' + BLOCK_PREFIX + 'HW3' expected_id = 'mit.eecs.6002x' expected_branch = 'published' expected_block_ref = 'HW3' @@ -181,6 +191,18 @@ class LocatorTest(TestCase): self.assertEqual(str(testobj), testurn) self.assertEqual(testobj.url(), 'edx://' + testurn) + def test_repr(self): + testurn = 'mit.eecs.6002x' + BRANCH_PREFIX + 'published' + BLOCK_PREFIX + 'HW3' + testobj = BlockUsageLocator(course_id=testurn) + self.assertEqual('BlockUsageLocator("mit.eecs.6002x/branch/published/block/HW3")', repr(testobj)) + + def test_description_locator_url(self): + definition_locator=DescriptionLocator("chapter12345_2") + self.assertEqual('edx://' + URL_VERSION_PREFIX + 'chapter12345_2', definition_locator.url()) + + def test_description_locator_version(self): + definition_locator=DescriptionLocator("chapter12345_2") + self.assertEqual("chapter12345_2", definition_locator.version()) # ------------------------------------------------------------------ # Utilities From 33da3ec1cfa5080250bf70a5b10f41c7ccc8e069 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 14:50:57 -0400 Subject: [PATCH 080/395] Fix self assessment template --- .../openended/open_ended.html | 4 ++-- .../selfassessment/self_assessment_prompt.html | 18 ++++++++---------- 2 files changed, 10 insertions(+), 12 deletions(-) diff --git a/lms/templates/combinedopenended/openended/open_ended.html b/lms/templates/combinedopenended/openended/open_ended.html index 6e2de66823..c6a8cb2253 100644 --- a/lms/templates/combinedopenended/openended/open_ended.html +++ b/lms/templates/combinedopenended/openended/open_ended.html @@ -31,8 +31,8 @@
      - - + +
      diff --git a/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html b/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html index 99782e3185..ee2dd643df 100644 --- a/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html +++ b/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html @@ -4,25 +4,23 @@
      ${prompt} +
      - Response + ${_("Response")}
      -

      ${_("Response")}

      -
      -
      -
      +
      +
      ${initial_rubric}
      -
      - -
      -
      - + + +
      +
    From cde8ee50d726d46be1af9f4350062d4f69821986 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Fri, 9 Aug 2013 15:18:08 -0400 Subject: [PATCH 081/395] Fix jumping to the top of the page on unit delete. --- cms/static/coffee/src/views/unit.coffee | 1 + 1 file changed, 1 insertion(+) diff --git a/cms/static/coffee/src/views/unit.coffee b/cms/static/coffee/src/views/unit.coffee index fd679c289b..0154b4f51a 100644 --- a/cms/static/coffee/src/views/unit.coffee +++ b/cms/static/coffee/src/views/unit.coffee @@ -120,6 +120,7 @@ class CMS.Views.UnitEdit extends Backbone.View @model.save() deleteComponent: (event) => + event.preventDefault() msg = new CMS.Views.Prompt.Warning( title: gettext('Delete this component?'), message: gettext('Deleting this component is permanent and cannot be undone.'), From d6e06e441f2175419fd55b769dff6906dc31d3d8 Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Fri, 9 Aug 2013 15:53:48 -0400 Subject: [PATCH 082/395] Upgrade diff-cover to newest version --- requirements/edx/github.txt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/requirements/edx/github.txt b/requirements/edx/github.txt index 9eea9e1eff..a2b4dde59c 100644 --- a/requirements/edx/github.txt +++ b/requirements/edx/github.txt @@ -10,4 +10,4 @@ # Our libraries: -e git+https://github.com/edx/XBlock.git@446668fddc75b78512eef4e9425cbc9a3327606f#egg=XBlock -e git+https://github.com/edx/codejail.git@0a1b468#egg=codejail --e git+https://github.com/edx/diff-cover.git@v0.2.0#egg=diff_cover +-e git+https://github.com/edx/diff-cover.git@v0.2.1#egg=diff_cover From 5c9538a9a112129d9d44551e23bdcd2cad35949c Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 16:46:25 -0400 Subject: [PATCH 083/395] Address review comments --- .../xmodule/xmodule/combined_open_ended_module.py | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index faf22d1926..2856c98127 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -13,9 +13,11 @@ import textwrap log = logging.getLogger("mitx.courseware") -V1_SETTINGS_ATTRIBUTES = ["display_name", "max_attempts", "graded", "accept_file_upload", - "skip_spelling_checks", "due", "graceperiod", "weight", "min_to_calibrate", - "max_to_calibrate", "peer_grader_count", "required_peer_grading"] +V1_SETTINGS_ATTRIBUTES = [ + "display_name", "max_attempts", "graded", "accept_file_upload", + "skip_spelling_checks", "due", "graceperiod", "weight", "min_to_calibrate", + "max_to_calibrate", "peer_grader_count", "required_peer_grading", +] V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state", "student_attempts", "ready_to_reset"] @@ -250,14 +252,14 @@ class CombinedOpenEndedFields(object): help="The minimum number of calibration essays each student will need to complete for peer grading.", default=3, scope=Scope.settings, - values={"min" : 1, "step" : "1"} + values={"min" : 1, "max" : 20, "step" : "1"} ) max_to_calibrate = Integer( display_name="Maximum Peer Grading Calibrations", help="The maximum number of calibration essays each student will need to complete for peer grading.", default=6, scope=Scope.settings, - values={"max" : 20, "step" : "1"} + values={"min" : 1, "max" : 20, "step" : "1"} ) peer_grader_count = Integer( display_name="Peer Graders per Response", From 464141c72a48edfc693bd151c9598a6d84ca633a Mon Sep 17 00:00:00 2001 From: Giulio Gratta Date: Fri, 9 Aug 2013 09:24:17 -0700 Subject: [PATCH 084/395] changing http:// to // on intro video urls to prevent browsers from blocking video embeds --- cms/djangoapps/models/settings/course_details.py | 2 +- cms/static/js/models/settings/course_details.js | 2 +- lms/templates/index.html | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 7c3b883283..78c5dcff33 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -173,7 +173,7 @@ class CourseDetails(object): # the right thing result = None if video_key: - result = '' return result diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index d7e11d5689..4d048bab81 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -75,7 +75,7 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ return this.videosourceSample(); }, videosourceSample : function() { - if (this.has('intro_video')) return "http://www.youtube.com/embed/" + this.get('intro_video'); + if (this.has('intro_video')) return "//www.youtube.com/embed/" + this.get('intro_video'); else return ""; } }); diff --git a/lms/templates/index.html b/lms/templates/index.html index e7c0d638c7..0fecd24e84 100644 --- a/lms/templates/index.html +++ b/lms/templates/index.html @@ -186,7 +186,7 @@ else: youtube_video_id = "XNaiOGxWeto" %> - +
    From 68199da821410205ef3c9741b8329555695caa4f Mon Sep 17 00:00:00 2001 From: marco Date: Fri, 9 Aug 2013 18:44:15 -0400 Subject: [PATCH 085/395] added oe-tools header areas to eventually consolitate all messaging --- .../css/combinedopenended/display.scss | 20 +++++++++++++++++++ .../js/fixtures/combined-open-ended.html | 8 +++++--- .../xmodule/xmodule/js/fixtures/rubric.html | 12 +++++------ lms/static/sass/course/_rubric.scss | 10 ++++++++-- .../combined_open_ended.html | 10 ++++++++-- .../combined_open_ended_results.html | 9 +++------ .../self_assessment_prompt.html | 2 -- 7 files changed, 50 insertions(+), 21 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 3500e6e47d..d4a79ff825 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -855,3 +855,23 @@ section.open-ended-child { padding: $baseline/2 0; } } + +//OE Tool Area Styling + +.oe-tools { + display: inline-block; + width: 100%; + border-radius: 5px; + + .oe-tools-label { + font-size: 0.8em; + display: inline-block; + vertical-align: middle; + padding: 10px; + } + + .reset-button { + vertical-align: middle; + } +} + diff --git a/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html b/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html index 5cd1113103..91d09f7922 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html +++ b/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html @@ -41,7 +41,10 @@
    - +
    + Response: + +
    @@ -65,8 +68,7 @@ 'xqa_key': 'KUBrWtK3RAaBALLbccHrXeD3RHOpmZ2A', 'category': 'CombinedOpenEndedModule', 'user': 'blah' - })" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log">QA - + })" id="i4x_MITx_6_002x_combinedopenended_CombinedOE_xqa_log">QA - - - +
    + Response: + +
    @@ -89,8 +90,8 @@ Submitted Rubric
    - Scored rubric from grader 1 + Scored rubric from grader 1
    @@ -313,8 +314,7 @@ Demonstrates effective adjustment of language and tone to task and reader.
    - - +
    diff --git a/lms/static/sass/course/_rubric.scss b/lms/static/sass/course/_rubric.scss index 59aabdb917..b97eea5cd3 100644 --- a/lms/static/sass/course/_rubric.scss +++ b/lms/static/sass/course/_rubric.scss @@ -1,10 +1,16 @@ .rubric-header { - padding: $baseline/2 0; + background-color: #fafafa; + border-radius: 5px; + .rubric-collapse { - float: right; + margin-right: $baseline/2; } } +.button { + display: inline-block; +} + .rubric { margin: 0; color: #3C3C3C; diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 7d46df806e..84ac00fab3 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -37,12 +37,18 @@
    ${item['content'] | n}
    % endfor - - +
    + Response: + +
    +
    + + +
    diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 5dc79ed5d7..99ea6dc46d 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -11,7 +11,9 @@ Submitted Rubric -
    +
    + Assessment Tools: + % if len(results)>1: Previous % endif @@ -19,7 +21,6 @@ % if len(results)>1: Next % endif -
    ${result['result'] | n}
    @@ -29,8 +30,4 @@ %endif % endfor -% if len(results)>1: - Previous - Next -% endif diff --git a/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html b/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html index 99782e3185..543b8899f5 100644 --- a/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html +++ b/lms/templates/combinedopenended/selfassessment/self_assessment_prompt.html @@ -9,8 +9,6 @@
    Response
    - -

    ${_("Response")}

    From 36fda350408e1d2719b4e2322af654c56e8d26c6 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 8 Aug 2013 23:27:48 -0400 Subject: [PATCH 086/395] do portable link rewriting on import and add test to confirm it --- .../contentstore/tests/test_contentstore.py | 11 ++ .../xmodule/modulestore/store_utilities.py | 23 ++++ .../xmodule/modulestore/xml_importer.py | 121 ++++++++++-------- common/test/data/toy/course/2012_Fall.xml | 1 + common/test/data/toy/html/nonportable.html | 1 + common/test/data/toy/html/nonportable.xml | 1 + 6 files changed, 104 insertions(+), 54 deletions(-) create mode 100644 common/test/data/toy/html/nonportable.html create mode 100644 common/test/data/toy/html/nonportable.xml diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 23135964a9..f6dd5a24b7 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -720,6 +720,17 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get('http://localhost:8001/c4x/CDX/123123/asset/&images_circuits_Lab7Solution2.png') self.assertEqual(resp.status_code, 400) + def test_rewrite_nonportable_links_on_import(self): + module_store = modulestore('direct') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy'], static_content_store=content_store) + + html_module_location = Location(['i4x', 'edX', 'toy', 'html', 'nonportable']) + html_module = module_store.get_instance('edX/toy/2012_Fall', html_module_location) + + self.assertIn('/static/foo.jpg', html_module.data) + def test_delete_course(self): """ This test will import a course, make a draft item, and delete it. This will also assert that the diff --git a/common/lib/xmodule/xmodule/modulestore/store_utilities.py b/common/lib/xmodule/xmodule/modulestore/store_utilities.py index cfe0a0a6c5..bd871ad9d0 100644 --- a/common/lib/xmodule/xmodule/modulestore/store_utilities.py +++ b/common/lib/xmodule/xmodule/modulestore/store_utilities.py @@ -1,11 +1,34 @@ +import re from xmodule.contentstore.content import StaticContent from xmodule.modulestore import Location from xmodule.modulestore.mongo import MongoModuleStore from xmodule.modulestore.inheritance import own_metadata +from static_replace import _url_replace_regex import logging +def convert_to_portable_links(source_course_id, text): + """ + Does a regex replace on non-portable links: + /c4x///asset/ -> /static/ + /jump_to/i4x:///// -> /jump_to_id/ + """ + + def portable_asset_link_subtitution(match): + quote = match.group('quote') + rest = match.group('rest') + return "".join([quote, '/static/'+rest, quote]) + + org, course, run = source_course_id.split("/") + course_location = Location(['i4x', org, course, 'course', run]) + + c4x_link_base = '{0}/'.format(StaticContent.get_base_url_path_for_course_assets(course_location)) + text = re.sub(_url_replace_regex(c4x_link_base), portable_asset_link_subtitution, text) + + return text + + def _clone_modules(modulestore, modules, dest_location): for module in modules: original_loc = Location(module.location) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 698310da87..c4edb2f6d6 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -10,6 +10,7 @@ from xmodule.modulestore import Location from xmodule.contentstore.content import StaticContent from .inheritance import own_metadata from xmodule.errortracker import make_error_tracker +from .store_utilities import convert_to_portable_links log = logging.getLogger(__name__) @@ -60,54 +61,6 @@ def import_static_content(modules, course_loc, course_data_path, static_content_ return remap_dict -def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False): - # remap module to the new namespace - if target_location_namespace is not None: - # This looks a bit wonky as we need to also change the 'name' of the imported course to be what - # the caller passed in - if module.location.category != 'course': - module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, - course=target_location_namespace.course) - else: - module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, - course=target_location_namespace.course, name=target_location_namespace.name) - - # then remap children pointers since they too will be re-namespaced - if module.has_children: - children_locs = module.children - new_locs = [] - for child in children_locs: - child_loc = Location(child) - new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, - course=target_location_namespace.course) - - new_locs.append(new_child_loc.url()) - - module.children = new_locs - - if hasattr(module, 'data'): - modulestore.update_item(module.location, module.data) - - if module.has_children: - modulestore.update_children(module.location, module.children) - - modulestore.update_metadata(module.location, own_metadata(module)) - - -def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False): - # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which - # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, - # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - - # if there is *any* tabs - then there at least needs to be some predefined ones - if module.tabs is None or len(module.tabs) == 0: - module.tabs = [{"type": "courseware"}, - {"type": "course_info", "name": "Course Info"}, - {"type": "discussion", "name": "Discussion"}, - {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge - - import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace, verbose=verbose) - - def import_from_xml(store, data_dir, course_dirs=None, default_class='xmodule.raw_module.RawDescriptor', load_error_modules=True, static_content_store=None, target_location_namespace=None, @@ -175,7 +128,7 @@ def import_from_xml(store, data_dir, course_dirs=None, {"type": "discussion", "name": "Discussion"}, {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge - import_module(module, store, course_data_path, static_content_store) + import_module(module, store, course_data_path, static_content_store, course_location) course_items.append(module) @@ -202,12 +155,12 @@ def import_from_xml(store, data_dir, course_dirs=None, if verbose: log.debug('importing module location {0}'.format(module.location)) - import_module(module, store, course_data_path, static_content_store) + import_module(module, store, course_data_path, static_content_store, course_location) # now import any 'draft' items if draft_store is not None: import_course_draft(xml_module_store, store, draft_store, course_data_path, - static_content_store, target_location_namespace if target_location_namespace is not None + static_content_store, course_location, target_location_namespace if target_location_namespace is not None else course_location) finally: @@ -220,7 +173,7 @@ def import_from_xml(store, data_dir, course_dirs=None, return xml_module_store, course_items -def import_module(module, store, course_data_path, static_content_store, allow_not_found=False): +def import_module(module, store, course_data_path, static_content_store, source_course_location, allow_not_found=False): content = {} for field in module.fields: if field.scope != Scope.content: @@ -237,6 +190,11 @@ def import_module(module, store, course_data_path, static_content_store, allow_n else: module_data = content + if isinstance(module_data, basestring): + # we want to convert all 'non-portable' links in the module_data (if it is a string) to + # portable strings (e.g. /static/) + module_data = convert_to_portable_links(source_course_location.course_id, module_data) + if allow_not_found: store.update_item(module.location, module_data, allow_not_found=allow_not_found) else: @@ -250,7 +208,7 @@ def import_module(module, store, course_data_path, static_content_store, allow_n store.update_metadata(module.location, dict(own_metadata(module))) -def import_course_draft(xml_module_store, store, draft_store, course_data_path, static_content_store, target_location_namespace): +def import_course_draft(xml_module_store, store, draft_store, course_data_path, static_content_store, source_location_namespace, target_location_namespace): ''' This will import all the content inside of the 'drafts' folder, if it exists NOTE: This is not a full course import, basically in our current application only verticals (and downwards) @@ -307,7 +265,7 @@ def import_course_draft(xml_module_store, store, draft_store, course_data_path, del module.xml_attributes['parent_sequential_url'] del module.xml_attributes['index_in_children_list'] - import_module(module, draft_store, course_data_path, static_content_store, allow_not_found=True) + import_module(module, draft_store, course_data_path, static_content_store, source_location_namespace, allow_not_found=True) for child in module.get_children(): _import_module(child) @@ -524,3 +482,58 @@ def perform_xlint(data_dir, course_dirs, print "This course can be imported successfully." return err_cnt + + +# +# UNSURE IF THIS IS UNUSED CODE - IF SO NEEDS TO BE PRUNED. TO BE INVESTIGATED. +# +def import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False): + # remap module to the new namespace + if target_location_namespace is not None: + # This looks a bit wonky as we need to also change the 'name' of the imported course to be what + # the caller passed in + if module.location.category != 'course': + module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) + else: + module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course, name=target_location_namespace.name) + + # then remap children pointers since they too will be re-namespaced + if module.has_children: + children_locs = module.children + new_locs = [] + for child in children_locs: + child_loc = Location(child) + new_child_loc = child_loc._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, + course=target_location_namespace.course) + + new_locs.append(new_child_loc.url()) + + module.children = new_locs + + if hasattr(module, 'data'): + modulestore.update_item(module.location, module.data) + + if module.has_children: + modulestore.update_children(module.location, module.children) + + modulestore.update_metadata(module.location, own_metadata(module)) + + +def import_course_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace=None, verbose=False): + # CDODGE: Is this unused code (along with import_module_from_xml)? I can't find any references to it. If so, then + # we need to delete this apparently duplicate code. + + # cdodge: more hacks (what else). Seems like we have a problem when importing a course (like 6.002) which + # does not have any tabs defined in the policy file. The import goes fine and then displays fine in LMS, + # but if someone tries to add a new tab in the CMS, then the LMS barfs because it expects that - + # if there is *any* tabs - then there at least needs to be some predefined ones + if module.tabs is None or len(module.tabs) == 0: + module.tabs = [{"type": "courseware"}, + {"type": "course_info", "name": "Course Info"}, + {"type": "discussion", "name": "Discussion"}, + {"type": "wiki", "name": "Wiki"}] # note, add 'progress' when we can support it on Edge + + import_module_from_xml(modulestore, static_content_store, course_data_path, module, target_location_namespace, verbose=verbose) + diff --git a/common/test/data/toy/course/2012_Fall.xml b/common/test/data/toy/course/2012_Fall.xml index 2fd5401c24..c2faad5727 100644 --- a/common/test/data/toy/course/2012_Fall.xml +++ b/common/test/data/toy/course/2012_Fall.xml @@ -5,6 +5,7 @@ +
    student0@test.com
    - Open Response + ${_("Open Response")}
    - Assessments: + ${_("Assessments:")}
    ${status|n} @@ -30,7 +30,7 @@
    % for item in items: @@ -38,7 +38,7 @@ % endfor
    - Response: + ${_("Response: ")}
    diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index b013586d4f..94208d43dc 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -9,13 +9,13 @@
    - Submitted Rubric + ${_("Submitted Rubric")}
    - Rubric: - + ${_("Rubric: ")} + - Scores: + ${_("Scores:")} % if len(results)>1: % endif diff --git a/lms/templates/instructor/staff_grading.html b/lms/templates/instructor/staff_grading.html index 40e80de11e..8595b0d722 100644 --- a/lms/templates/instructor/staff_grading.html +++ b/lms/templates/instructor/staff_grading.html @@ -19,75 +19,69 @@

    ${_("Staff grading")}

    - -
    -
    -
    -
    + +
    +
    -

    ${_("Instructions")}

    -
    -

    ${_("This is the list of problems that currently need to be graded in order to train the machine learning models. Each problem needs to be trained separately, and we have indicated the number of student submissions that need to be graded in order for a model to be generated. You can grade more than the minimum required number of submissions--this will improve the accuracy of machine learning, though with diminishing returns. You can see the current accuracy of machine learning while grading.")}

    -
    +

    ${_("Instructions")}

    +
    +

    ${_("This is the list of problems that currently need to be graded in order to train the machine learning models. Each problem needs to be trained separately, and we have indicated the number of student submissions that need to be graded in order for a model to be generated. You can grade more than the minimum required number of submissions--this will improve the accuracy of machine learning, though with diminishing returns. You can see the current accuracy of machine learning while grading.")}

    +
    -

    ${_("Problem List")}

    - -
    +

    ${_("Problem List")}

    + +
    -

    -
    -
    -
    -
    -
    -
    -
    +

    +

    +
    +
    +
    +
    +
    +
    +

    ${_('Prompt')} ${_("(Hide)")}

    -
    -
    +
    - +
    - +
    +
    +
    +

    ${_("Student Response")}

    +
    +
    +
    +
    +

    +

    +

    +

    +

    ${_("Written Feedback")}

    + +

    + ${_("Flag as inappropriate content for later review")} +

    +
    +
    + + +
    -
    -
    -

    ${_("Student Response")}

    -
    -
    -
    -
    -

    -

    -

    -

    -

    ${_("Written Feedback")}

    - -

    - ${_("Flag as inappropriate content for later review")} -

    -
    +
    - -
    - - -
    - -
    - -
    +
    diff --git a/lms/templates/peer_grading/peer_grading_problem.html b/lms/templates/peer_grading/peer_grading_problem.html index a46f9135a0..a0078b8868 100644 --- a/lms/templates/peer_grading/peer_grading_problem.html +++ b/lms/templates/peer_grading/peer_grading_problem.html @@ -4,102 +4,95 @@
    -
    -
    -

    ${_("Learning to Grade")}

    -
    -
    -

    ${_("Peer Grading")}

    -
    -
    - -
    -
    -
    +
    +
    +

    ${_("Learning to Grade")}

    +
    +
    +

    ${_("Peer Grading")}

    - ${_('Hide Prompt')}
    -
    -
    -
    + +
    +
    +
    +
    + ${_('Hide Prompt')} +
    +
    +
    +
    +
    +
    +
    +
    +
    +

    ${_("Student Response")}

    +
    +
    +

    +
    +
    + + +
    +
    +

    +

    +

    ${_("Written Feedback")}

    +

    ${_("Please include some written feedback as well.")}

    + +
    ${_("This submission has explicit or pornographic content : ")} + +
    +
    ${_("I do not know how to grade this question : ")} + +
    +
    +
    + +
    +
    +
    +
    + +
    +

    ${_("How did I do?")}

    +
    + +
    -
    + +
    +

    ${_("Ready to grade!")}

    +

    ${_("You have finished learning to grade, which means that you are now ready to start grading.")}

    + +
    + +
    +

    ${_("Learning to grade")}

    +

    ${_("You have not yet finished learning to grade this problem.")}

    +

    ${_("You will now be shown a series of instructor-scored essays, and will be asked to score them yourself.")}

    +

    ${_("Once you can score the essays similarly to an instructor, you will be ready to grade your peers.")}

    + +
    -
    -

    ${_("Student Response")}

    - -
    -
    -

    -
    + +
    +

    ${_("Are you sure that you want to flag this submission?")}

    +

    + ${_("You are about to flag a submission. You should only flag a submission that contains explicit or offensive content. If the submission is not addressed to the question or is incorrect, you should give it a score of zero and accompanying feedback instead of flagging it.")} +

    +
    + +
    - - -
    -
    -

    -

    -

    -

    ${_("Written Feedback")}

    -

    ${_("Please include some written feedback as well.")}

    - -
    ${_("This submission has explicit or pornographic content : ")}
    -
    ${_("I do not know how to grade this question : ")}
    -
    +
    - -
    - -
    - -
    -
    -
    - -
    - - -
    -

    ${_("How did I do?")}

    -
    -
    - -
    - - -
    -

    ${_("Ready to grade!")}

    -

    ${_("You have finished learning to grade, which means that you are now ready to start grading.")}

    - -
    - - -
    -

    ${_("Learning to grade")}

    -

    ${_("You have not yet finished learning to grade this problem.")}

    -

    ${_("You will now be shown a series of instructor-scored essays, and will be asked to score them yourself.")}

    -

    ${_("Once you can score the essays similarly to an instructor, you will be ready to grade your peers.")}

    - -
    - - -
    -

    ${_("Are you sure that you want to flag this submission?")}

    -

    - ${_("You are about to flag a submission. You should only flag a submission that contains explicit or offensive content. If the submission is not addressed to the question or is incorrect, you should give it a score of zero and accompanying feedback instead of flagging it.")} -

    -
    - - -
    -
    - - - + From 6c6ba54e84c248fca71a4b89b6c54b8881ccef8a Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 14 Aug 2013 11:57:26 -0400 Subject: [PATCH 175/395] Create Course Modes django app --- lms/djangoapps/course_modes/__init__.py | 0 lms/djangoapps/course_modes/models.py | 46 +++++++++++++++++++ lms/djangoapps/course_modes/tests.py | 61 +++++++++++++++++++++++++ lms/djangoapps/course_modes/views.py | 1 + lms/envs/common.py | 3 ++ 5 files changed, 111 insertions(+) create mode 100644 lms/djangoapps/course_modes/__init__.py create mode 100644 lms/djangoapps/course_modes/models.py create mode 100644 lms/djangoapps/course_modes/tests.py create mode 100644 lms/djangoapps/course_modes/views.py diff --git a/lms/djangoapps/course_modes/__init__.py b/lms/djangoapps/course_modes/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/course_modes/models.py b/lms/djangoapps/course_modes/models.py new file mode 100644 index 0000000000..44e096fa13 --- /dev/null +++ b/lms/djangoapps/course_modes/models.py @@ -0,0 +1,46 @@ +""" +Add and create new modes for running courses on this particular LMS +""" +from django.db import models +from collections import namedtuple +from django.utils.translation import ugettext as _ + +Mode = namedtuple('Mode', ['slug', 'name', 'min_price', 'suggested_prices']) + + +class CourseMode(models.Model): + """ + We would like to offer a course in a variety of modes. + + """ + # the course that this mode is attached to + course_id = models.CharField(max_length=255, db_index=True) + + # the reference to this mode that can be used by Enrollments to generate + # similar behavior for the same slug across courses + mode_slug = models.CharField(max_length=100) + + # The 'pretty' name that can be translated and displayed + mode_display_name = models.CharField(max_length=255) + + # minimum price in USD that we would like to charge for this mode of the course + min_price = models.IntegerField(default=0) + + # the suggested prices for this mode + suggested_prices = models.CommaSeparatedIntegerField(max_length=255, blank=True, default='') + + DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '') + + @classmethod + def modes_for_course(cls, course_id): + """ + Returns a list of the modes for a given course id + + If no modes have been set in the table, returns the default mode + """ + found_course_modes = cls.objects.filter(course_id=course_id) + modes = ([Mode(mode.mode_slug, mode.mode_display_name, mode.min_price, mode.suggested_prices) + for mode in found_course_modes]) + if not modes: + modes = [cls.DEFAULT_MODE] + return modes diff --git a/lms/djangoapps/course_modes/tests.py b/lms/djangoapps/course_modes/tests.py new file mode 100644 index 0000000000..9f19d5e5bc --- /dev/null +++ b/lms/djangoapps/course_modes/tests.py @@ -0,0 +1,61 @@ +""" +This file demonstrates writing tests using the unittest module. These will pass +when you run "manage.py test". + +Replace this with more appropriate tests for your application. +""" + +from django.test import TestCase +from course_modes.models import CourseMode, Mode + + +class CourseModeModelTest(TestCase): + """ + Tests for the CourseMode model + """ + + def setUp(self): + self.course_id = 'TestCourse' + CourseMode.objects.all().delete() + + def create_mode(self, mode_slug, mode_name, min_price=0, suggested_prices=''): + """ + Create a new course mode + """ + CourseMode.objects.get_or_create( + course_id=self.course_id, + mode_display_name=mode_name, + mode_slug=mode_slug, + min_price=min_price, + suggested_prices=suggested_prices + ) + + def test_modes_for_course_empty(self): + """ + If we can't find any modes, we should get back the default mode + """ + # shouldn't be able to find a corresponding course + modes = CourseMode.modes_for_course(self.course_id) + self.assertEqual([CourseMode.DEFAULT_MODE], modes) + + def test_nodes_for_course_single(self): + """ + Find the modes for a course with only one mode + """ + + self.create_mode('verified', 'Verified Certificate') + modes = CourseMode.modes_for_course(self.course_id) + self.assertEqual([Mode(u'verified', u'Verified Certificate', 0, '')], modes) + + def test_modes_for_course_multiple(self): + """ + Finding the modes when there's multiple modes + """ + mode1 = Mode(u'honor', u'Honor Code Certificate', 0, '') + mode2 = Mode(u'verified', u'Verified Certificate', 0, '') + set_modes = [mode1, mode2] + for mode in set_modes: + self.create_mode(mode.slug, mode.name, mode.min_price, mode.suggested_prices) + + modes = CourseMode.modes_for_course(self.course_id) + self.assertEqual(modes, set_modes) diff --git a/lms/djangoapps/course_modes/views.py b/lms/djangoapps/course_modes/views.py new file mode 100644 index 0000000000..60f00ef0ef --- /dev/null +++ b/lms/djangoapps/course_modes/views.py @@ -0,0 +1 @@ +# Create your views here. diff --git a/lms/envs/common.py b/lms/envs/common.py index 0579fc94d6..0a96efd45d 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -771,6 +771,9 @@ INSTALLED_APPS = ( # Notification preferences setting 'notification_prefs', + + # Different Course Modes + 'course_modes' ) ######################### MARKETING SITE ############################### From 22b1ef34723bff7689b174b7a87b008ca90c1b81 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 14 Aug 2013 13:26:32 -0400 Subject: [PATCH 176/395] Initial migrations for course modes --- .../course_modes/migrations/0001_initial.py | 40 +++++++++++++++++++ .../course_modes/migrations/__init__.py | 0 2 files changed, 40 insertions(+) create mode 100644 lms/djangoapps/course_modes/migrations/0001_initial.py create mode 100644 lms/djangoapps/course_modes/migrations/__init__.py diff --git a/lms/djangoapps/course_modes/migrations/0001_initial.py b/lms/djangoapps/course_modes/migrations/0001_initial.py new file mode 100644 index 0000000000..83e53769a2 --- /dev/null +++ b/lms/djangoapps/course_modes/migrations/0001_initial.py @@ -0,0 +1,40 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CourseMode' + db.create_table('course_modes_coursemode', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('mode_slug', self.gf('django.db.models.fields.CharField')(max_length=100)), + ('mode_display_name', self.gf('django.db.models.fields.CharField')(max_length=255)), + ('min_price', self.gf('django.db.models.fields.IntegerField')(default=0)), + ('suggested_prices', self.gf('django.db.models.fields.CommaSeparatedIntegerField')(default='', max_length=255, blank=True)), + )) + db.send_create_signal('course_modes', ['CourseMode']) + + + def backwards(self, orm): + # Deleting model 'CourseMode' + db.delete_table('course_modes_coursemode') + + + models = { + 'course_modes.coursemode': { + 'Meta': {'object_name': 'CourseMode'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}) + } + } + + complete_apps = ['course_modes'] \ No newline at end of file diff --git a/lms/djangoapps/course_modes/migrations/__init__.py b/lms/djangoapps/course_modes/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 From 7dc7406d3c352a4c09e7d87fec53c2a5ce704e26 Mon Sep 17 00:00:00 2001 From: John Jarvis Date: Wed, 14 Aug 2013 14:42:24 -0400 Subject: [PATCH 177/395] updating help for --course --- common/djangoapps/student/management/commands/get_grades.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/common/djangoapps/student/management/commands/get_grades.py b/common/djangoapps/student/management/commands/get_grades.py index 9aa279458e..f0d5b5ec5d 100644 --- a/common/djangoapps/student/management/commands/get_grades.py +++ b/common/djangoapps/student/management/commands/get_grades.py @@ -41,8 +41,7 @@ class Command(BaseCommand): metavar='COURSE_ID', dest='course', default=False, - help='Grade and generate certificates for a specific ' - 'course'), + help='Course ID for grade distribution'), make_option('-o', '--output', metavar='FILE', dest='output', From 7d44379c9929ef2b3e329285f2f00218a2852a23 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 14 Aug 2013 15:13:11 -0400 Subject: [PATCH 178/395] Add admin site for CourseMode --- lms/djangoapps/course_modes/admin.py | 4 ++++ 1 file changed, 4 insertions(+) create mode 100644 lms/djangoapps/course_modes/admin.py diff --git a/lms/djangoapps/course_modes/admin.py b/lms/djangoapps/course_modes/admin.py new file mode 100644 index 0000000000..58c458236a --- /dev/null +++ b/lms/djangoapps/course_modes/admin.py @@ -0,0 +1,4 @@ +from ratelimitbackend import admin +from course_modes.models import CourseMode + +admin.site.register(CourseMode) From f438922a88397d931d20c69d33c7593a78f84da4 Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Wed, 14 Aug 2013 16:28:46 -0400 Subject: [PATCH 179/395] Fix error seen in combinedopenended modules --- common/lib/xmodule/xmodule/combined_open_ended_module.py | 4 ++-- common/lib/xmodule/xmodule/fields.py | 3 +++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index e01ae49149..74082df1ce 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -8,7 +8,7 @@ from .x_module import XModule from xblock.core import Integer, Scope, String, List, Float, Boolean from xmodule.open_ended_grading_classes.combined_open_ended_modulev1 import CombinedOpenEndedV1Module, CombinedOpenEndedV1Descriptor from collections import namedtuple -from .fields import Date +from .fields import Date, Timedelta import textwrap log = logging.getLogger("mitx.courseware") @@ -229,7 +229,7 @@ class CombinedOpenEndedFields(object): default=None, scope=Scope.settings ) - graceperiod = String( + graceperiod = Timedelta( help="Amount of time after the due date that submissions will be accepted", default=None, scope=Scope.settings diff --git a/common/lib/xmodule/xmodule/fields.py b/common/lib/xmodule/xmodule/fields.py index dc2f000286..b7094203c4 100644 --- a/common/lib/xmodule/xmodule/fields.py +++ b/common/lib/xmodule/xmodule/fields.py @@ -82,6 +82,9 @@ TIMEDELTA_REGEX = re.compile(r'^((?P\d+?) day(?:s?))?(\s)?((?P\d+?) class Timedelta(ModelType): + # Timedeltas are immutable, see http://docs.python.org/2/library/datetime.html#available-types + MUTABLE = False + def from_json(self, time_str): """ time_str: A string with the following components: From 4855fd5af446b5a6a633091612d145899153bde3 Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Wed, 14 Aug 2013 17:03:21 -0400 Subject: [PATCH 180/395] turn off i18n --- cms/envs/common.py | 2 +- lms/envs/common.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index cd92f17b1d..9d246edece 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -201,7 +201,7 @@ STATICFILES_DIRS = [ TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html -USE_I18N = True +USE_I18N = False USE_L10N = True # Localization strings (e.g. django.po) are under this directory diff --git a/lms/envs/common.py b/lms/envs/common.py index 0a96efd45d..3e65c70805 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -378,7 +378,7 @@ FAVICON_PATH = 'images/favicon.ico' # Locale/Internationalization TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html -USE_I18N = True +USE_I18N = False USE_L10N = True # Localization strings (e.g. django.po) are under this directory From c5d52b8b7479b20ac2d443f22e0ce4a99866a9b8 Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Wed, 14 Aug 2013 17:03:21 -0400 Subject: [PATCH 181/395] turn off i18n --- cms/envs/common.py | 2 +- lms/envs/common.py | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cms/envs/common.py b/cms/envs/common.py index cd92f17b1d..9d246edece 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -201,7 +201,7 @@ STATICFILES_DIRS = [ TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html -USE_I18N = True +USE_I18N = False USE_L10N = True # Localization strings (e.g. django.po) are under this directory diff --git a/lms/envs/common.py b/lms/envs/common.py index 0579fc94d6..f4ecb318f6 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -378,7 +378,7 @@ FAVICON_PATH = 'images/favicon.ico' # Locale/Internationalization TIME_ZONE = 'America/New_York' # http://en.wikipedia.org/wiki/List_of_tz_zones_by_name LANGUAGE_CODE = 'en' # http://www.i18nguy.com/unicode/language-identifiers.html -USE_I18N = True +USE_I18N = False USE_L10N = True # Localization strings (e.g. django.po) are under this directory From 3ed4198204e7a062f47a25d9be915c40ecaf33fd Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 14 Aug 2013 19:10:00 -0400 Subject: [PATCH 182/395] Contentstore views pylint fixes --- cms/djangoapps/contentstore/views/access.py | 10 +- cms/djangoapps/contentstore/views/assets.py | 71 +++--- .../contentstore/views/checklist.py | 15 +- .../contentstore/views/component.py | 165 +++++++++----- cms/djangoapps/contentstore/views/course.py | 204 ++++++++++++------ 5 files changed, 305 insertions(+), 160 deletions(-) diff --git a/cms/djangoapps/contentstore/views/access.py b/cms/djangoapps/contentstore/views/access.py index 49ce0c8733..5cb6b8c6f4 100644 --- a/cms/djangoapps/contentstore/views/access.py +++ b/cms/djangoapps/contentstore/views/access.py @@ -26,12 +26,16 @@ def has_access(user, location, role=STAFF_ROLE_NAME): There is a super-admin permissions if user.is_staff is set Also, since we're unifying the user database between LMS and CAS, I'm presuming that the course instructor (formally known as admin) - will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our queries here as INSTRUCTOR - has all the rights that STAFF do + will not be in both INSTRUCTOR and STAFF groups, so we have to cascade our + queries here as INSTRUCTOR has all the rights that STAFF do ''' course_location = get_course_location_for_item(location) _has_access = is_user_in_course_group_role(user, course_location, role) # if we're not in STAFF, perhaps we're in INSTRUCTOR groups if not _has_access and role == STAFF_ROLE_NAME: - _has_access = is_user_in_course_group_role(user, course_location, INSTRUCTOR_ROLE_NAME) + _has_access = is_user_in_course_group_role( + user, + course_location, + INSTRUCTOR_ROLE_NAME + ) return _has_access diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 94bfa55b58..ede6939398 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -4,6 +4,7 @@ import os import tarfile import shutil import cgi +from functools import partial from tempfile import mkdtemp from path import path @@ -34,7 +35,8 @@ from .access import get_location_and_verify_access from util.json_request import JsonResponse -__all__ = ['asset_index', 'upload_asset', 'import_course', 'generate_export_course', 'export_course'] +__all__ = ['asset_index', 'upload_asset', 'import_course', + 'generate_export_course', 'export_course'] def assets_to_json_dict(assets): @@ -58,13 +60,14 @@ def assets_to_json_dict(assets): obj["thumbnail"] = thumbnail id_info = asset.get("_id") if id_info: - obj["id"] = "/{tag}/{org}/{course}/{revision}/{category}/{name}".format( - org=id_info.get("org", ""), - course=id_info.get("course", ""), - revision=id_info.get("revision", ""), - tag=id_info.get("tag", ""), - category=id_info.get("category", ""), - name=id_info.get("name", ""), + obj["id"] = "/{tag}/{org}/{course}/{revision}/{category}/{name}" \ + .format( + org=id_info.get("org", ""), + course=id_info.get("course", ""), + revision=id_info.get("revision", ""), + tag=id_info.get("tag", ""), + category=id_info.get("category", ""), + name=id_info.get("name", ""), ) ret.append(obj) return ret @@ -132,14 +135,14 @@ def asset_index(request, org, course, name): @login_required def upload_asset(request, org, course, coursename): ''' - This method allows for POST uploading of files into the course asset library, which will - be supported by GridFS in MongoDB. + This method allows for POST uploading of files into the course asset + library, which will be supported by GridFS in MongoDB. ''' # construct a location from the passed in path location = get_location_and_verify_access(request, org, course, coursename) - # Does the course actually exist?!? Get anything from it to prove its existance - + # Does the course actually exist?!? Get anything from it to prove its + # existence try: modulestore().get_item(location) except: @@ -150,9 +153,10 @@ def upload_asset(request, org, course, coursename): if 'file' not in request.FILES: return HttpResponseBadRequest() - # compute a 'filename' which is similar to the location formatting, we're using the 'filename' - # nomenclature since we're using a FileSystem paradigm here. We're just imposing - # the Location string formatting expectations to keep things a bit more consistent + # compute a 'filename' which is similar to the location formatting, we're + # using the 'filename' nomenclature since we're using a FileSystem paradigm + # here. We're just imposing the Location string formatting expectations to + # keep things a bit more consistent upload_file = request.FILES['file'] filename = upload_file.name mime_type = upload_file.content_type @@ -160,20 +164,25 @@ def upload_asset(request, org, course, coursename): content_loc = StaticContent.compute_location(org, course, filename) chunked = upload_file.multiple_chunks() + sc_partial = partial(StaticContent, content_loc, filename, mime_type) if chunked: - content = StaticContent(content_loc, filename, mime_type, upload_file.chunks()) + content = sc_partial(upload_file.chunks()) + temp_filepath = upload_file.temporary_file_path() else: - content = StaticContent(content_loc, filename, mime_type, upload_file.read()) + content = sc_partial(upload_file.read()) + tempfile_path = None thumbnail_content = None thumbnail_location = None # first let's see if a thumbnail can be created - (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail(content, - tempfile_path=None if not chunked else - upload_file.temporary_file_path()) + (thumbnail_content, thumbnail_location) = contentstore().generate_thumbnail( + content, + tempfile_path=tempfile_path + ) - # delete cached thumbnail even if one couldn't be created this time (else the old thumbnail will continue to show) + # delete cached thumbnail even if one couldn't be created this time (else + # the old thumbnail will continue to show) del_cached_content(thumbnail_location) # now store thumbnail location only if we could create it if thumbnail_content is not None: @@ -186,13 +195,15 @@ def upload_asset(request, org, course, coursename): # readback the saved content - we need the database timestamp readback = contentstore().find(content.location) - response_payload = {'displayname': content.name, - 'uploadDate': get_default_time_display(readback.last_modified_at), - 'url': StaticContent.get_url_path_from_location(content.location), - 'portable_url': StaticContent.get_static_path_from_location(content.location), - 'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) if thumbnail_content is not None else None, - 'msg': 'Upload completed' - } + response_payload = { + 'displayname': content.name, + 'uploadDate': get_default_time_display(readback.last_modified_at), + 'url': StaticContent.get_url_path_from_location(content.location), + 'portable_url': StaticContent.get_static_path_from_location(content.location), + 'thumb_url': StaticContent.get_url_path_from_location(thumbnail_location) + if thumbnail_content is not None else None, + 'msg': 'Upload completed' + } response = JsonResponse(response_payload) return response @@ -202,8 +213,8 @@ def upload_asset(request, org, course, coursename): @login_required def remove_asset(request, org, course, name): ''' - This method will perform a 'soft-delete' of an asset, which is basically to copy the asset from - the main GridFS collection and into a Trashcan + This method will perform a 'soft-delete' of an asset, which is basically to + copy the asset from the main GridFS collection and into a Trashcan ''' get_location_and_verify_access(request, org, course, name) diff --git a/cms/djangoapps/contentstore/views/checklist.py b/cms/djangoapps/contentstore/views/checklist.py index 74f0a33769..030aa70693 100644 --- a/cms/djangoapps/contentstore/views/checklist.py +++ b/cms/djangoapps/contentstore/views/checklist.py @@ -30,7 +30,8 @@ def get_checklists(request, org, course, name): modulestore = get_modulestore(location) course_module = modulestore.get_item(location) - # If course was created before checklists were introduced, copy them over from the template. + # If course was created before checklists were introduced, copy them over + # from the template. copied = False if not course_module.checklists: course_module.checklists = CourseDescriptor.checklists.default @@ -68,7 +69,8 @@ def update_checklist(request, org, course, name, checklist_index=None): if checklist_index is not None and 0 <= int(checklist_index) < len(course_module.checklists): index = int(checklist_index) course_module.checklists[index] = json.loads(request.body) - # seeming noop which triggers kvs to record that the metadata is not default + # seeming noop which triggers kvs to record that the metadata is + # not default course_module.checklists = course_module.checklists checklists, _ = expand_checklist_action_urls(course_module) course_module.save() @@ -76,10 +78,13 @@ def update_checklist(request, org, course, name, checklist_index=None): return JsonResponse(checklists[index]) else: return HttpResponseBadRequest( - "Could not save checklist state because the checklist index was out of range or unspecified.", - content_type="text/plain") + ( "Could not save checklist state because the checklist index " + "was out of range or unspecified."), + content_type="text/plain" + ) elif request.method == 'GET': - # In the JavaScript view initialize method, we do a fetch to get all the checklists. + # In the JavaScript view initialize method, we do a fetch to get all + # the checklists. checklists, modified = expand_checklist_action_urls(course_module) if modified: course_module.save() diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index d7b41acb24..292bc841ff 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -2,7 +2,8 @@ import json import logging from collections import defaultdict -from django.http import HttpResponse, HttpResponseBadRequest, HttpResponseForbidden +from django.http import HttpResponse, HttpResponseBadRequest, \ + HttpResponseForbidden from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods from django.core.exceptions import PermissionDenied @@ -72,10 +73,15 @@ def edit_subsection(request, location): except ItemNotFoundError: return HttpResponseBadRequest() - lms_link = get_lms_link_for_item(location, course_id=course.location.course_id) - preview_link = get_lms_link_for_item(location, course_id=course.location.course_id, preview=True) + lms_link = get_lms_link_for_item( + location, course_id=course.location.course_id + ) + preview_link = get_lms_link_for_item( + location, course_id=course.location.course_id, preview=True + ) - # make sure that location references a 'sequential', otherwise return BadRequest + # make sure that location references a 'sequential', otherwise return + # BadRequest if item.location.category != 'sequential': return HttpResponseBadRequest() @@ -83,18 +89,22 @@ def edit_subsection(request, location): # we're for now assuming a single parent if len(parent_locs) != 1: - logging.error('Multiple (or none) parents have been found for {0}'.format(location)) + logging.error( + 'Multiple (or none) parents have been found for' + location + ) # this should blow up if we don't find any parents, which would be erroneous parent = modulestore().get_item(parent_locs[0]) - # remove all metadata from the generic dictionary that is presented in a more normalized UI + # remove all metadata from the generic dictionary that is presented in a + # more normalized UI policy_metadata = dict( (field.name, field.read_from(item)) for field in item.fields - if field.name not in ['display_name', 'start', 'due', 'format'] and field.scope == Scope.settings + if field.name not in ['display_name', 'start', 'due', 'format'] + and field.scope == Scope.settings ) can_view_live = False @@ -105,19 +115,22 @@ def edit_subsection(request, location): can_view_live = True break - return render_to_response('edit_subsection.html', - {'subsection': item, - 'context_course': course, - 'new_unit_category': 'vertical', - 'lms_link': lms_link, - 'preview_link': preview_link, - 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), - 'parent_location': course.location, - 'parent_item': parent, - 'policy_metadata': policy_metadata, - 'subsection_units': subsection_units, - 'can_view_live': can_view_live - }) + return render_to_response( + 'edit_subsection.html', + { + 'subsection': item, + 'context_course': course, + 'new_unit_category': 'vertical', + 'lms_link': lms_link, + 'preview_link': preview_link, + 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), + 'parent_location': course.location, + 'parent_item': parent, + 'policy_metadata': policy_metadata, + 'subsection_units': subsection_units, + 'can_view_live': can_view_live + } + ) @login_required @@ -125,7 +138,7 @@ def edit_unit(request, location): """ Display an editing page for the specified module. - Expects a GET request with the parameter 'id'. + Expects a GET request with the parameter `id`. id: A Location URL """ @@ -141,7 +154,10 @@ def edit_unit(request, location): item = modulestore().get_item(location, depth=1) except ItemNotFoundError: return HttpResponseBadRequest() - lms_link = get_lms_link_for_item(item.location, course_id=course.location.course_id) + lms_link = get_lms_link_for_item( + item.location, + course_id=course.location.course_id + ) component_templates = defaultdict(list) for category in COMPONENT_TYPES: @@ -162,17 +178,19 @@ def edit_unit(request, location): template.get('template_id') )) - # Check if there are any advanced modules specified in the course policy. These modules - # should be specified as a list of strings, where the strings are the names of the modules - # in ADVANCED_COMPONENT_TYPES that should be enabled for the course. + # Check if there are any advanced modules specified in the course policy. + # These modules should be specified as a list of strings, where the strings + # are the names of the modules in ADVANCED_COMPONENT_TYPES that should be + # enabled for the course. course_advanced_keys = course.advanced_modules # Set component types according to course policy file if isinstance(course_advanced_keys, list): for category in course_advanced_keys: if category in ADVANCED_COMPONENT_TYPES: - # Do I need to allow for boilerplates or just defaults on the class? i.e., can an advanced - # have more than one entry in the menu? one for default and others for prefilled boilerplates? + # Do I need to allow for boilerplates or just defaults on the + # class? i.e., can an advanced have more than one entry in the + # menu? one for default and others for prefilled boilerplates? try: component_class = XModuleDescriptor.load_class(category) @@ -183,13 +201,16 @@ def edit_unit(request, location): None # don't override default data )) except PluginMissingError: - # dhm: I got this once but it can happen any time the course author configures - # an advanced component which does not exist on the server. This code here merely - # prevents any authors from trying to instantiate the non-existent component type - # by not showing it in the menu + # dhm: I got this once but it can happen any time the + # course author configures an advanced component which does + # not exist on the server. This code here merely + # prevents any authors from trying to instantiate the + # non-existent component type by not showing it in the menu pass else: - log.error("Improper format for course advanced keys! {0}".format(course_advanced_keys)) + log.error( + "Improper format for course advanced keys!" + course_advanced_keys + ) components = [ component.location.url() @@ -201,16 +222,20 @@ def edit_unit(request, location): # this will need to change to check permissions correctly so as # to pick the correct parent subsection - containing_subsection_locs = modulestore().get_parent_locations(location, None) + containing_subsection_locs = modulestore().get_parent_locations( + location, None + ) containing_subsection = modulestore().get_item(containing_subsection_locs[0]) - - containing_section_locs = modulestore().get_parent_locations(containing_subsection.location, None) + containing_section_locs = modulestore().get_parent_locations( + containing_subsection.location, None + ) containing_section = modulestore().get_item(containing_section_locs[0]) # cdodge hack. We're having trouble previewing drafts via jump_to redirect # so let's generate the link url here - # need to figure out where this item is in the list of children as the preview will need this + # need to figure out where this item is in the list of children as the + # preview will need this index = 1 for child in containing_subsection.get_children(): if child.location == item.location: @@ -219,15 +244,19 @@ def edit_unit(request, location): preview_lms_base = settings.MITX_FEATURES.get('PREVIEW_LMS_BASE') - preview_lms_link = '//{preview_lms_base}/courses/{org}/{course}/{course_name}/courseware/{section}/{subsection}/{index}'.format( - preview_lms_base=preview_lms_base, - lms_base=settings.LMS_BASE, - org=course.location.org, - course=course.location.course, - course_name=course.location.name, - section=containing_section.location.name, - subsection=containing_subsection.location.name, - index=index) + preview_lms_link = ( + '//{preview_lms_base}/courses/{org}/{course}/' + '{course_name}/courseware/{section}/{subsection}/{index}' + ).format( + preview_lms_base=preview_lms_base, + lms_base=settings.LMS_BASE, + org=course.location.org, + course=course.location.course, + course_name=course.location.name, + section=containing_section.location.name, + subsection=containing_subsection.location.name, + index=index + ) unit_state = compute_unit_state(item) @@ -240,11 +269,13 @@ def edit_unit(request, location): 'draft_preview_link': preview_lms_link, 'published_preview_link': lms_link, 'subsection': containing_subsection, - 'release_date': get_default_time_display(containing_subsection.lms.start) if containing_subsection.lms.start is not None else None, + 'release_date': get_default_time_display(containing_subsection.lms.start) + if containing_subsection.lms.start is not None else None, 'section': containing_section, 'new_unit_category': 'vertical', 'unit_state': unit_state, - 'published_date': get_default_time_display(item.cms.published_date) if item.cms.published_date is not None else None + 'published_date': get_default_time_display(item.cms.published_date) + if item.cms.published_date is not None else None }) @@ -253,9 +284,10 @@ def edit_unit(request, location): @require_http_methods(("GET", "POST", "PUT")) @ensure_csrf_cookie def assignment_type_update(request, org, course, category, name): - ''' - CRUD operations on assignment types for sections and subsections and anything else gradable. - ''' + """ + CRUD operations on assignment types for sections and subsections and + anything else gradable. + """ location = Location(['i4x', org, course, category, name]) if not has_access(request.user, location): return HttpResponseForbidden() @@ -263,7 +295,9 @@ def assignment_type_update(request, org, course, category, name): if request.method == 'GET': return JsonResponse(CourseGradingModel.get_section_grader_type(location)) elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. - return JsonResponse(CourseGradingModel.update_section_grader_type(location, request.POST)) + return JsonResponse(CourseGradingModel.update_section_grader_type( + location, request.POST + )) @login_required @@ -276,8 +310,8 @@ def create_draft(request): if not has_access(request.user, location): raise PermissionDenied() - # This clones the existing item location to a draft location (the draft is implicit, - # because modulestore is a Draft modulestore) + # This clones the existing item location to a draft location (the draft is + # implicit, because modulestore is a Draft modulestore) modulestore().convert_to_draft(location) return HttpResponse() @@ -286,7 +320,9 @@ def create_draft(request): @login_required @expect_json def publish_draft(request): - "Publish a draft" + """ + Publish a draft + """ location = request.POST['id'] # check permissions for this user within this course @@ -294,7 +330,10 @@ def publish_draft(request): raise PermissionDenied() item = modulestore().get_item(location) - _xmodule_recurse(item, lambda i: modulestore().publish(i.location, request.user.id)) + _xmodule_recurse( + item, + lambda i: modulestore().publish(i.location, request.user.id) + ) return HttpResponse() @@ -328,13 +367,23 @@ def module_info(request, module_location): raise PermissionDenied() rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true'] - logging.debug('rewrite_static_links = {0} {1}'.format(request.GET.get('rewrite_url_links', 'False'), rewrite_static_links)) + logging.debug('rewrite_static_links = {0} {1}'.format( + request.GET.get('rewrite_url_links', 'False'), + rewrite_static_links) + ) # check that logged in user has permissions to this item if not has_access(request.user, location): raise PermissionDenied() if request.method == 'GET': - return JsonResponse(get_module_info(get_modulestore(location), location, rewrite_static_links=rewrite_static_links)) + return JsonResponse(get_module_info( + get_modulestore(location), + location, + rewrite_static_links=rewrite_static_links + )) elif request.method in ("POST", "PUT"): - return JsonResponse(set_module_info(get_modulestore(location), location, request.POST)) + return JsonResponse(set_module_info( + get_modulestore(location), + location, request.POST + )) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index a6b1b29aab..b47f8e9ffb 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -82,7 +82,9 @@ def course_index(request, org, course, name): 'context_course': course, 'lms_link': lms_link, 'sections': sections, - 'course_graders': json.dumps(CourseGradingModel.fetch(course.location).graders), + 'course_graders': json.dumps( + CourseGradingModel.fetch(course.location).graders + ), 'parent_location': course.location, 'new_section_category': 'chapter', 'new_subsection_category': 'sequential', @@ -120,24 +122,31 @@ def create_new_course(request): except ItemNotFoundError: pass if existing_course is not None: - return JsonResponse( - { - 'ErrMsg': _('There is already a course defined with the same organization, course number, and course run. Please change either organization or course number to be unique.'), - 'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'), - 'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'), - } - ) + return JsonResponse({ + 'ErrMsg': _(('There is already a course defined with the same ' + 'organization, course number, and course run. Please ' + 'change either organization or course number to be ' + 'unique.')), + 'OrgErrMsg': _(('Please change either the organization or ' + 'course number so that it is unique.')), + 'CourseErrMsg': _(('Please change either the organization or ' + 'course number so that it is unique.')), + }) - course_search_location = ['i4x', dest_location.org, dest_location.course, 'course', None] + course_search_location = ['i4x', dest_location.org, dest_location.course, + 'course', None + ] courses = modulestore().get_items(course_search_location) if len(courses) > 0: - return JsonResponse( - { - 'ErrMsg': _('There is already a course defined with the same organization and course number. Please change at least one field to be unique.'), - 'OrgErrMsg': _('Please change either the organization or course number so that it is unique.'), - 'CourseErrMsg': _('Please change either the organization or course number so that it is unique.'), - } - ) + return JsonResponse({ + 'ErrMsg': _(('There is already a course defined with the same ' + 'organization and course number. Please ' + 'change at least one field to be unique.')), + 'OrgErrMsg': _(('Please change either the organization or ' + 'course number so that it is unique.')), + 'CourseErrMsg': _(('Please change either the organization or ' + 'course number so that it is unique.')), + }) # instantiate the CourseDescriptor and then persist it # note: no system to pass @@ -145,11 +154,17 @@ def create_new_course(request): metadata = {} else: metadata = {'display_name': display_name} - modulestore('direct').create_and_save_xmodule(dest_location, metadata=metadata) + modulestore('direct').create_and_save_xmodule( + dest_location, + metadata=metadata + ) new_course = modulestore('direct').get_item(dest_location) # clone a default 'about' overview module as well - dest_about_location = dest_location.replace(category='about', name='overview') + dest_about_location = dest_location.replace( + category='about', + name='overview' + ) overview_template = AboutDescriptor.get_template('overview.yaml') modulestore('direct').create_and_save_xmodule( dest_about_location, @@ -164,7 +179,8 @@ def create_new_course(request): # seed the forums seed_permissions_roles(new_course.location.course_id) - # auto-enroll the course creator in the course so that "View Live" will work. + # auto-enroll the course creator in the course so that "View Live" will + # work. CourseEnrollment.enroll(request.user, new_course.location.course_id) return JsonResponse({'id': new_course.location.url()}) @@ -174,7 +190,8 @@ def create_new_course(request): @ensure_csrf_cookie def course_info(request, org, course, name, provided_id=None): """ - Send models and views as well as html for editing the course info to the client. + Send models and views as well as html for editing the course info to the + client. org, course, name: Attributes of the Location for the item to edit """ @@ -189,7 +206,8 @@ def course_info(request, org, course, name, provided_id=None): 'context_course': course_module, 'url_base': "/" + org + "/" + course + "/", 'course_updates': json.dumps(get_course_updates(location)), - 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() + 'handouts_location': Location(['i4x', org, course, 'course_info', + 'handouts']).url() }) @@ -202,14 +220,16 @@ def course_info_updates(request, org, course, provided_id=None): restful CRUD operations on course_info updates. org, course: Attributes of the Location for the item to edit - provided_id should be none if it's new (create) and a composite of the update db id + index otherwise. + provided_id should be none if it's new (create) and a composite of the + update db id + index otherwise. """ # ??? No way to check for access permission afaik # get current updates location = ['i4x', org, course, 'course_info', "updates"] - # Hmmm, provided_id is coming as empty string on create whereas I believe it used to be None :-( - # Possibly due to my removing the seemingly redundant pattern in urls.py + # Hmmm, provided_id is coming as empty string on create whereas I believe + # it used to be None :-( Possibly due to my removing the seemingly + # redundant pattern in urls.py if provided_id == '': provided_id = None @@ -221,13 +241,19 @@ def course_info_updates(request, org, course, provided_id=None): return JsonResponse(get_course_updates(location)) elif request.method == 'DELETE': try: - return JsonResponse(delete_course_update(location, request.POST, provided_id)) + return JsonResponse(delete_course_update(location, request.POST, + provided_id + )) except: return HttpResponseBadRequest("Failed to delete", content_type="text/plain") - elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other + elif request.method in ('POST', 'PUT'): # can be either and sometimes + # django is rewriting one to the + # other try: - return JsonResponse(update_course_updates(location, request.POST, provided_id)) + return JsonResponse(update_course_updates(location, request.POST, + provided_id + )) except: return HttpResponseBadRequest("Failed to save", content_type="text/plain") @@ -237,7 +263,8 @@ def course_info_updates(request, org, course, provided_id=None): @ensure_csrf_cookie def get_course_settings(request, org, course, name): """ - Send models and views as well as html for editing the course settings to the client. + Send models and views as well as html for editing the course settings to + the client. org, course, name: Attributes of the Location for the item to edit """ @@ -253,7 +280,9 @@ def get_course_settings(request, org, course, name): "course": course, "name": name, "section": "details"}), - 'about_page_editable': not settings.MITX_FEATURES.get('ENABLE_MKTG_SITE', False) + 'about_page_editable': not settings.MITX_FEATURES.get( + 'ENABLE_MKTG_SITE', False + ) }) @@ -261,7 +290,8 @@ def get_course_settings(request, org, course, name): @ensure_csrf_cookie def course_config_graders_page(request, org, course, name): """ - Send models and views as well as html for editing the course settings to the client. + Send models and views as well as html for editing the course settings to + the client. org, course, name: Attributes of the Location for the item to edit """ @@ -281,7 +311,8 @@ def course_config_graders_page(request, org, course, name): @ensure_csrf_cookie def course_config_advanced_page(request, org, course, name): """ - Send models and views as well as html for editing the advanced course settings to the client. + Send models and views as well as html for editing the advanced course + settings to the client. org, course, name: Attributes of the Location for the item to edit """ @@ -301,8 +332,9 @@ def course_config_advanced_page(request, org, course, name): @ensure_csrf_cookie def course_settings_updates(request, org, course, name, section): """ - restful CRUD operations on course settings. This differs from get_course_settings by communicating purely - through json (not rendering any html) and handles section level operations rather than whole page. + Restful CRUD operations on course settings. This differs from + get_course_settings by communicating purely through json (not rendering any + html) and handles section level operations rather than whole page. org, course: Attributes of the Location for the item to edit section: one of details, faculty, grading, problems, discussions @@ -318,9 +350,15 @@ def course_settings_updates(request, org, course, name, section): if request.method == 'GET': # Cannot just do a get w/o knowing the course name :-( - return JsonResponse(manager.fetch(Location(['i4x', org, course, 'course', name])), encoder=CourseSettingsEncoder) + return JsonResponse( + manager.fetch(Location(['i4x', org, course, 'course', name])), + encoder=CourseSettingsEncoder + ) elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. - return JsonResponse(manager.update_from_json(request.POST), encoder=CourseSettingsEncoder) + return JsonResponse( + manager.update_from_json(request.POST), + encoder=CourseSettingsEncoder + ) @expect_json @@ -329,8 +367,9 @@ def course_settings_updates(request, org, course, name, section): @ensure_csrf_cookie def course_grader_updates(request, org, course, name, grader_index=None): """ - restful CRUD operations on course_info updates. This differs from get_course_settings by communicating purely - through json (not rendering any html) and handles section level operations rather than whole page. + Restful CRUD operations on course_info updates. This differs from + get_course_settings by communicating purely through json (not rendering any + html) and handles section level operations rather than whole page. org, course: Attributes of the Location for the item to edit """ @@ -339,13 +378,18 @@ def course_grader_updates(request, org, course, name, grader_index=None): if request.method == 'GET': # Cannot just do a get w/o knowing the course name :-( - return JsonResponse(CourseGradingModel.fetch_grader(Location(location), grader_index)) + return JsonResponse(CourseGradingModel.fetch_grader( + Location(location), grader_index + )) elif request.method == "DELETE": # ??? Should this return anything? Perhaps success fail? CourseGradingModel.delete_grader(Location(location), grader_index) return JsonResponse() else: # post or put, doesn't matter. - return JsonResponse(CourseGradingModel.update_grader_from_json(Location(location), request.POST)) + return JsonResponse(CourseGradingModel.update_grader_from_json( + Location(location), + request.POST + )) # # NB: expect_json failed on ["key", "key2"] and json payload @@ -354,8 +398,9 @@ def course_grader_updates(request, org, course, name, grader_index=None): @ensure_csrf_cookie def course_advanced_updates(request, org, course, name): """ - restful CRUD operations on metadata. The payload is a json rep of the metadata dicts. For delete, otoh, - the payload is either a key or a list of keys to delete. + Restful CRUD operations on metadata. The payload is a json rep of the + metadata dicts. For delete, otoh, the payload is either a key or a list of + keys to delete. org, course: Attributes of the Location for the item to edit """ @@ -364,20 +409,26 @@ def course_advanced_updates(request, org, course, name): if request.method == 'GET': return JsonResponse(CourseMetadata.fetch(location)) elif request.method == 'DELETE': - return JsonResponse(CourseMetadata.delete_key(location, json.loads(request.body))) + return JsonResponse(CourseMetadata.delete_key( + location, + json.loads(request.body) + )) else: # NOTE: request.POST is messed up because expect_json - # cloned_request.POST.copy() is creating a defective entry w/ the whole payload as the key + # cloned_request.POST.copy() is creating a defective entry w/ the whole + # payload as the key request_body = json.loads(request.body) # Whether or not to filter the tabs key out of the settings metadata filter_tabs = True - # Check to see if the user instantiated any advanced components. This is a hack - # that does the following : - # 1) adds/removes the open ended panel tab to a course automatically if the user - # has indicated that they want to edit the combinedopendended or peergrading module - # 2) adds/removes the notes panel tab to a course automatically if the user has - # indicated that they want the notes module enabled in their course + # Check to see if the user instantiated any advanced components. This + # is a hack that does the following : + # 1) adds/removes the open ended panel tab to a course automatically + # if the user has indicated that they want to edit the + # combinedopendended or peergrading module + # 2) adds/removes the notes panel tab to a course automatically if + # the user has indicated that they want the notes module enabled in + # their course # TODO refactor the above into distinct advanced policy settings if ADVANCED_COMPONENT_POLICY_KEY in request_body: # Get the course so that we can scrape current tabs @@ -389,19 +440,25 @@ def course_advanced_updates(request, org, course, name): 'notes': NOTE_COMPONENT_TYPES, } - # Check to see if the user instantiated any notes or open ended components + # Check to see if the user instantiated any notes or open ended + # components for tab_type in tab_component_map.keys(): component_types = tab_component_map.get(tab_type) found_ac_type = False for ac_type in component_types: if ac_type in request_body[ADVANCED_COMPONENT_POLICY_KEY]: # Add tab to the course if needed - changed, new_tabs = add_extra_panel_tab(tab_type, course_module) - # If a tab has been added to the course, then send the metadata along to CourseMetadata.update_from_json + changed, new_tabs = add_extra_panel_tab( + tab_type, + course_module + ) + # If a tab has been added to the course, then send the + # metadata along to CourseMetadata.update_from_json if changed: course_module.tabs = new_tabs request_body.update({'tabs': new_tabs}) - # Indicate that tabs should not be filtered out of the metadata + # Indicate that tabs should not be filtered out of + # the metadata filter_tabs = False # Set this flag to avoid the tab removal code below. found_ac_type = True @@ -410,18 +467,26 @@ def course_advanced_updates(request, org, course, name): # we may need to remove the tab from the course. if not found_ac_type: # Remove tab from the course if needed - changed, new_tabs = remove_extra_panel_tab(tab_type, course_module) + changed, new_tabs = remove_extra_panel_tab( + tab_type, course_module + ) if changed: course_module.tabs = new_tabs request_body.update({'tabs': new_tabs}) - # Indicate that tabs should *not* be filtered out of the metadata + # Indicate that tabs should *not* be filtered out of + # the metadata filter_tabs = False try: - return JsonResponse(CourseMetadata.update_from_json(location, - request_body, - filter_tabs=filter_tabs)) + return JsonResponse(CourseMetadata.update_from_json( + location, + request_body, + filter_tabs=filter_tabs + )) except (TypeError, ValueError) as err: - return HttpResponseBadRequest("Incorrect setting format. " + str(err), content_type="text/plain") + return HttpResponseBadRequest( + "Incorrect setting format. " + str(err), + content_type="text/plain" + ) class TextbookValidationError(Exception): @@ -498,7 +563,8 @@ def textbook_index(request, org, course, name): if request.is_ajax(): if request.method == 'GET': return JsonResponse(course_module.pdf_textbooks) - elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other + elif request.method in ('POST', 'PUT'): # can be either and sometimes + # django is rewriting one to the other try: textbooks = validate_textbooks_json(request.body) except TextbookValidationError as err: @@ -517,7 +583,10 @@ def textbook_index(request, org, course, name): # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. course_module.save() - store.update_metadata(course_module.location, own_metadata(course_module)) + store.update_metadata( + course_module.location, + own_metadata(course_module) + ) return JsonResponse(course_module.pdf_textbooks) else: upload_asset_url = reverse('upload_asset', kwargs={ @@ -599,7 +668,8 @@ def textbook_by_id(request, org, course, name, tid): if not textbook: return JsonResponse(status=404) return JsonResponse(textbook) - elif request.method in ('POST', 'PUT'): # can be either and sometimes django is rewriting one to the other + elif request.method in ('POST', 'PUT'): # can be either and sometimes + # django is rewriting one to the other try: new_textbook = validate_textbook_json(request.body) except TextbookValidationError as err: @@ -616,7 +686,10 @@ def textbook_by_id(request, org, course, name, tid): # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. course_module.save() - store.update_metadata(course_module.location, own_metadata(course_module)) + store.update_metadata( + course_module.location, + own_metadata(course_module) + ) return JsonResponse(new_textbook, status=201) elif request.method == 'DELETE': if not textbook: @@ -626,5 +699,8 @@ def textbook_by_id(request, org, course, name, tid): new_textbooks.extend(course_module.pdf_textbooks[i + 1:]) course_module.pdf_textbooks = new_textbooks course_module.save() - store.update_metadata(course_module.location, own_metadata(course_module)) + store.update_metadata( + course_module.location, + own_metadata(course_module) + ) return JsonResponse() From 501c089f419a2b879cf75ac7d64692f5d1388357 Mon Sep 17 00:00:00 2001 From: ichuang Date: Wed, 14 Aug 2013 21:51:40 -0400 Subject: [PATCH 183/395] capa optioninput template was missing msg output (needed for hints) --- common/lib/capa/capa/templates/optioninput.html | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/common/lib/capa/capa/templates/optioninput.html b/common/lib/capa/capa/templates/optioninput.html index 9205fe7f3f..81ba43b71c 100644 --- a/common/lib/capa/capa/templates/optioninput.html +++ b/common/lib/capa/capa/templates/optioninput.html @@ -29,4 +29,9 @@ Status: incomplete % endif + + % if msg: + ${msg|n} + % endif + From 5f3dd37f98634c6046f6331cb59e758aa0957861 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Wed, 14 Aug 2013 16:17:40 -0400 Subject: [PATCH 184/395] Move the silencing of numpy's warnings into test_calc.py ..because that is where it is the most annoying/visible. Otherwise it really has no effect on the LMS or anything else. --- common/lib/calc/calc.py | 5 ----- common/lib/calc/tests/test_calc.py | 21 +++++++++++++-------- 2 files changed, 13 insertions(+), 13 deletions(-) diff --git a/common/lib/calc/calc.py b/common/lib/calc/calc.py index f2a68988ae..ab300f121b 100644 --- a/common/lib/calc/calc.py +++ b/common/lib/calc/calc.py @@ -11,11 +11,6 @@ import numpy import scipy.constants import calcfunctions -# Have numpy ignore errors on functions outside its domain. -# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html -# TODO worry about thread safety/changing a global setting -numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise' - from pyparsing import ( Word, Literal, CaselessLiteral, ZeroOrMore, MatchFirst, Optional, Forward, Group, ParseResults, stringEnd, Suppress, Combine, alphas, nums, alphanums diff --git a/common/lib/calc/tests/test_calc.py b/common/lib/calc/tests/test_calc.py index 48ac7b88c1..3b8981f5c3 100644 --- a/common/lib/calc/tests/test_calc.py +++ b/common/lib/calc/tests/test_calc.py @@ -7,6 +7,12 @@ import numpy import calc from pyparsing import ParseException +# numpy's default behavior when it evaluates a function outside its domain +# is to raise a warning (not an exception) which is then printed to STDOUT. +# To prevent this from polluting the output of the tests, configure numpy to +# ignore it instead. +# See http://docs.scipy.org/doc/numpy/reference/generated/numpy.seterr.html +numpy.seterr(all='ignore') # Also: 'ignore', 'warn' (default), 'raise' class EvaluatorTest(unittest.TestCase): """ @@ -186,17 +192,16 @@ class EvaluatorTest(unittest.TestCase): arcsin_inputs = ['-0.707', '0', '0.5', '0.588', '1.298 + 0.635*j'] arcsin_angles = [-0.785, 0, 0.524, 0.629, 1 + 1j] self.assert_function_values('arcsin', arcsin_inputs, arcsin_angles) - # Rather than throwing an exception, numpy.arcsin gives nan - # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(-1.1)'))) - # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(1.1)'))) - # Disabled for now because they are giving a runtime warning... :-/ + # Rather than a complex number, numpy.arcsin gives nan + self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(-1.1)'))) + self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arcsin(1.1)'))) # Include those where the real part is between 0 and pi arccos_inputs = ['1', '0.866', '0.809', '0.834-0.989*j'] arccos_angles = [0, 0.524, 0.628, 1 + 1j] self.assert_function_values('arccos', arccos_inputs, arccos_angles) - # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(-1.1)'))) - # self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(1.1)'))) + self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(-1.1)'))) + self.assertTrue(numpy.isnan(calc.evaluator({}, {}, 'arccos(1.1)'))) # Has the same range as arcsin arctan_inputs = ['-1', '0', '0.577', '0.727', '0.272 + 1.084*j'] @@ -535,10 +540,10 @@ class EvaluatorTest(unittest.TestCase): # With case sensitive turned on, it should pick the right function functions = {'f': lambda x: x, 'F': lambda x: x + 1} self.assertEqual( - calc.evaluator({}, functions, 'f(6)', case_sensitive=True), 6 + 6, calc.evaluator({}, functions, 'f(6)', case_sensitive=True) ) self.assertEqual( - calc.evaluator({}, functions, 'F(6)', case_sensitive=True), 7 + 7, calc.evaluator({}, functions, 'F(6)', case_sensitive=True) ) def test_undefined_vars(self): From 80619da4290ec933b10cc1a8eb7d0068169d8950 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Thu, 15 Aug 2013 10:21:32 -0400 Subject: [PATCH 185/395] Review fixes --- .../contentstore/views/component.py | 35 ++++++----- cms/djangoapps/contentstore/views/course.py | 60 +++++++++---------- 2 files changed, 47 insertions(+), 48 deletions(-) diff --git a/cms/djangoapps/contentstore/views/component.py b/cms/djangoapps/contentstore/views/component.py index 292bc841ff..a5fec7c033 100644 --- a/cms/djangoapps/contentstore/views/component.py +++ b/cms/djangoapps/contentstore/views/component.py @@ -2,14 +2,15 @@ import json import logging from collections import defaultdict -from django.http import HttpResponse, HttpResponseBadRequest, \ - HttpResponseForbidden +from django.http import ( HttpResponse, HttpResponseBadRequest, + HttpResponseForbidden ) from django.contrib.auth.decorators import login_required from django.views.decorators.http import require_http_methods from django.core.exceptions import PermissionDenied from django_future.csrf import ensure_csrf_cookie from django.conf import settings -from xmodule.modulestore.exceptions import ItemNotFoundError, InvalidLocationError +from xmodule.modulestore.exceptions import ( ItemNotFoundError, + InvalidLocationError ) from mitxmako.shortcuts import render_to_response from xmodule.modulestore import Location @@ -20,8 +21,8 @@ from xblock.core import Scope from util.json_request import expect_json, JsonResponse from contentstore.module_info_model import get_module_info, set_module_info -from contentstore.utils import get_modulestore, get_lms_link_for_item, \ - compute_unit_state, UnitState, get_course_for_item +from contentstore.utils import ( get_modulestore, get_lms_link_for_item, + compute_unit_state, UnitState, get_course_for_item ) from models.settings.course_grading import CourseGradingModel @@ -90,7 +91,8 @@ def edit_subsection(request, location): # we're for now assuming a single parent if len(parent_locs) != 1: logging.error( - 'Multiple (or none) parents have been found for' + location + 'Multiple (or none) parents have been found for %', + location ) # this should blow up if we don't find any parents, which would be erroneous @@ -209,7 +211,8 @@ def edit_unit(request, location): pass else: log.error( - "Improper format for course advanced keys!" + course_advanced_keys + "Improper format for course advanced keys! %", + course_advanced_keys ) components = [ @@ -293,11 +296,12 @@ def assignment_type_update(request, org, course, category, name): return HttpResponseForbidden() if request.method == 'GET': - return JsonResponse(CourseGradingModel.get_section_grader_type(location)) + rsp = CourseGradingModel.get_section_grader_type(location) elif request.method in ('POST', 'PUT'): # post or put, doesn't matter. - return JsonResponse(CourseGradingModel.update_section_grader_type( + rsp = CourseGradingModel.update_section_grader_type( location, request.POST - )) + ) + return JsonResponse(rsp) @login_required @@ -368,7 +372,7 @@ def module_info(request, module_location): rewrite_static_links = request.GET.get('rewrite_url_links', 'True') in ['True', 'true'] logging.debug('rewrite_static_links = {0} {1}'.format( - request.GET.get('rewrite_url_links', 'False'), + request.GET.get('rewrite_url_links', False), rewrite_static_links) ) @@ -377,13 +381,14 @@ def module_info(request, module_location): raise PermissionDenied() if request.method == 'GET': - return JsonResponse(get_module_info( + rsp = get_module_info( get_modulestore(location), location, rewrite_static_links=rewrite_static_links - )) + ) elif request.method in ("POST", "PUT"): - return JsonResponse(set_module_info( + rsp = set_module_info( get_modulestore(location), location, request.POST - )) + ) + return JsonResponse(rsp) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index b47f8e9ffb..753df66fe0 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -123,14 +123,14 @@ def create_new_course(request): pass if existing_course is not None: return JsonResponse({ - 'ErrMsg': _(('There is already a course defined with the same ' + 'ErrMsg': _('There is already a course defined with the same ' 'organization, course number, and course run. Please ' 'change either organization or course number to be ' - 'unique.')), - 'OrgErrMsg': _(('Please change either the organization or ' - 'course number so that it is unique.')), - 'CourseErrMsg': _(('Please change either the organization or ' - 'course number so that it is unique.')), + 'unique.'), + 'OrgErrMsg': _('Please change either the organization or ' + 'course number so that it is unique.'), + 'CourseErrMsg': _('Please change either the organization or ' + 'course number so that it is unique.'), }) course_search_location = ['i4x', dest_location.org, dest_location.course, @@ -139,13 +139,13 @@ def create_new_course(request): courses = modulestore().get_items(course_search_location) if len(courses) > 0: return JsonResponse({ - 'ErrMsg': _(('There is already a course defined with the same ' + 'ErrMsg': _('There is already a course defined with the same ' 'organization and course number. Please ' - 'change at least one field to be unique.')), - 'OrgErrMsg': _(('Please change either the organization or ' - 'course number so that it is unique.')), - 'CourseErrMsg': _(('Please change either the organization or ' - 'course number so that it is unique.')), + 'change at least one field to be unique.'), + 'OrgErrMsg': _('Please change either the organization or ' + 'course number so that it is unique.'), + 'CourseErrMsg': _('Please change either the organization or ' + 'course number so that it is unique.'), }) # instantiate the CourseDescriptor and then persist it @@ -206,9 +206,7 @@ def course_info(request, org, course, name, provided_id=None): 'context_course': course_module, 'url_base': "/" + org + "/" + course + "/", 'course_updates': json.dumps(get_course_updates(location)), - 'handouts_location': Location(['i4x', org, course, 'course_info', - 'handouts']).url() - }) + 'handouts_location': Location(['i4x', org, course, 'course_info', 'handouts']).url() }) @expect_json @@ -227,9 +225,6 @@ def course_info_updates(request, org, course, provided_id=None): # get current updates location = ['i4x', org, course, 'course_info', "updates"] - # Hmmm, provided_id is coming as empty string on create whereas I believe - # it used to be None :-( Possibly due to my removing the seemingly - # redundant pattern in urls.py if provided_id == '': provided_id = None @@ -241,22 +236,21 @@ def course_info_updates(request, org, course, provided_id=None): return JsonResponse(get_course_updates(location)) elif request.method == 'DELETE': try: - return JsonResponse(delete_course_update(location, request.POST, - provided_id - )) + return JsonResponse(delete_course_update(location, request.POST, provided_id)) except: - return HttpResponseBadRequest("Failed to delete", - content_type="text/plain") - elif request.method in ('POST', 'PUT'): # can be either and sometimes - # django is rewriting one to the - # other + return HttpResponseBadRequest( + "Failed to delete", + content_type="text/plain" + ) + # can be either and sometimes django is rewriting one to the other: + elif request.method in ('POST', 'PUT'): try: - return JsonResponse(update_course_updates(location, request.POST, - provided_id - )) + return JsonResponse(update_course_updates(location, request.POST, provided_id)) except: - return HttpResponseBadRequest("Failed to save", - content_type="text/plain") + return HttpResponseBadRequest( + "Failed to save", + content_type="text/plain" + ) @login_required @@ -563,8 +557,8 @@ def textbook_index(request, org, course, name): if request.is_ajax(): if request.method == 'GET': return JsonResponse(course_module.pdf_textbooks) - elif request.method in ('POST', 'PUT'): # can be either and sometimes - # django is rewriting one to the other + # can be either and sometimes django is rewriting one to the other: + elif request.method in ('POST', 'PUT'): try: textbooks = validate_textbooks_json(request.body) except TextbookValidationError as err: From 6f657c1124190ab388f24f93b156d6b217344007 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 10:30:16 -0400 Subject: [PATCH 186/395] Import to fix internationalization --- lms/templates/combinedopenended/combined_open_ended_results.html | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 94208d43dc..3fa4d32e8d 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -1,3 +1,4 @@ +<%! from django.utils.translation import ugettext as _ %> % for (i,result) in enumerate(results): % if 'task_name' in result and 'result' in result:
    Date: Wed, 14 Aug 2013 17:37:10 -0400 Subject: [PATCH 187/395] Add test for invalid YouTube IDs strings --- .../lib/xmodule/xmodule/tests/test_video.py | 26 +++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py index 4a13d565cc..1ea4b4e187 100644 --- a/common/lib/xmodule/xmodule/tests/test_video.py +++ b/common/lib/xmodule/xmodule/tests/test_video.py @@ -64,6 +64,32 @@ class VideoModuleTest(LogicTest): '1.25': '', '1.50': ''}) + def test_parse_youtube_invalid(self): + """Ensure that ids that are invalid return an empty dict""" + + # invalid id + youtube_str = 'thisisaninvalidid' + output = VideoDescriptor._parse_youtube(youtube_str) + self.assertEqual(output, {'0.75': '', + '1.00': '', + '1.25': '', + '1.50': ''}) + # another invalid id + youtube_str = ',::,:,,' + output = VideoDescriptor._parse_youtube(youtube_str) + self.assertEqual(output, {'0.75': '', + '1.00': '', + '1.25': '', + '1.50': ''}) + + # and another one, partially invalid + youtube_str = '0.75_BAD!!!,1.0:AXdE34_U,1.25:KLHF9K_Y,1.5:VO3SxfeD,' + output = VideoDescriptor._parse_youtube(youtube_str) + self.assertEqual(output, {'0.75': '', + '1.00': 'AXdE34_U', + '1.25': 'KLHF9K_Y', + '1.50': 'VO3SxfeD'}) + def test_parse_youtube_key_format(self): """ Make sure that inconsistent speed keys are parsed correctly. From 9f229a46059f239f98cc0b568ec15f8c089dd815 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 15 Aug 2013 12:42:13 -0400 Subject: [PATCH 188/395] Make get_errored_courses part of the modulestore API (with default implementation). Also clarifies the contraction of location.course_id by throwing an exception for lcoations that are not of category course. Add test for course_id method. --- cms/djangoapps/auth/tests/test_authz.py | 4 +-- common/djangoapps/student/views.py | 2 +- .../xmodule/xmodule/modulestore/__init__.py | 27 +++++++++++++++++-- .../modulestore/tests/test_location.py | 9 +++++++ 4 files changed, 37 insertions(+), 5 deletions(-) diff --git a/cms/djangoapps/auth/tests/test_authz.py b/cms/djangoapps/auth/tests/test_authz.py index e04c108250..69050539cf 100644 --- a/cms/djangoapps/auth/tests/test_authz.py +++ b/cms/djangoapps/auth/tests/test_authz.py @@ -181,7 +181,7 @@ class CourseGroupTest(TestCase): create_all_course_groups(self.creator, self.location) add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME) - location2 = 'i4x', 'mitX', '103', 'course2', 'test2' + location2 = 'i4x', 'mitX', '103', 'course', 'test2' staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo') create_all_course_groups(self.creator, location2) add_user_to_course_group(self.creator, staff2, location2, STAFF_ROLE_NAME) @@ -193,7 +193,7 @@ class CourseGroupTest(TestCase): create_all_course_groups(self.creator, self.location) add_user_to_course_group(self.creator, self.staff, self.location, STAFF_ROLE_NAME) - location2 = 'i4x', 'mitX', '103', 'course2', 'test2' + location2 = 'i4x', 'mitX', '103', 'course', 'test2' creator2 = User.objects.create_user('testcreator2', 'testcreator2+courses@edx.org', 'foo') staff2 = User.objects.create_user('teststaff2', 'teststaff2+courses@edx.org', 'foo') create_all_course_groups(creator2, location2) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 2fe165077a..0b061f5a94 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -274,7 +274,7 @@ def dashboard(request): # Global staff can see what courses errored on their dashboard staff_access = False errored_courses = {} - if has_access(user, 'global', 'staff') and callable(getattr(modulestore(), 'get_errored_courses')): + if has_access(user, 'global', 'staff'): # Show any courses that errored on load staff_access = True errored_courses = modulestore().get_errored_courses() diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index a2297a7d26..d616f21efa 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -235,8 +235,15 @@ class Location(_LocationBase): @property def course_id(self): - """Return the ID of the Course that this item belongs to by looking - at the location URL hierachy""" + """ + Return the ID of the Course that this item belongs to by looking + at the location URL hierachy. + + Throws an InvalidLocationError is this location does not represent a course. + """ + if self.category != 'course': + raise InvalidLocationError('Cannot call course_id for {0} because it is not of category course'.format(self)) + return "/".join([self.org, self.course, self.name]) def replace(self, **kwargs): @@ -370,6 +377,13 @@ class ModuleStore(object): ''' raise NotImplementedError + def get_errored_courses(self): + """ + Return a dictionary of course_dir -> [(msg, exception_str)], for each + course_dir where course loading failed. + """ + raise NotImplementedError + class ModuleStoreBase(ModuleStore): ''' @@ -409,6 +423,15 @@ class ModuleStoreBase(ModuleStore): errorlog = self._get_errorlog(location) return errorlog.errors + def get_errored_courses(self): + """ + Returns an empty dict. + + It is up to subclasses to extend this method if the concept + of errored courses makes sense for their implementation. + """ + return {} + def get_course(self, course_id): """Default impl--linear search through course list""" for c in self.get_courses(): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py index f0f0e8bf48..7e8ba1731b 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py @@ -159,3 +159,12 @@ def test_clean_for_html(): def test_html_id(): loc = Location("tag://org/course/cat/name:more_name@rev") assert_equals(loc.html_id(), "tag-org-course-cat-name_more_name-rev") + + +def test_course_id(): + loc = Location('i4x', 'mitX', '103', 'course', 'test2') + assert_equals('mitX/103/test2', loc.course_id) + + loc = Location('i4x', 'mitX', '103', '_not_a_course', 'test2') + with assert_raises(InvalidLocationError): + loc.course_id From 7865fbe0dff1ccb8dfe1a86d1be42f4dbab90320 Mon Sep 17 00:00:00 2001 From: marco Date: Thu, 15 Aug 2013 13:56:17 -0400 Subject: [PATCH 189/395] cleaned up padding for peer review training and grading states, removed extraneous response label,fixed style issue on rubric for peer grading where rubric items had a background color overridding the intended style --- .../css/combinedopenended/display.scss | 5 +- .../js/fixtures/combined-open-ended.html | 2 +- .../xmodule/xmodule/js/fixtures/rubric.html | 2 +- lms/static/sass/course/_staff_grading.scss | 21 +++- .../combined_open_ended.html | 2 +- lms/templates/peer_grading/peer_grading.html | 106 +++++++++--------- 6 files changed, 75 insertions(+), 63 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index ef39d8a1b6..704b00812a 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -266,8 +266,6 @@ div.combined-rubric-container { h4 { padding-top: $baseline/2; - border-color: lightgray; - border-top: 1px solid; } span.rubric-category { @@ -892,7 +890,6 @@ section.open-ended-child { .oe-tools { display: inline-block; - padding-left: $baseline; width: 100%; border-radius: 5px; @@ -910,7 +907,7 @@ section.open-ended-child { .rubric-previous-button { margin-right: $baseline/4; } - + .rubric-next-button { margin-left: $baseline/4; } diff --git a/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html b/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html index 91d09f7922..e5eb0858f7 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html +++ b/common/lib/xmodule/xmodule/js/fixtures/combined-open-ended.html @@ -42,7 +42,7 @@
    - Response: +
    diff --git a/common/lib/xmodule/xmodule/js/fixtures/rubric.html b/common/lib/xmodule/xmodule/js/fixtures/rubric.html index 6b867cc52b..bdb572d11b 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/rubric.html +++ b/common/lib/xmodule/xmodule/js/fixtures/rubric.html @@ -78,7 +78,7 @@
    - Response: +
    diff --git a/lms/static/sass/course/_staff_grading.scss b/lms/static/sass/course/_staff_grading.scss index 739cdc6ca5..1087aae67a 100644 --- a/lms/static/sass/course/_staff_grading.scss +++ b/lms/static/sass/course/_staff_grading.scss @@ -36,10 +36,10 @@ div.peer-grading{ &.submission-container{ @include clearfix; overflow-y: auto; - height: auto; max-height: 300px; + height: auto; border: 1px solid #ddd; - background: #F6F6F6; + background: #f6f6f6; } } @@ -47,7 +47,6 @@ div.peer-grading{ margin: 0; padding: 2px; min-width: 50px; - background-color: white; text-size: 1.5em; } @@ -63,7 +62,7 @@ div.peer-grading{ } .problem-list { - width:100%; + width: 100%; table-layout: auto; text-align: center; @@ -193,6 +192,20 @@ div.peer-grading { border-radius: $baseline/2; padding: 0; + .peer-grading-tools { + padding: $baseline; + } + + .error-container { + margin: $baseline; + border-radius: $baseline/4; + padding: $baseline/2; + } + + .interstitial-page, .calibration -feedback, .calibration-interstitial-page { + padding: $baseline; + } + .prompt-wrapper { padding: $baseline; } diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 098f6d456a..65b44b0aa1 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -38,7 +38,7 @@ % endfor
    - ${_("Response: ")} +
    diff --git a/lms/templates/peer_grading/peer_grading.html b/lms/templates/peer_grading/peer_grading.html index f423de1c6b..468c2f300f 100644 --- a/lms/templates/peer_grading/peer_grading.html +++ b/lms/templates/peer_grading/peer_grading.html @@ -2,59 +2,61 @@
    ${error_text}
    -

    ${_("Peer Grading")}

    -

    ${_("Instructions")}

    -

    ${_("Here are a list of problems that need to be peer graded for this course.")}

    - % if success: - % if len(problem_list) == 0: -
    - ${_("Nothing to grade!")} -
    - %else: -
    - - - - - - - - +
    +

    ${_("Peer Grading")}

    +

    ${_("Instructions")}

    +

    ${_("Here are a list of problems that need to be peer graded for this course.")}

    + % if success: + % if len(problem_list) == 0: +
    + ${_("Nothing to grade!")} +
    + %else: +
    +
    ${_("Problem Name")}${_("Due date")}${_("Graded")}${_("Available")}${_("Required")}${_("Progress")}
    + + + + + + + + + %for problem in problem_list: + + + + + + + - %for problem in problem_list: - - - - - - - - - %endfor -
    ${_("Problem Name")}${_("Due date")}${_("Graded")}${_("Available")}${_("Required")}${_("Progress")}
    + %if problem['closed']: + ${problem['problem_name']} + %else: + ${problem['problem_name']} + %endif + + % if problem['due']: + ${problem['due']} + % else: + ${_("No due date")} + % endif + + ${problem['num_graded']} + + ${problem['num_pending']} + + ${problem['num_required']} + +
    +
    +
    - %if problem['closed']: - ${problem['problem_name']} - %else: - ${problem['problem_name']} - %endif - - % if problem['due']: - ${problem['due']} - % else: - ${_("No due date")} - % endif - - ${problem['num_graded']} - - ${problem['num_pending']} - - ${problem['num_required']} - -
    -
    -
    -
    + %endfor +
    +
    + %endif %endif - %endif +
    From f8dab92ba62f81ad68d9719fd2e8db0b4026af19 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 14:15:19 -0400 Subject: [PATCH 190/395] Hide submit button after student submits --- .../lib/xmodule/xmodule/js/src/combinedopenended/display.coffee | 1 + .../xmodule/open_ended_grading_classes/openendedchild.py | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 6b79ec9e3b..64401b647b 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -375,6 +375,7 @@ class @CombinedOpenEnded save_answer: (event) => event.preventDefault() + @submit_button.hide() max_filesize = 2*1000*1000 #2MB pre_can_upload_files = @can_upload_files if @child_state == 'initial' diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 8635f75db6..3e12b4656d 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -182,7 +182,7 @@ class OpenEndedChild(object): whitelist_tags=set(['embed', 'iframe', 'a', 'img'])) clean_html = cleaner.clean_html(answer) clean_html = re.sub(r'

    $', '', re.sub(r'^

    ', '', clean_html)) - except: + except Exception: clean_html = answer return clean_html From ac4d3124151a3c3d34cf07f833ee988a134a5246 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 14:26:42 -0400 Subject: [PATCH 191/395] Make rubric text generic, fix right arrow --- .../xmodule/xmodule/js/src/combinedopenended/display.coffee | 6 ------ .../combinedopenended/combined_open_ended_results.html | 4 ++-- 2 files changed, 2 insertions(+), 8 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 64401b647b..f2888c0144 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -667,10 +667,4 @@ class @CombinedOpenEnded toggle_rubric: (event) => info_rubric_elements = @$(@info_rubric_elements_sel) info_rubric_elements.slideToggle() - @rubric_header = @$(@rubric_collapse_sel) - if @rubric_header.text() == "Show Score Only" - new_text = "Show Full Rubric" - else - new_text = "Show Score Only" - @rubric_header.text(new_text) return false diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 3fa4d32e8d..8b0a7d3b48 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -14,7 +14,7 @@

    ${_("Rubric: ")} - + ${_("Scores:")} % if len(results)>1: @@ -22,7 +22,7 @@ % endif ${result['task_name']} from grader ${i+1} % if len(results)>1: - + % endif
    From a084d4486d6985e1560911c1df7699af323e6e0a Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 14:28:52 -0400 Subject: [PATCH 192/395] Get rid of labels --- .../combinedopenended/combined_open_ended_results.html | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 8b0a7d3b48..c1b79bca95 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -13,10 +13,10 @@ ${_("Submitted Rubric")}
    - ${_("Rubric: ")} + - ${_("Scores:")} + % if len(results)>1: % endif From f8b5e6736323795d92d8646048329a07b45f476b Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 14:52:51 -0400 Subject: [PATCH 193/395] Text and styling cleanup --- .../lib/xmodule/xmodule/css/combinedopenended/display.scss | 5 +++++ .../xmodule/js/src/peergrading/peer_grading_problem.coffee | 4 ++-- .../combinedopenended/combined_open_ended_results.html | 2 +- lms/templates/peer_grading/peer_grading.html | 2 +- 4 files changed, 9 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 704b00812a..120ea0c52d 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -296,6 +296,11 @@ div.combined-rubric-container { content: url('../images/incorrect-icon.png'); } } + + div.written-feedback { + background: #f6f6f6; + padding: 15px; + } } div.result-container { diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index 4f644a4ec1..a026cafd61 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -266,7 +266,7 @@ class @PeerGradingProblem @calibration_feedback_button.click => @calibration_feedback_panel.hide() @grading_wrapper.show() - @gentle_alert "Calibration essay saved. Fetched the next essay." + @gentle_alert "Calibration essay saved. Fetching the next essay." @is_calibrated_check() @interstitial_page_button.click => @@ -384,7 +384,7 @@ class @PeerGradingProblem if response.success @is_calibrated_check() @grading_message.fadeIn() - message = "

    Successfully saved your feedback. Fetched the next essay." + message = "

    Successfully saved your feedback. Fetching the next essay." if response.required_done message = message + " You have completed the required number of gradings." message = message + "

    " diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index c1b79bca95..5e5294bf60 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -14,7 +14,7 @@
    - + % if len(results)>1: diff --git a/lms/templates/peer_grading/peer_grading.html b/lms/templates/peer_grading/peer_grading.html index 468c2f300f..162c9273b0 100644 --- a/lms/templates/peer_grading/peer_grading.html +++ b/lms/templates/peer_grading/peer_grading.html @@ -9,7 +9,7 @@ % if success: % if len(problem_list) == 0:
    - ${_("Nothing to grade!")} + ${_("You currently do not having any peer grading to do. In order to have peer grading to do, you need to have submitted a response to a peer grading problem. The instructor also needs to score the essays that are used in the 'learning to grade' process.")}
    %else:
    From 958597ac71c2f509573073cb7fbd5c4128795cfd Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 14 Aug 2013 23:09:06 -0400 Subject: [PATCH 194/395] was getting unicode errors when exporting CB22x --- cms/djangoapps/contentstore/views/assets.py | 3 +++ common/lib/xmodule/xmodule/video_module.py | 2 +- 2 files changed, 4 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 94bfa55b58..b35f00f0e2 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -348,6 +348,8 @@ def generate_export_course(request, org, course, name): try: export_to_xml(modulestore('direct'), contentstore(), loc, root_dir, name, modulestore()) except SerializationError, e: + logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e))) + unit = None failed_item = None parent = None @@ -380,6 +382,7 @@ def generate_export_course(request, org, course, name): }) }) except Exception, e: + logging.exception('There was an error exporting course {0}. {1}'.format(course_module.location, unicode(e))) return render_to_response('export.html', { 'context_course': course_module, 'successful_import_redirect_url': '', diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index 407547d9bf..afa811b718 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -266,7 +266,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor if key in fields and fields[key].default == getattr(self, key): continue if value: - xml.set(key, str(value)) + xml.set(key, unicode(value)) for source in self.html5_sources: ele = etree.Element('source') From 26651a1dd7e4d9267764586cba620282eae08c3d Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Wed, 14 Aug 2013 23:35:40 -0400 Subject: [PATCH 195/395] export_to_xml() really should be definition_to_xml() since we'll rely on the base method that is in xml_module.py to preserve the correct export filesystem hierarchy. --- common/lib/xmodule/xmodule/video_module.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index afa811b718..c8c0bf4225 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -240,7 +240,7 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor video = cls(system, model_data) return video - def export_to_xml(self, resource_fs): + def definition_to_xml(self, resource_fs): """ Returns an xml string representing this module. """ @@ -277,7 +277,8 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor ele = etree.Element('track') ele.set('src', self.track) xml.append(ele) - return etree.tostring(xml, pretty_print=True) + return xml + #return etree.tostring(xml, pretty_print=True) @staticmethod def _parse_youtube(data): From 43f2c6a8e4c20bce937307de2a629534f9478de8 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 15 Aug 2013 00:28:16 -0400 Subject: [PATCH 196/395] remove commented out line --- common/lib/xmodule/xmodule/video_module.py | 1 - 1 file changed, 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index c8c0bf4225..8f5b4b4d09 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -278,7 +278,6 @@ class VideoDescriptor(VideoFields, TabsEditingDescriptor, EmptyDataRawDescriptor ele.set('src', self.track) xml.append(ele) return xml - #return etree.tostring(xml, pretty_print=True) @staticmethod def _parse_youtube(data): From f27ed07305994a9cd5e0c591de77e22fbf2ac547 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 15 Aug 2013 01:04:55 -0400 Subject: [PATCH 197/395] update tests to call definition_to_xml rather than export_to_xml, which presumes writing to a filesystem, plus it adds a url_name to the attribute set. Also, on __init__ reset the 'category' attribute, on some code paths this can get lost. Not sure why, but this gets all the tests to pass. --- common/lib/xmodule/xmodule/tests/test_video.py | 9 +++++---- common/lib/xmodule/xmodule/video_module.py | 4 ++++ 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/tests/test_video.py b/common/lib/xmodule/xmodule/tests/test_video.py index 4a13d565cc..ac1ab1d247 100644 --- a/common/lib/xmodule/xmodule/tests/test_video.py +++ b/common/lib/xmodule/xmodule/tests/test_video.py @@ -15,6 +15,7 @@ the course, section, subsection, unit, etc. import unittest from . import LogicTest +from lxml import etree from .import get_test_system from xmodule.modulestore import Location from xmodule.video_module import VideoDescriptor, _create_youtube_string @@ -344,7 +345,7 @@ class VideoExportTestCase(unittest.TestCase): desc.track = 'http://www.example.com/track' desc.html5_sources = ['http://www.example.com/source.mp4', 'http://www.example.com/source.ogg'] - xml = desc.export_to_xml(None) # We don't use the `resource_fs` parameter + xml = desc.definition_to_xml(None) # We don't use the `resource_fs` parameter expected = dedent('''\ ''') - self.assertEquals(expected, xml) + self.assertEquals(expected, etree.tostring(xml, pretty_print=True)) def test_export_to_xml_empty_parameters(self): """Test XML export with defaults.""" @@ -361,7 +362,7 @@ class VideoExportTestCase(unittest.TestCase): location = Location(["i4x", "edX", "video", "default", "SampleProblem1"]) desc = VideoDescriptor(module_system, {'location': location}) - xml = desc.export_to_xml(None) + xml = desc.definition_to_xml(None) expected = '
    \ No newline at end of file From b98572225500ecaaf74b3c2be37bf639859e090e Mon Sep 17 00:00:00 2001 From: James Tauber Date: Fri, 16 Aug 2013 13:52:07 -0400 Subject: [PATCH 260/395] fixed encoding xgettext seems to only like `utf-8` not `utf8` so string extraction was failing without this --- .../student/management/commands/anonymized_id_mapping.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/djangoapps/student/management/commands/anonymized_id_mapping.py b/common/djangoapps/student/management/commands/anonymized_id_mapping.py index 6156d4bf1d..f1ed5bdef9 100644 --- a/common/djangoapps/student/management/commands/anonymized_id_mapping.py +++ b/common/djangoapps/student/management/commands/anonymized_id_mapping.py @@ -1,4 +1,4 @@ -# -*- coding: utf8 -*- +# -*- coding: utf-8 -*- """Dump username,unique_id_for_user pairs as CSV. Give instructors easy access to the mapping from anonymized IDs to user IDs From 4c286840146f1e250f7f4c62d519e8ac7429208e Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Fri, 16 Aug 2013 14:01:16 -0400 Subject: [PATCH 261/395] pass xblock fields as top-level keywords on factories --- .../contentstore/tests/test_crud.py | 4 +- .../modulestore/tests/persistent_factories.py | 51 +++++-------------- 2 files changed, 16 insertions(+), 39 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_crud.py b/cms/djangoapps/contentstore/tests/test_crud.py index 9bfd20b1c0..e543b7b517 100644 --- a/cms/djangoapps/contentstore/tests/test_crud.py +++ b/cms/djangoapps/contentstore/tests/test_crud.py @@ -151,7 +151,7 @@ class TemplateTests(unittest.TestCase): parent_location=chapter.location, user_id='testbot', category='vertical') first_problem = persistent_factories.ItemFactory.create( display_name='problem 1', parent_location=sub.location, user_id='testbot', category='problem', - fields={'data':""} + data="" ) first_problem.max_attempts = 3 first_problem.save() # decache the above into the kvs @@ -165,7 +165,7 @@ class TemplateTests(unittest.TestCase): display_name='problem 2', parent_location=BlockUsageLocator(updated_loc, usage_id=sub.location.usage_id), user_id='testbot', category='problem', - fields={'data':""} + data="" ) # course root only updated 2x diff --git a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py index 51e3a7f04d..3031990974 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/persistent_factories.py @@ -11,38 +11,24 @@ class PersistentCourseFactory(factory.Factory): """ Create a new course (not a new version of a course, but a whole new index entry). - keywords: + keywords: any xblock field plus (note, the below are filtered out; so, if they + become legitimate xblock fields, they won't be settable via this factory) * org: defaults to textX * prettyid: defaults to 999 - * display_name - * user_id - * fields (optional) the settings and content payloads. If display_name is in the metadata, that takes - precedence over any display_name provided directly. + * master_branch: (optional) defaults to 'draft' + * user_id: (optional) defaults to 'test_user' + * display_name (xblock field): will default to 'Robot Super Course' unless provided """ FACTORY_FOR = CourseDescriptor - org = 'testX' - prettyid = '999' - display_name = 'Robot Super Course' - user_id = "test_user" - master_branch = 'draft' - # pylint: disable=W0613 @classmethod - def _create(cls, target_class, *args, **kwargs): - - org = kwargs.get('org') - prettyid = kwargs.get('prettyid') - display_name = kwargs.get('display_name') - user_id = kwargs.get('user_id') - fields = kwargs.get('fields', {}) - if display_name and 'display_name' not in fields: - fields['display_name'] = display_name + def _create(cls, target_class, org='testX', prettyid='999', user_id='test_user', master_branch='draft', **kwargs): # Write the data to the mongo datastore new_course = modulestore('split').create_course( - org, prettyid, user_id, fields=fields, id_root=prettyid, - master_branch=kwargs.get('master_branch')) + org, prettyid, user_id, fields=kwargs, id_root=prettyid, + master_branch=master_branch) return new_course @@ -54,33 +40,24 @@ class PersistentCourseFactory(factory.Factory): class ItemFactory(factory.Factory): FACTORY_FOR = XModuleDescriptor - category = 'chapter' - user_id = 'test_user' display_name = factory.LazyAttributeSequence(lambda o, n: "{} {}".format(o.category, n)) # pylint: disable=W0613 @classmethod - def _create(cls, target_class, *args, **kwargs): + def _create(cls, target_class, parent_location, category='chapter', + user_id='test_user', definition_locator=None, **kwargs): """ - Uses *kwargs*: + passes *kwargs* as the new item's field values: :param parent_location: (required) the location of the course & possibly parent :param category: (defaults to 'chapter') - :param fields: (optional) the data for the item - :param definition_locator (optional): the DescriptorLocator for the definition this uses or branches - - :param display_name (optional): the display name of the item """ - fields = kwargs.get('fields', {}) - if 'display_name' not in fields and 'display_name' in kwargs: - fields['display_name'] = kwargs['display_name'] - - return modulestore('split').create_item(kwargs['parent_location'], kwargs['category'], - kwargs['user_id'], definition_locator=kwargs.get('definition_locator'), - fields=fields) + return modulestore('split').create_item( + parent_location, category, user_id, definition_locator, fields=kwargs + ) @classmethod def _build(cls, target_class, *args, **kwargs): From 4f3ec68fb744565af929da3eae5f9dc2c5262bf7 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 14:12:37 -0400 Subject: [PATCH 262/395] Scroll to top in peer and staff grading, preserve line breaks in submissions --- .../js/src/combinedopenended/display.coffee | 30 +++++++++++-------- .../peergrading/peer_grading_problem.coffee | 8 ++++- .../open_ended_module.py | 2 +- .../openendedchild.py | 3 +- .../self_assessment_module.py | 2 +- .../src/staff_grading/staff_grading.coffee | 8 ++++- lms/templates/instructor/staff_grading.html | 6 ++-- lms/templates/peer_grading/peer_grading.html | 2 +- .../peer_grading/peer_grading_problem.html | 4 +-- 9 files changed, 43 insertions(+), 22 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index e846717030..b4f72a3e1d 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -100,6 +100,7 @@ class @CombinedOpenEnded open_ended_child_sel: 'section.open-ended-child' error_sel: '.error' answer_area_sel: 'textarea.answer' + answer_area_div_sel : 'div.answer' prompt_sel: '.prompt' rubric_wrapper_sel: '.rubric-wrapper' hint_wrapper_sel: '.hint-wrapper' @@ -339,6 +340,21 @@ class @CombinedOpenEnded find_hint_elements: -> @hint_area = @$('textarea.post_assessment') + replace_answer: (response) => + if response.success + @rubric_wrapper.html(response.rubric_html) + @rubric_wrapper.show() + @rub = new Rubric(@coe) + @rub.initialize(@location) + @child_state = 'assessing' + @find_assessment_elements() + @rebind() + answer_area_div = @$(@answer_area_div_sel) + answer_area_div.html(response.student_response) + else + @can_upload_files = pre_can_upload_files + @gentle_alert response.error + save_answer: (event) => @submit_button.attr("disabled",true) event.preventDefault() @@ -367,19 +383,9 @@ class @CombinedOpenEnded data: fd processData: false contentType: false + async: false success: (response) => - if response.success - @rubric_wrapper.html(response.rubric_html) - @rubric_wrapper.show() - @rub = new Rubric(@coe) - @rub.initialize(@location) - @answer_area.html(response.student_response) - @child_state = 'assessing' - @find_assessment_elements() - @rebind() - else - @can_upload_files = pre_can_upload_files - @gentle_alert response.error + @replace_answer(response) $.ajaxWithPrefix("#{@ajax_url}/save_answer",settings) else diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index a026cafd61..f96704037e 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -450,7 +450,7 @@ class @PeerGradingProblem @submit_button.unbind('click') @submit_button.click @submit_calibration_essay - + @scroll_to_top() else if response.error @render_error(response.error) else @@ -479,6 +479,7 @@ class @PeerGradingProblem @submit_button.unbind('click') @submit_button.click @submit_grade + @scroll_to_top() else if response.error @render_error(response.error) else @@ -573,3 +574,8 @@ class @PeerGradingProblem Logger.log 'peer_grading_show_question', {location: @location} new_text = "(Hide)" @question_header.text(new_text) + + scroll_to_top: () => + $('html, body').animate({ + scrollTop: $(".peer-grading").offset().top + }, 200) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py index dc2c3fb01b..800789b56a 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/open_ended_module.py @@ -663,7 +663,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): return { 'success': success, 'error': error_message, - 'student_response': data['student_answer'] + 'student_response': data['student_answer'].replace("\n","
    ") } def update_score(self, data, system): diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py index 3e12b4656d..d99e466886 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -179,9 +179,10 @@ class OpenEndedChild(object): answer = autolink_html(answer) cleaner = Cleaner(style=True, links=True, add_nofollow=False, page_structure=True, safe_attrs_only=True, host_whitelist=open_ended_image_submission.TRUSTED_IMAGE_DOMAINS, - whitelist_tags=set(['embed', 'iframe', 'a', 'img'])) + whitelist_tags=set(['embed', 'iframe', 'a', 'img', 'br'])) clean_html = cleaner.clean_html(answer) clean_html = re.sub(r'

    $', '', re.sub(r'^

    ', '', clean_html)) + clean_html = re.sub("\n","
    ", clean_html) except Exception: clean_html = answer return clean_html diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py index 5c40aca116..2485fc77ea 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/self_assessment_module.py @@ -196,7 +196,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): 'success': success, 'rubric_html': self.get_rubric_html(system), 'error': error_message, - 'student_response': data['student_answer'], + 'student_response': data['student_answer'].replace("\n","
    ") } def save_assessment(self, data, _system): diff --git a/lms/static/coffee/src/staff_grading/staff_grading.coffee b/lms/static/coffee/src/staff_grading/staff_grading.coffee index e8cee60001..31c084ffd0 100644 --- a/lms/static/coffee/src/staff_grading/staff_grading.coffee +++ b/lms/static/coffee/src/staff_grading/staff_grading.coffee @@ -253,7 +253,7 @@ class @StaffGrading # always clear out errors and messages on transition. @error_msg = '' @message = '' - + if response.success if response.problem_list @problems = response.problem_list @@ -265,6 +265,7 @@ class @StaffGrading @error(response.error) @render_view() + @scroll_to_top() get_next_submission: (location) -> @location = location @@ -474,6 +475,11 @@ class @StaffGrading new_text = "(Hide)" @question_header.text(new_text) + scroll_to_top: () => + $('html, body').animate({ + scrollTop: $(".staff-grading").offset().top + }, 200) + # for now, just create an instance and load it... diff --git a/lms/templates/instructor/staff_grading.html b/lms/templates/instructor/staff_grading.html index bb60bddeb0..15ea97332a 100644 --- a/lms/templates/instructor/staff_grading.html +++ b/lms/templates/instructor/staff_grading.html @@ -38,6 +38,7 @@

    +

    @@ -80,8 +81,9 @@
    -
    - \ No newline at end of file + + + diff --git a/lms/templates/peer_grading/peer_grading.html b/lms/templates/peer_grading/peer_grading.html index 162c9273b0..b0dffe8f9d 100644 --- a/lms/templates/peer_grading/peer_grading.html +++ b/lms/templates/peer_grading/peer_grading.html @@ -9,7 +9,7 @@ % if success: % if len(problem_list) == 0:
    - ${_("You currently do not having any peer grading to do. In order to have peer grading to do, you need to have submitted a response to a peer grading problem. The instructor also needs to score the essays that are used in the 'learning to grade' process.")} + ${_("You currently do not having any peer grading to do. In order to have peer grading to do, you need to have submitted a response to a peer grading problem. The instructor also needs to score the essays that are used to help you better understand the grading criteria.")}
    %else:
    diff --git a/lms/templates/peer_grading/peer_grading_problem.html b/lms/templates/peer_grading/peer_grading_problem.html index a0078b8868..b945e030cb 100644 --- a/lms/templates/peer_grading/peer_grading_problem.html +++ b/lms/templates/peer_grading/peer_grading_problem.html @@ -27,6 +27,8 @@
    +
    +

    ${_("Student Response")}

    @@ -53,8 +55,6 @@
    -
    -
    From bc8244b70950e651088e083fc45a06df14cc6f24 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 14:43:37 -0400 Subject: [PATCH 263/395] Rename reset to try again, hide and disable submit buttons --- .../xmodule/xmodule/js/src/combinedopenended/display.coffee | 4 +++- .../xmodule/js/src/peergrading/peer_grading_problem.coffee | 6 +++++- lms/templates/combinedopenended/combined_open_ended.html | 2 +- 3 files changed, 9 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index b4f72a3e1d..7065a54d55 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -357,8 +357,8 @@ class @CombinedOpenEnded save_answer: (event) => @submit_button.attr("disabled",true) - event.preventDefault() @submit_button.hide() + event.preventDefault() @answer_area.attr("disabled", true) max_filesize = 2*1000*1000 #2MB pre_can_upload_files = @can_upload_files @@ -404,6 +404,8 @@ class @CombinedOpenEnded @is_ctrl=false save_assessment: (event) => + @submit_button.attr("disabled",true) + @submit_button.hide() event.preventDefault() if @child_state == 'assessing' && @rub.check_complete() checked_assessment = @rub.get_total_score() diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index f96704037e..bdb02c44b9 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -322,10 +322,12 @@ class @PeerGradingProblem submit_calibration_essay: ()=> data = @construct_data() + @submit_button.hide() @backend.post('save_calibration_essay', data, @calibration_callback) submit_grade: () => data = @construct_data() + @submit_button.hide() @backend.post('save_grade', data, @submission_callback) @@ -448,6 +450,7 @@ class @PeerGradingProblem @feedback_area.val("") + @submit_button.show() @submit_button.unbind('click') @submit_button.click @submit_calibration_essay @scroll_to_top() @@ -476,7 +479,8 @@ class @PeerGradingProblem @flag_student_container.show() @answer_unknown_container.show() @feedback_area.val("") - + + @submit_button.show() @submit_button.unbind('click') @submit_button.click @submit_grade @scroll_to_top() diff --git a/lms/templates/combinedopenended/combined_open_ended.html b/lms/templates/combinedopenended/combined_open_ended.html index 65b44b0aa1..5f088265a2 100644 --- a/lms/templates/combinedopenended/combined_open_ended.html +++ b/lms/templates/combinedopenended/combined_open_ended.html @@ -39,7 +39,7 @@
    - +
    From a5a6d2d54afdc81319d88e19fb8f4291229da392 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Fri, 16 Aug 2013 16:59:40 +0300 Subject: [PATCH 264/395] Added test for Flash video player. Fix for video and captions sync. Test for this fix. --- .../xmodule/js/spec/video/video_caption_spec.js | 17 +++++++++++++++-- 1 file changed, 15 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js index 49e5df0e9a..f7a7e131dc 100644 --- a/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js +++ b/common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js @@ -443,7 +443,7 @@ }); it('trigger seek event with the correct time', function() { - expect(videoPlayer.currentTime).toEqual(15); + expect(videoPlayer.currentTime).toEqual(14.91); }); }); @@ -455,7 +455,20 @@ }); it('trigger seek event with the correct time', function() { - expect(videoPlayer.currentTime).toEqual(15); + expect(videoPlayer.currentTime).toEqual(14.91); + }); + }); + + describe('when the player type is Flash at speed 0.75x', function () { + beforeEach(function () { + initialize(); + videoSpeedControl.currentSpeed = '0.75'; + state.currentPlayerMode = 'flash'; + $('.subtitles li[data-start="14910"]').trigger('click'); + }); + + it('trigger seek event with the correct time', function () { + expect(videoPlayer.currentTime).toEqual(15); }); }); }); From 8248c331386708100c94247387729fb0a45a3f74 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 14:48:11 -0400 Subject: [PATCH 265/395] Grab the correct location to report data for --- common/lib/xmodule/xmodule/peer_grading_module.py | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index aa742f6a0b..e8409948e9 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -188,9 +188,8 @@ class PeerGradingModule(PeerGradingFields, XModule): return json.dumps(d, cls=ComplexEncoder) - def query_data_for_location(self): + def query_data_for_location(self, location): student_id = self.system.anonymous_student_id - location = self.link_to_location success = False response = {} @@ -322,7 +321,7 @@ class PeerGradingModule(PeerGradingFields, XModule): try: response = self.peer_gs.save_grade(**data_dict) - success, location_data = self.query_data_for_location() + success, location_data = self.query_data_for_location(data_dict['location']) response.update({'required_done' : False}) if 'count_graded' in location_data and 'count_required' in location_data and int(location_data['count_graded'])>=int(location_data['count_required']): response['required_done'] = True From 48b0e570bc131e60647b543d7965061dc9dd3b5e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 15:10:55 -0400 Subject: [PATCH 266/395] Disable submit buttons until rubric totally filled out --- .../xmodule/xmodule/js/src/combinedopenended/display.coffee | 2 +- .../xmodule/js/src/peergrading/peer_grading_problem.coffee | 5 ++++- 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 7065a54d55..1732bfbe71 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -300,7 +300,7 @@ class @CombinedOpenEnded @hide_file_upload() @submit_button.prop('value', 'Submit assessment') @submit_button.click @save_assessment - @submit_button.attr("disabled",false) + @submit_button.attr("disabled",true) if @child_type == "openended" @submit_button.hide() @queueing() diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index bdb02c44b9..0cbccad548 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -453,6 +453,7 @@ class @PeerGradingProblem @submit_button.show() @submit_button.unbind('click') @submit_button.click @submit_calibration_essay + @submit_button.attr('disabled', true) @scroll_to_top() else if response.error @render_error(response.error) @@ -479,10 +480,11 @@ class @PeerGradingProblem @flag_student_container.show() @answer_unknown_container.show() @feedback_area.val("") - + @submit_button.show() @submit_button.unbind('click') @submit_button.click @submit_grade + @submit_button.attr('disabled', true) @scroll_to_top() else if response.error @render_error(response.error) @@ -558,6 +560,7 @@ class @PeerGradingProblem @action_button.show() show_submit_button: () => + @submit_button.attr('disabled', false) @submit_button.show() setup_score_selection: (max_score) => From 1128f369f7261d8a6dfaae7bb5dc0e708f5b6e79 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 16 Aug 2013 15:24:20 -0400 Subject: [PATCH 267/395] add changelog entry --- CHANGELOG.rst | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index e108a31594..1338cacd3b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -96,6 +96,8 @@ LMS: Removed press releases Common: Updated Sass and Bourbon libraries, added Neat library +LMS: Add a MixedModuleStore to aggregate the XMLModuleStore and MongoMonduleStore + LMS: Users are no longer auto-activated if they click "reset password" This is now done when they click on the link in the reset password email they receive (along with usual path through activation email). From 59336ee03caab6d398cd72eebc1666d466510ed1 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 15:24:38 -0400 Subject: [PATCH 268/395] Fix prompt hiding issue in peer grading --- .../src/peergrading/peer_grading_problem.coffee | 15 +++++++-------- .../peer_grading/peer_grading_problem.html | 4 ++-- 2 files changed, 9 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index 0cbccad548..8771b4fe91 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -257,8 +257,6 @@ class @PeerGradingProblem $(window).keydown @keydown_handler $(window).keyup @keyup_handler - @collapse_question() - Collapsible.setCollapsibles(@content_panel) # Set up the click event handlers @@ -571,16 +569,17 @@ class @PeerGradingProblem @grading_message.fadeIn() @grading_message.html("

    " + msg + "

    ") - collapse_question: () => + collapse_question: (event) => @prompt_container.slideToggle() @prompt_container.toggleClass('open') - if @question_header.text() == "(Hide)" - Logger.log 'peer_grading_hide_question', {location: @location} - new_text = "(Show)" + if @question_header.text() == "Hide Prompt" + new_text = "Show Prompt" + Logger.log 'oe_hide_question', {location: @location} else - Logger.log 'peer_grading_show_question', {location: @location} - new_text = "(Hide)" + Logger.log 'oe_show_question', {location: @location} + new_text = "Hide Prompt" @question_header.text(new_text) + return false scroll_to_top: () => $('html, body').animate({ diff --git a/lms/templates/peer_grading/peer_grading_problem.html b/lms/templates/peer_grading/peer_grading_problem.html index b945e030cb..80b230e2aa 100644 --- a/lms/templates/peer_grading/peer_grading_problem.html +++ b/lms/templates/peer_grading/peer_grading_problem.html @@ -14,10 +14,10 @@
    -
    +
    - ${_('Hide Prompt')} + ${_('Hide Prompt')}
    From bd71a2ceb3a03d283db6e6a255ac409b80419b20 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Fri, 16 Aug 2013 15:24:43 -0400 Subject: [PATCH 269/395] add unit test for video_caption asset path --- .../contentstore/tests/test_contentstore.py | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index e70df4164a..96b0b84e36 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -400,6 +400,20 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): resp = self.client.get(url) self.assertEqual(resp.status_code, 200) + def test_video_module_caption_asset_path(self): + ''' + This verifies that a video caption url is as we expect it to be + ''' + direct_store = modulestore('direct') + import_from_xml(direct_store, 'common/test/data/', ['toy']) + + # also try a custom response which will trigger the 'is this course in whitelist' logic + video_module_location = Location(['i4x', 'edX', 'toy', 'video', 'sample_video', None]) + url = reverse('preview_component', kwargs={'location': video_module_location.url()}) + resp = self.client.get(url) + self.assertEqual(resp.status_code, 200) + self.assertContains(resp, 'data-caption-asset-path="/c4x/edX/toy/asset/subs_"') + def test_delete(self): direct_store = modulestore('direct') CourseFactory.create(org='edX', course='999', display_name='Robot Super Course') From af76997ccd74fb697e8df52bf6c62d5b59d1c406 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 16 Aug 2013 15:26:57 -0400 Subject: [PATCH 270/395] Add in a uniqueness constraint on mode, course, and currency. --- ...coursemode_course_id_currency_mode_slug.py | 33 +++++++++++++++++++ lms/djangoapps/course_modes/models.py | 4 +++ 2 files changed, 37 insertions(+) create mode 100644 lms/djangoapps/course_modes/migrations/0003_auto__add_unique_coursemode_course_id_currency_mode_slug.py diff --git a/lms/djangoapps/course_modes/migrations/0003_auto__add_unique_coursemode_course_id_currency_mode_slug.py b/lms/djangoapps/course_modes/migrations/0003_auto__add_unique_coursemode_course_id_currency_mode_slug.py new file mode 100644 index 0000000000..56a4f28c57 --- /dev/null +++ b/lms/djangoapps/course_modes/migrations/0003_auto__add_unique_coursemode_course_id_currency_mode_slug.py @@ -0,0 +1,33 @@ +# -*- coding: utf-8 -*- +import datetime +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding unique constraint on 'CourseMode', fields ['course_id', 'currency', 'mode_slug'] + db.create_unique('course_modes_coursemode', ['course_id', 'currency', 'mode_slug']) + + + def backwards(self, orm): + # Removing unique constraint on 'CourseMode', fields ['course_id', 'currency', 'mode_slug'] + db.delete_unique('course_modes_coursemode', ['course_id', 'currency', 'mode_slug']) + + + models = { + 'course_modes.coursemode': { + 'Meta': {'unique_together': "(('course_id', 'mode_slug', 'currency'),)", 'object_name': 'CourseMode'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'min_price': ('django.db.models.fields.IntegerField', [], {'default': '0'}), + 'mode_display_name': ('django.db.models.fields.CharField', [], {'max_length': '255'}), + 'mode_slug': ('django.db.models.fields.CharField', [], {'max_length': '100'}), + 'suggested_prices': ('django.db.models.fields.CommaSeparatedIntegerField', [], {'default': "''", 'max_length': '255', 'blank': 'True'}) + } + } + + complete_apps = ['course_modes'] \ No newline at end of file diff --git a/lms/djangoapps/course_modes/models.py b/lms/djangoapps/course_modes/models.py index 347094880b..561c078b3b 100644 --- a/lms/djangoapps/course_modes/models.py +++ b/lms/djangoapps/course_modes/models.py @@ -34,6 +34,10 @@ class CourseMode(models.Model): DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd') + class Meta: + """ meta attributes of this model """ + unique_together = ('course_id', 'mode_slug', 'currency') + @classmethod def modes_for_course(cls, course_id): """ From fc899ab116206ad64381f2abefb88bc99dbb4999 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Fri, 16 Aug 2013 15:30:35 -0400 Subject: [PATCH 271/395] Stylistic change and factored out method to common --- lms/djangoapps/courseware/features/common.py | 7 +++++++ lms/djangoapps/courseware/features/help.py | 7 ------- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index bf58cbc6fe..b69dd4c866 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -66,6 +66,13 @@ def add_tab_to_course(_step, course, extra_tab_name): display_name=str(extra_tab_name)) +@step(u'I am in a course$') +def go_into_course(step): + step.given('I am registered for the course "6.002x"') + step.given('And I am logged in') + step.given('And I click on View Courseware') + + def course_id(course_num): return "%s/%s/%s" % (world.scenario_dict['COURSE'].org, course_num, world.scenario_dict['COURSE'].display_name.replace(" ", "_")) diff --git a/lms/djangoapps/courseware/features/help.py b/lms/djangoapps/courseware/features/help.py index a426caa910..7691328f00 100644 --- a/lms/djangoapps/courseware/features/help.py +++ b/lms/djangoapps/courseware/features/help.py @@ -36,13 +36,6 @@ def see_confirmation(step): assert world.browser.evaluate_script("$('input[value=\"Submit\"]').attr('disabled')") == 'disabled' -@step(u'I am in a course') -def go_into_course(step): - step.given('I am registered for the course "6.002x"') - step.given('And I am logged in') - step.given('And I click on View Courseware') - - def fill_field(name, info): def fill_info(): form_css = 'form.feedback_form' From 0833f87fef3a50a914b77609e03eb49fea88bfb6 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Wed, 31 Jul 2013 16:19:59 -0400 Subject: [PATCH 272/395] Remove 'edu' from examples per our agreement not to use the domain --- .../lib/xmodule/xmodule/modulestore/parsers.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/parsers.py b/common/lib/xmodule/xmodule/modulestore/parsers.py index 9308894b86..b646d10707 100644 --- a/common/lib/xmodule/xmodule/modulestore/parsers.py +++ b/common/lib/xmodule/xmodule/modulestore/parsers.py @@ -19,10 +19,10 @@ def parse_url(string): Examples: 'edx://version/0123FFFF' - 'edx://edu.mit.eecs.6002x' - 'edx://edu.mit.eecs.6002x/branch/published' - 'edx://edu.mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b/block/HW3' - 'edx://edu.mit.eecs.6002x/branch/published/block/HW3' + 'edx://mit.eecs.6002x' + 'edx://mit.eecs.6002x;published' + 'edx://mit.eecs.6002x;published#HW3' + 'edx://mit.eecs.6002x;published@000eee12345#HW3' This returns None if string cannot be parsed. @@ -97,11 +97,11 @@ def parse_course_id(string): Examples of valid course_ids: - 'edu.mit.eecs.6002x' - 'edu.mit.eecs.6002x/branch/published' - 'edu.mit.eecs.6002x/block/HW3' - 'edu.mit.eecs.6002x/branch/published/block/HW3' - 'edu.mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b/block/HW3' + 'mit.eecs.6002x' + 'mit.eecs.6002x;published' + 'mit.eecs.6002x#HW3' + 'mit.eecs.6002x;published#HW3' + 'mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b/block/HW3' Syntax: From 1abae6f09316b8debda522ce9a65073f63ab45bc Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Wed, 31 Jul 2013 16:20:34 -0400 Subject: [PATCH 273/395] Location to Locator mapping utilities partial impl --- .../lib/xmodule/xmodule/modulestore/split_mongo/split.py | 9 +++------ 1 file changed, 3 insertions(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 7038cdf865..7e151a6649 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -10,9 +10,8 @@ from xmodule.errortracker import null_error_tracker from xmodule.x_module import XModuleDescriptor from xmodule.modulestore.locator import BlockUsageLocator, DescriptionLocator, CourseLocator, VersionTree from xmodule.modulestore.exceptions import InsufficientSpecificationError, VersionConflictError -from xmodule.modulestore import inheritance +from xmodule.modulestore import inheritance, ModuleStoreBase -from .. import ModuleStoreBase from ..exceptions import ItemNotFoundError from .definition_lazy_loader import DefinitionLazyLoader from .caching_descriptor_system import CachingDescriptorSystem @@ -62,14 +61,11 @@ class SplitMongoModuleStore(ModuleStoreBase): **kwargs ), db) - # TODO add caching of structures to thread_cache to prevent repeated fetches (but not index b/c - # it changes w/o having a change in id) self.course_index = self.db[collection + '.active_versions'] self.structures = self.db[collection + '.structures'] self.definitions = self.db[collection + '.definitions'] - # ??? Code review question: those familiar w/ python threading. Should I instead - # use django cache? How should I expire entries? + # Code review question: How should I expire entries? # _add_cache could use a lru mechanism to control the cache size? self.thread_cache = threading.local() @@ -1178,6 +1174,7 @@ class SplitMongoModuleStore(ModuleStoreBase): else: return DescriptionLocator(definition['_id']) + def _block_matches(self, value, qualifiers): ''' Return True or False depending on whether the value (block contents) From 3ee6b3c0954137e82dc06736d702ed2c122d1d03 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 5 Aug 2013 14:25:01 -0400 Subject: [PATCH 274/395] pep8 violation fix (shadowed identifier) --- .../xmodule/modulestore/tests/test_split_modulestore.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py index 71361a6fce..d90c3020b4 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_split_modulestore.py @@ -992,8 +992,8 @@ class TestInheritance(SplitModuleTest): # This mocks the django.modulestore() function and is intended purely to disentangle # the tests from django def modulestore(): - def load_function(path): - module_path, _, name = path.rpartition('.') + def load_function(engine_path): + module_path, _, name = engine_path.rpartition('.') return getattr(import_module(module_path), name) if SplitModuleTest.modulestore is None: From 37602edfec9a9f29ce5bc594c29b416f3a3bb3fd Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Tue, 6 Aug 2013 17:07:58 -0400 Subject: [PATCH 275/395] Translate locations <-> locators unit tests and fixes they implied --- .../modulestore/tests/test_location_mapper.py | 502 ++++++++++++++++++ 1 file changed, 502 insertions(+) create mode 100644 common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py new file mode 100644 index 0000000000..4fc719ed31 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py @@ -0,0 +1,502 @@ +''' +Created on Aug 5, 2013 + +@author: dmitchell +''' +import unittest +import uuid +from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore +from xmodule.modulestore import Location +from xmodule.modulestore.locator import BlockUsageLocator +from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError + + +class TestLocationMapper(unittest.TestCase): + + def setUp(self): + modulestore_options = { + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore{0}'.format(uuid.uuid4().hex), + 'fs_root': '', + 'render_template': render_to_template_mock, + 'default_class': 'xmodule.raw_module.RawDescriptor', + } + + # pylint: disable=W0142 + TestLocationMapper.modulestore = SplitMongoModuleStore(**modulestore_options) + + + def tearDown(self): + db = TestLocationMapper.modulestore.db + db.drop_collection(TestLocationMapper.modulestore.course_index) + db.drop_collection(TestLocationMapper.modulestore.structures) + db.drop_collection(TestLocationMapper.modulestore.definitions) + db.drop_collection(TestLocationMapper.modulestore.location_map) + db.connection.close() + TestLocationMapper.modulestore = None + + def test_create_map(self): + org = 'foo_org' + course = 'bar_course' + modulestore().create_map_entry(Location('i4x', org, course, 'course', 'baz_run')) + entry = modulestore().location_map.find_one({ + '_id': {'org': org, 'course': course, 'name': 'baz_run'} + }) + self.assertIsNotNone(entry, "Didn't find entry") + self.assertEqual(entry['course_id'], '{}.{}.baz_run'.format(org, course)) + self.assertEqual(entry['draft_branch'], 'draft') + self.assertEqual(entry['prod_branch'], 'published') + self.assertEqual(entry['block_map'], {}) + + modulestore().create_map_entry(Location('i4x', org, course, 'vertical', 'baz_vert')) + entry = modulestore().location_map.find_one({ + '_id': {'org': org, 'course': course} + }) + self.assertIsNotNone(entry, "Didn't find entry") + self.assertEqual(entry['course_id'], '{}.{}'.format(org, course)) + + course = 'quux_course' + # oldname: {category: newname} + block_map = {'abc123': {'problem': 'problem2'}} + modulestore().create_map_entry( + Location('i4x', org, course, 'problem', 'abc123', 'draft'), + 'foo_org.geek_dept.quux_course.baz_run', + 'wip', + 'live', + block_map) + entry = modulestore().location_map.find_one({ + '_id': {'org': org, 'course': course} + }) + self.assertIsNotNone(entry, "Didn't find entry") + self.assertEqual(entry['course_id'], 'foo_org.geek_dept.quux_course.baz_run') + self.assertEqual(entry['draft_branch'], 'wip') + self.assertEqual(entry['prod_branch'], 'live') + self.assertEqual(entry['block_map'], block_map) + + def test_translate_location_read_only(self): + """ + Test the variants of translate_location which don't create entries, just decode + """ + # lookup before there are any maps + org = 'foo_org' + course = 'bar_course' + old_style_course_id = '{}/{}/{}'.format(org, course, 'baz_run') + prob_locator = modulestore().translate_location( + old_style_course_id, + Location('i4x', org, course, 'problem', 'abc123'), + add_entry_if_missing=False + ) + self.assertIsNone(prob_locator, 'found entry in empty map table') + + new_style_course_id = '{}.geek_dept.{}.baz_run'.format(org, course) + block_map = {'abc123': {'problem': 'problem2'}} + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'baz_run'), + new_style_course_id, + block_map=block_map + ) + # only one course matches + prob_locator = modulestore().translate_location( + old_style_course_id, + Location('i4x', org, course, 'problem', 'abc123'), + add_entry_if_missing=False + ) + self.assertEqual(prob_locator.course_id, new_style_course_id) + self.assertEqual(prob_locator.branch, 'published') + self.assertEqual(prob_locator.usage_id, 'problem2') + # look for w/ only the Location (works b/c there's only one possible course match) + prob_locator = modulestore().translate_location( + None, + Location('i4x', org, course, 'problem', 'abc123'), + add_entry_if_missing=False + ) + self.assertEqual(prob_locator.course_id, new_style_course_id) + # look for non-existent problem + prob_locator = modulestore().translate_location( + None, + Location('i4x', org, course, 'problem', '1def23'), + add_entry_if_missing=False + ) + self.assertIsNone(prob_locator, "Found non-existent problem") + + # add a distractor course + block_map = {'abc123': {'problem': 'problem3'}} + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'delta_run'), + '{}.geek_dept.{}.{}'.format(org, course, 'delta_run'), + block_map=block_map + ) + prob_locator = modulestore().translate_location( + old_style_course_id, + Location('i4x', org, course, 'problem', 'abc123'), + add_entry_if_missing=False + ) + self.assertEqual(prob_locator.course_id, new_style_course_id) + self.assertEqual(prob_locator.usage_id, 'problem2') + # look for w/ only the Location (not unique; so, just verify it returns something) + prob_locator = modulestore().translate_location( + None, + Location('i4x', org, course, 'problem', 'abc123'), + add_entry_if_missing=False + ) + self.assertIsNotNone(prob_locator, "couldn't find ambiguous location") + + # add a default course pointing to the delta_run + modulestore().create_map_entry( + Location('i4x', org, course, 'problem', '789abc123efg456'), + '{}.geek_dept.{}.{}'.format(org, course, 'delta_run'), + block_map=block_map + ) + # now the ambiguous query should return delta + prob_locator = modulestore().translate_location( + None, + Location('i4x', org, course, 'problem', 'abc123'), + add_entry_if_missing=False + ) + self.assertEqual(prob_locator.course_id, '{}.geek_dept.{}.{}'.format(org, course, 'delta_run')) + self.assertEqual(prob_locator.usage_id, 'problem3') + + # get the draft one (I'm sorry this is getting long) + prob_locator = modulestore().translate_location( + None, + Location('i4x', org, course, 'problem', 'abc123'), + published=False, + add_entry_if_missing=False + ) + self.assertEqual(prob_locator.course_id, '{}.geek_dept.{}.{}'.format(org, course, 'delta_run')) + self.assertEqual(prob_locator.usage_id, 'problem3') + self.assertEqual(prob_locator.branch, 'draft') + + def translate_location_dwim(self): + """ + Test the location translation mechanisms which try to do-what-i-mean by creating new + entries for never seen queries. + """ + org = 'foo_org' + course = 'bar_course' + old_style_course_id = '{}/{}/{}'.format(org, course, 'baz_run') + problem_name = 'abc123abc123abc123abc123abc123f9' + location = Location('i4x', org, course, 'problem', problem_name) + prob_locator = modulestore().translate_location( + old_style_course_id, + location, + add_entry_if_missing=True + ) + new_style_course_id = '{}.{}.{}'.format(org, course, 'baz_run') + self.assertEqual(prob_locator.course_id, new_style_course_id) + self.assertEqual(prob_locator.branch, 'published') + self.assertEqual(prob_locator.usage_id, 'problemabc') + # look for w/ only the Location (works b/c there's only one possible course match) + prob_locator = modulestore().translate_location( + None, + location, + add_entry_if_missing=True + ) + self.assertEqual(prob_locator.course_id, new_style_course_id) + + # add a distractor course + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'delta_run'), + '{}.geek_dept.{}.{}'.format(org, course, 'delta_run'), + block_map={problem_name: {'problem': 'problem3'}} + ) + prob_locator = modulestore().translate_location( + old_style_course_id, + location, + add_entry_if_missing=True + ) + self.assertEqual(prob_locator.course_id, new_style_course_id) + self.assertEqual(prob_locator.usage_id, 'problemabc') + # look for w/ only the Location (not unique; so, just verify it returns something) + prob_locator = modulestore().translate_location( + None, + location, + add_entry_if_missing=True + ) + self.assertIsNotNone(prob_locator, "couldn't find ambiguous location") + + # add a default course pointing to the delta_run + modulestore().create_map_entry( + Location('i4x', org, course, 'problem', '789abc123efg456'), + '{}.geek_dept.{}.{}'.format(org, course, 'delta_run'), + block_map={problem_name: {'problem': 'problem3'}} + ) + # now the ambiguous query should return delta + prob_locator = modulestore().translate_location( + None, + location, + add_entry_if_missing=False + ) + self.assertEqual(prob_locator.course_id, '{}.geek_dept.{}.{}'.format(org, course, 'delta_run')) + self.assertEqual(prob_locator.usage_id, 'problem3') + + def test_translate_locator(self): + """ + tests translate_locator_to_location(BlockUsageLocator) + """ + # lookup for non-existent course + org = 'foo_org' + course = 'bar_course' + new_style_course_id = '{}.geek_dept.{}.baz_run'.format(org, course) + prob_locator = BlockUsageLocator( + course_id=new_style_course_id, + usage_id='problem2' + ) + prob_location = modulestore().translate_locator_to_location(prob_locator) + self.assertIsNone(prob_location, 'found entry in empty map table') + + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'baz_run'), + new_style_course_id, + block_map={ + 'abc123': {'problem': 'problem2'}, + '48f23a10395384929234': {'chapter': 'chapter48f'} + } + ) + # only one course matches + prob_location = modulestore().translate_locator_to_location(prob_locator) + # default branch + self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None)) + # explicit branch + prob_locator = BlockUsageLocator(prob_locator, branch='draft') + prob_location = modulestore().translate_locator_to_location(prob_locator) + self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', 'draft')) + prob_locator = BlockUsageLocator( + course_id=new_style_course_id, usage_id='problem2', branch='production' + ) + prob_location = modulestore().translate_locator_to_location(prob_locator) + self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None)) + # same for chapter except chapter cannot be draft in old system + chap_locator = BlockUsageLocator( + course_id=new_style_course_id, + usage_id='chapter48f' + ) + chap_location = modulestore().translate_locator_to_location(chap_locator) + self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234')) + # explicit branch + chap_locator = BlockUsageLocator(chap_locator, branch='draft') + chap_location = modulestore().translate_locator_to_location(chap_locator) + self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234')) + chap_locator = BlockUsageLocator( + course_id=new_style_course_id, usage_id='chapter48f', branch='production' + ) + chap_location = modulestore().translate_locator_to_location(chap_locator) + self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234')) + + # look for non-existent problem + prob_locator2 = BlockUsageLocator( + course_id=new_style_course_id, + branch='draft', + usage_id='problem3' + ) + prob_location = modulestore().translate_locator_to_location(prob_locator2) + self.assertIsNone(prob_location, 'Found non-existent problem') + + # add a distractor course + new_style_course_id = '{}.geek_dept.{}.{}'.format(org, course, 'delta_run') + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'delta_run'), + new_style_course_id, + block_map={'abc123': {'problem': 'problem3'}} + ) + prob_location = modulestore().translate_locator_to_location(prob_locator) + self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None)) + + # add a default course pointing to the delta_run + modulestore().create_map_entry( + Location('i4x', org, course, 'problem', '789abc123efg456'), + new_style_course_id, + block_map={'abc123': {'problem': 'problem3'}} + ) + # now query delta (2 entries point to it) + prob_locator = BlockUsageLocator( + course_id=new_style_course_id, + branch='production', + usage_id='problem3' + ) + prob_location = modulestore().translate_locator_to_location(prob_locator) + self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123')) + + def test_add_block(self): + """ + Test add_block_location_translator(location, old_course_id=None, usage_id=None) + """ + # call w/ no matching courses + org = 'foo_org' + course = 'bar_course' + old_style_course_id = '{}/{}/{}'.format(org, course, 'baz_run') + problem_name = 'abc123abc123abc123abc123abc123f9' + location = Location('i4x', org, course, 'problem', problem_name) + with self.assertRaises(ItemNotFoundError): + modulestore().add_block_location_translator(location) + with self.assertRaises(ItemNotFoundError): + modulestore().add_block_location_translator(location, old_style_course_id) + + # w/ one matching course + new_style_course_id = '{}.{}.{}'.format(org, course, 'baz_run') + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'baz_run'), + new_style_course_id, + ) + new_usage_id = modulestore().add_block_location_translator(location) + self.assertEqual(new_usage_id, 'problemabc') + # look it up + translated_loc = modulestore().translate_location(old_style_course_id, location, add_entry_if_missing=False) + self.assertEqual(translated_loc.course_id, new_style_course_id) + self.assertEqual(translated_loc.usage_id, new_usage_id) + + # w/ one distractor which has one entry already + new_style_course_id = '{}.geek_dept.{}.{}'.format(org, course, 'delta_run') + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'delta_run'), + new_style_course_id, + block_map={'48f23a10395384929234': {'chapter': 'chapter48f'}} + ) + # try adding the one added before + new_usage_id2 = modulestore().add_block_location_translator(location) + self.assertEqual(new_usage_id, new_usage_id2) + # it should be in the distractor now + new_location = modulestore().translate_locator_to_location( + BlockUsageLocator(course_id=new_style_course_id, usage_id=new_usage_id2) + ) + self.assertEqual(new_location, location) + # add one close to the existing chapter (cause name collision) + location = Location('i4x', org, course, 'chapter', '48f23a103953849292341234567890ab') + new_usage_id = modulestore().add_block_location_translator(location) + self.assertRegexpMatches(new_usage_id, r'^chapter48f\d') + # retrievable from both courses + new_location = modulestore().translate_locator_to_location( + BlockUsageLocator(course_id=new_style_course_id, usage_id=new_usage_id) + ) + self.assertEqual(new_location, location) + new_location = modulestore().translate_locator_to_location( + BlockUsageLocator(course_id='{}.{}.{}'.format(org, course, 'baz_run'), usage_id=new_usage_id) + ) + self.assertEqual(new_location, location) + + # provoke duplicate item errors + location = location.replace(name='44f23a103953849292341234567890ab') + with self.assertRaises(DuplicateItemError): + modulestore().add_block_location_translator(location, usage_id=new_usage_id) + new_usage_id = modulestore().add_block_location_translator(location, old_course_id=old_style_course_id) + other_course_old_style = '{}/{}/{}'.format(org, course, 'delta_run') + new_usage_id2 = modulestore().add_block_location_translator( + location, + old_course_id=other_course_old_style, + usage_id='{}b'.format(new_usage_id) + ) + with self.assertRaises(DuplicateItemError): + modulestore().add_block_location_translator(location) + + def test_update_block(self): + """ + test update_block_location_translator(location, usage_id, old_course_id=None) + """ + org = 'foo_org' + course = 'bar_course' + new_style_course_id = '{}.geek_dept.{}.baz_run'.format(org, course) + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'baz_run'), + new_style_course_id, + block_map={ + 'abc123': {'problem': 'problem2'}, + '48f23a10395384929234': {'chapter': 'chapter48f'}, + '1': {'chapter': 'chapter1', 'problem': 'problem1'}, + } + ) + new_style_course_id2 = '{}.geek_dept.{}.delta_run'.format(org, course) + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'delta_run'), + new_style_course_id2, + block_map={ + 'abc123': {'problem': 'problem3'}, + '48f23a10395384929234': {'chapter': 'chapter48b'}, + '1': {'chapter': 'chapter2', 'problem': 'problem2'}, + } + ) + location = Location('i4x', org, course, 'problem', '1') + # change in all courses to same value + modulestore().update_block_location_translator(location, 'problem1') + trans_loc = modulestore().translate_locator_to_location( + BlockUsageLocator(course_id=new_style_course_id, usage_id='problem1') + ) + self.assertEqual(trans_loc, location) + trans_loc = modulestore().translate_locator_to_location( + BlockUsageLocator(course_id=new_style_course_id2, usage_id='problem1') + ) + self.assertEqual(trans_loc, location) + # try to change to overwrite used usage_id + location = Location('i4x', org, course, 'chapter', '48f23a10395384929234') + with self.assertRaises(DuplicateItemError): + modulestore().update_block_location_translator(location, 'chapter2') + # just change the one course + modulestore().update_block_location_translator(location, 'chapter2', '{}/{}/{}'.format(org, course, 'baz_run')) + trans_loc = modulestore().translate_locator_to_location( + BlockUsageLocator(course_id=new_style_course_id, usage_id='chapter2') + ) + self.assertEqual(trans_loc.name, '48f23a10395384929234') + # but this still points to the old + trans_loc = modulestore().translate_locator_to_location( + BlockUsageLocator(course_id=new_style_course_id2, usage_id='chapter2') + ) + self.assertEqual(trans_loc.name, '1') + + + def test_delete_block(self): + """ + test delete_block_location_translator(location, old_course_id=None) + """ + org = 'foo_org' + course = 'bar_course' + new_style_course_id = '{}.geek_dept.{}.baz_run'.format(org, course) + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'baz_run'), + new_style_course_id, + block_map={ + 'abc123': {'problem': 'problem2'}, + '48f23a10395384929234': {'chapter': 'chapter48f'}, + '1': {'chapter': 'chapter1', 'problem': 'problem1'}, + } + ) + new_style_course_id2 = '{}.geek_dept.{}.delta_run'.format(org, course) + modulestore().create_map_entry( + Location('i4x', org, course, 'course', 'delta_run'), + new_style_course_id2, + block_map={ + 'abc123': {'problem': 'problem3'}, + '48f23a10395384929234': {'chapter': 'chapter48b'}, + '1': {'chapter': 'chapter2', 'problem': 'problem2'}, + } + ) + location = Location('i4x', org, course, 'problem', '1') + # delete from all courses + modulestore().delete_block_location_translator(location) + self.assertIsNone(modulestore().translate_locator_to_location( + BlockUsageLocator(course_id=new_style_course_id, usage_id='problem1') + )) + self.assertIsNone(modulestore().translate_locator_to_location( + BlockUsageLocator(course_id=new_style_course_id2, usage_id='problem2') + )) + # delete from one course + location = location.replace(name='abc123') + modulestore().delete_block_location_translator(location, '{}/{}/{}'.format(org, course, 'baz_run')) + self.assertIsNone(modulestore().translate_location( + '{}/{}/{}'.format(org, course, 'baz_run'), + location, + add_entry_if_missing=False + )) + locator = modulestore().translate_location( + '{}/{}/{}'.format(org, course, 'delta_run'), + location, + add_entry_if_missing=False + ) + self.assertEqual(locator.usage_id, 'problem3') + +#================================== +# functions to mock existing services +def modulestore(): + return TestLocationMapper.modulestore + +def render_to_template_mock(*_args): + pass From 1b63ecab48c147fa21aaab612883862ac3832585 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Wed, 7 Aug 2013 13:25:09 -0400 Subject: [PATCH 276/395] Enable test via naming it correctly --- .../xmodule/xmodule/modulestore/tests/test_location_mapper.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py index 4fc719ed31..dd55927f10 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py @@ -168,7 +168,7 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(prob_locator.usage_id, 'problem3') self.assertEqual(prob_locator.branch, 'draft') - def translate_location_dwim(self): + def test_translate_location_dwim(self): """ Test the location translation mechanisms which try to do-what-i-mean by creating new entries for never seen queries. From 47a2122a65834ef83f2ac6b109647844fd0ed709 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Fri, 9 Aug 2013 15:58:22 -0400 Subject: [PATCH 277/395] Require edx:// for urls But don't use this parser for urls embedded in a browser url request. --- common/lib/xmodule/xmodule/modulestore/parsers.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/parsers.py b/common/lib/xmodule/xmodule/modulestore/parsers.py index b646d10707..4996cc4e8a 100644 --- a/common/lib/xmodule/xmodule/modulestore/parsers.py +++ b/common/lib/xmodule/xmodule/modulestore/parsers.py @@ -21,8 +21,8 @@ def parse_url(string): 'edx://version/0123FFFF' 'edx://mit.eecs.6002x' 'edx://mit.eecs.6002x;published' - 'edx://mit.eecs.6002x;published#HW3' - 'edx://mit.eecs.6002x;published@000eee12345#HW3' + 'edx://mit.eecs.6002x;published/block/HW3' + 'edx://mit.eecs.6002x;published/version/000eee12345/block/HW3' This returns None if string cannot be parsed. @@ -98,9 +98,9 @@ def parse_course_id(string): Examples of valid course_ids: 'mit.eecs.6002x' - 'mit.eecs.6002x;published' - 'mit.eecs.6002x#HW3' - 'mit.eecs.6002x;published#HW3' + 'mit.eecs.6002x/branch/published' + 'mit.eecs.6002x/block/HW3' + 'mit.eecs.6002x/branch/published/block/HW3' 'mit.eecs.6002x/branch/published/version/519665f6223ebd6980884f2b/block/HW3' From 2702d8a6360a655960e9bdb0587e0d19b027ae2f Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 12 Aug 2013 12:35:29 -0400 Subject: [PATCH 278/395] Implement location mapper as stand-alone helper class with own db connection. --- .../xmodule/modulestore/LocMapperStore.py | 397 ++++++++++++++++++ .../modulestore/tests/test_location_mapper.py | 158 ++++--- 2 files changed, 473 insertions(+), 82 deletions(-) create mode 100644 common/lib/xmodule/xmodule/modulestore/LocMapperStore.py diff --git a/common/lib/xmodule/xmodule/modulestore/LocMapperStore.py b/common/lib/xmodule/xmodule/modulestore/LocMapperStore.py new file mode 100644 index 0000000000..b71f647a27 --- /dev/null +++ b/common/lib/xmodule/xmodule/modulestore/LocMapperStore.py @@ -0,0 +1,397 @@ +''' +Method for converting among our differing Location/Locator whatever reprs +''' +from __future__ import absolute_import +from random import randint +import re +import pymongo +from django.conf import settings + +from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, DuplicateItemError +from xmodule.modulestore.locator import BlockUsageLocator +from xmodule.modulestore.mongo import draft +from xmodule.modulestore import Location + +def loc_mapper(): + """ + Get the loc mapper which bidirectionally maps Locations to Locators. Used like modulestore() as + a singleton accessor. + """ + # pylint: disable=W0212 + if LocMapperStore._singleton is None: + # instantiate + LocMapperStore(settings.modulestore_options) + return LocMapperStore._singleton + +class LocMapperStore(object): + ''' + This store persists mappings among the addressing schemes. At this time, it's between the old i4x Location + tuples and the split mongo Course and Block Locator schemes. + + edX has used several different addressing schemes. The original ones were organically created based on + immediate needs and were overly restrictive esp wrt course ids. These were slightly extended to support + some types of blocks may need to have draft states during editing to keep live courses from seeing the wip. + A later refactoring generalized course ids to enable governance and more complex naming, branch naming with + anything able to be in any branch. + + The expectation is that the configuration will have this use the same store as whatever is the default + or dominant store, but that's not a requirement. This store creates its own connection. + ''' + + _singleton = None + + def __init__(self, host, db, collection, port=27017, user=None, password=None, **kwargs): + ''' + Constructor + ''' + self.db = pymongo.database.Database(pymongo.MongoClient( + host=host, + port=port, + tz_aware=True, + **kwargs + ), db) + if user is not None and password is not None: + self.db.authenticate(user, password) + + self.location_map = self.db[collection + '.location_map'] + self.location_map.write_concern = {'w': 1} + LocMapperStore._singleton = self + + + # location_map functions + def create_map_entry(self, course_location, course_id=None, draft_branch='draft', prod_branch='published', + block_map=None): + """ + Add a new entry to map this course_location to the new style CourseLocator.course_id. If course_id is not + provided, it creates the default map of using org.course.name from the location (just like course_id) if + the location.cateogry = 'course'; otherwise, it uses org.course. + + You can create more than one mapping to the + same course_id target. In that case, the reverse translate will be arbitrary (no guarantee of which wins). + The use + case for more than one mapping is to map both org/course/run and org/course to the same new course_id thus + making a default for org/course. When querying for just org/course, the translator will prefer any entry + which does not have a name in the _id; otherwise, it will return an arbitrary match. + + Note: the opposite is not true. That is, it never makes sense to use 2 different CourseLocator.course_id + keys to index the same old Locator org/course/.. pattern. There's no checking to ensure you don't do this. + + NOTE: if there's already an entry w the given course_location, this may either overwrite that entry or + throw an error depending on how mongo is configured. + + :param course_location: a Location preferably whose category is 'course'. Unlike the other + map methods, this one doesn't take the old-style course_id because it's assumed to be called with + a course location not a block location. + :param course_id: the CourseLocator style course_id + :param draft_branch: the branch name to assign for drafts. This is hardcoded because old mongo had + a fixed notion that there was 2 and only 2 versions for modules: draft and production. The old mongo + did not, however, require that a draft version exist. The new one, however, does require a draft to + exist. + :param prod_branch: the branch name to assign for the production (live) copy. In old mongo, every course + had to have a production version (whereas new split mongo does not require that until the author's ready + to publish). + :param block_map: an optional map to specify preferred names for blocks where the keys are the + Location block names and the values are the BlockUsageLocator.block_id. + """ + if course_id is None: + if course_location.category == 'course': + course_id = "{0.org}.{0.course}.{0.name}".format(course_location) + else: + course_id = "{0.org}.{0.course}".format(course_location) + # very like _interpret_location_id but w/o the _id + location_id = {'org': course_location.org, 'course': course_location.course} + if course_location.category == 'course': + location_id['name'] = course_location.name + + self.location_map.insert({ + '_id': location_id, + 'course_id': course_id, + 'draft_branch': draft_branch, + 'prod_branch': prod_branch, + 'block_map': block_map or {}, + }) + + + def translate_location(self, old_style_course_id, location, published=True, add_entry_if_missing=True): + """ + Translate the given module location to a Locator. If the mapping has the run id in it, then you + should provide old_style_course_id with that run id in it to disambiguate the mapping if there exists more + than one entry in the mapping table for the org.course. + + The rationale for auto adding entries was that there should be a reasonable default translation + if the code just trips into this w/o creating translations. The downfall is that ambiguous course + locations may generate conflicting block_ids. + + :param old_style_course_id: the course_id used in old mongo not the new one (optional, will use location) + :param location: a Location pointing to a module + :param published: a boolean to indicate whether the caller wants the draft or published branch. + :param add_entry_if_missing: a boolean as to whether to return None or to create an entry if the course + or block is not found in the map. + + NOTE: unlike old mongo, draft branches contain the whole course; so, it applies to all category + of locations including course. + """ + location_id = self._interpret_location_course_id(old_style_course_id, location) + + maps = self.location_map.find(location_id) + if maps.count() == 0: + if add_entry_if_missing: + # create a new map + course_location = location.replace(category='course', name=location_id['_id.name']) + self.create_map_entry(course_location) + entry = self.location_map.find_one(location_id) + else: + return None + elif maps.count() > 1: + # if more than one, prefer the one w/o a name if that exists. Otherwise, choose the first (arbitrary) + # a bit odd b/c maps is a cursor and doesn't allow multitraversal w/o requerying db + entry = None + for candidate in maps: + if entry is None: + entry = candidate # pick off first ele in case we don't find one w/o a name + if 'name' not in candidate['_id']: + entry = candidate + break + else: + entry = maps[0] + + if published: + branch = entry['prod_branch'] + else: + branch = entry['draft_branch'] + + usage_id = entry['block_map'].get(location.name) + if usage_id is None: + if add_entry_if_missing: + usage_id = self._add_to_block_map(location, location_id, entry['block_map']) + else: + return None + elif isinstance(usage_id, dict): + # name is not unique, look through for the right category + if location.category in usage_id: + usage_id = usage_id[location.category] + elif add_entry_if_missing: + usage_id = self._add_to_block_map(location, location_id, entry['block_map']) + else: + return None + else: + raise InvalidLocationError() + + return BlockUsageLocator(course_id=entry['course_id'], branch=branch, usage_id=usage_id) + + def translate_locator_to_location(self, locator): + """ + Returns an old style Location for the given Locator if there's an appropriate entry in the + mapping collection. Note, it requires that the course was previously mapped (a side effect of + translate_location or explicitly via create_map_entry) and + the block's usage_id was previously stored in the + map (a side effect of translate_location or via add|update_block_location). + + If there are no matches, it returns None. + + If there's more than one location to locator mapping to the same course_id, it looks for the first + one with a mapping for the block usage_id and picks that arbitrary course location. + + :param locator: a BlockUsageLocator + """ + # Does not use _lookup_course b/c it doesn't actually require that the course exist in the active_version + # only that it has a mapping entry. + maps = self.location_map.find({'course_id': locator.course_id}) + # look for one which maps to this block usage_id + if maps.count() == 0: + return None + for candidate in maps: + for old_name, cat_to_usage in candidate['block_map'].iteritems(): + for category, usage_id in cat_to_usage.iteritems(): + if usage_id == locator.usage_id: + # figure out revision + # enforce the draft only if category in [..] logic + if category in draft.DIRECT_ONLY_CATEGORIES: + revision = None + elif locator.branch == candidate['draft_branch']: + revision = draft.DRAFT + else: + revision = None + return Location( + 'i4x', + candidate['_id']['org'], + candidate['_id']['course'], + category, + old_name, + revision) + return None + + + def add_block_location_translator(self, location, old_course_id=None, usage_id=None): + """ + Similar to translate_location which adds an entry if none is found, but this cannot create a new + course mapping entry, only a block within such a mapping entry. If it finds no existing + course maps, it raises ItemNotFoundError. + + In the case that there are more than one mapping record for the course identified by location, this + method adds the mapping to all matching records! (translate_location only adds to one) + + It allows the caller to specify + the new-style usage_id for the target rather than having the translate concoct its own. + If the provided usage_id already exists in one of the found maps for the org/course, this function + raises DuplicateItemError unless the old item id == the new one. + + If the caller does not provide a usage_id and there exists an entry in one of the course variants, + it will use that entry. If more than one variant uses conflicting entries, it will raise DuplicateItemError. + + Returns the usage_id used in the mapping + + :param location: a fully specified Location + :param old_course_id: the old-style org/course or org/course/run string (optional) + :param usage_id: the desired new block_id. If left as None, this will generate one as per translate_location + """ + location_id = self._interpret_location_course_id(old_course_id, location) + + maps = self.location_map.find(location_id) + if maps.count() == 0: + raise ItemNotFoundError() + + # turn maps from cursor to list + map_list = [map_entry for map_entry in maps] + # check whether there's already a usage_id for this location (and it agrees w/ any passed in or found) + for map_entry in map_list: + if (location.name in map_entry['block_map'] and + location.category in map_entry['block_map'][location.name]): + if usage_id is None: + usage_id = map_entry['block_map'][location.name][location.category] + elif usage_id != map_entry['block_map'][location.name][location.category]: + raise DuplicateItemError() + + computed_usage_id = usage_id + + # update the maps (and generate a usage_id if it's not been set yet) + for map_entry in map_list: + if computed_usage_id is None: + computed_usage_id = self._add_to_block_map(location, location_id, map_entry['block_map']) + elif (location.name not in map_entry['block_map'] or + location.category not in map_entry['block_map'][location.name]): + alt_usage_id = self._verify_uniqueness(computed_usage_id, map_entry['block_map']) + if alt_usage_id != computed_usage_id: + if usage_id is not None: + raise DuplicateItemError() + else: + # revise already set ones and add to remaining ones + computed_usage_id = self.update_block_location_translator( + location, + alt_usage_id, + old_course_id, + True + ) + + map_entry['block_map'].setdefault(location.name, {})[location.category] = computed_usage_id + self.location_map.update({'_id': map_entry['_id']}, {'$set': {'block_map': map_entry['block_map']}}) + + return computed_usage_id + + + def update_block_location_translator(self, location, usage_id, old_course_id=None, autogenerated_usage_id=False): + """ + Update all existing maps from location's block to the new usage_id. Used for changing the usage_id, + thus the usage_id is required. + + Returns the usage_id. (which is primarily useful in the case of autogenerated_usage_id) + + :param location: a fully specified Location + :param usage_id: the desired new block_id. + :param old_course_id: the old-style org/course or org/course/run string (optional) + :param autogenerated_usage_id: a flag used mostly for internal calls to indicate that this usage_id + was autogenerated and thus can be overridden if it's not unique. If you set this flag, the stored + usage_id may not be the one you submitted. + """ + location_id = self._interpret_location_course_id(old_course_id, location) + + maps = self.location_map.find(location_id) + for map_entry in maps: + # handle noop of renaming to same name + if (location.name in map_entry['block_map'] and + map_entry['block_map'][location.name].get(location.category) == usage_id): + continue + alt_usage_id = self._verify_uniqueness(usage_id, map_entry['block_map']) + if alt_usage_id != usage_id: + if autogenerated_usage_id: + # revise already set ones and add to remaining ones + usage_id = self.update_block_location_translator(location, alt_usage_id, old_course_id, True) + return usage_id + else: + raise DuplicateItemError() + + if location.category in map_entry['block_map'].setdefault(location.name, {}): + map_entry['block_map'][location.name][location.category] = usage_id + self.location_map.update({'_id': map_entry['_id']}, {'$set': {'block_map': map_entry['block_map']}}) + + return usage_id + + + def delete_block_location_translator(self, location, old_course_id=None): + """ + Remove all existing maps from location's block. + + :param location: a fully specified Location + :param old_course_id: the old-style org/course or org/course/run string (optional) + """ + location_id = self._interpret_location_course_id(old_course_id, location) + + maps = self.location_map.find(location_id) + for map_entry in maps: + if location.category in map_entry['block_map'].setdefault(location.name, {}): + if len(map_entry['block_map'][location.name]) == 1: + del map_entry['block_map'][location.name] + else: + del map_entry['block_map'][location.name][location.category] + self.location_map.update({'_id': map_entry['_id']}, {'$set': {'block_map': map_entry['block_map']}}) + + def _add_to_block_map(self, location, location_id, block_map): + '''add the given location to the block_map and persist it''' + if self._block_id_is_guid(location.name): + # I'm having second thoughts about this even though it will make the ids more meaningful. + # The downside is that if there's more than one course mapped to from the same org/course root + # the block ids will likely be out of sync and collide from an id perspective. HOWEVER, + # if there are few == org/course roots or their content is unrelated, this will work well. + usage_id = self._verify_uniqueness(location.category + location.name[:3], block_map) + block_map.setdefault(location.name, {})[location.category] = usage_id + self.location_map.update(location_id, {'$set': {'block_map': block_map}}) + return usage_id + + def _interpret_location_course_id(self, course_id, location): + """ + Take the old style course id (org/course/run) and return a dict for querying the mapping table. + If the course_id is empty, it uses location, but this may result in an inadequate id. + + :param course_id: old style 'org/course/run' id from Location.course_id where Location.category = 'course' + :param location: a Location object which may be to a module or a course. Provides partial info + if course_id is omitted. + """ + if course_id: + # re doesn't allow ?P<_id.org> and ilk + m = re.match(r'([^/]+)/([^/]+)/([^/]+)', course_id) + return dict(zip(['_id.org', '_id.course', '_id.name'], m.groups())) + + location_id = {'_id.org': location.org, '_id.course': location.course} + if location.category == 'course': + location_id['_id.name'] = location.name + return location_id + + def _block_id_is_guid(self, name): + return len(name) == 32 and re.search(r'[^0-9A-Fa-f]', name) is None + + def _verify_uniqueness(self, name, block_map): + ''' + Verify that the name doesn't occur elsewhere in block_map. If it does, keep adding to it until + it's unique. + ''' + for targets in block_map.itervalues(): + if isinstance(targets, dict): + for values in targets.itervalues(): + if values == name: + name += str(randint(0, 9)) + return self._verify_uniqueness(name, block_map) + + elif targets == name: + name += str(randint(0, 9)) + return self._verify_uniqueness(name, block_map) + return name diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py index dd55927f10..f4addd9ee9 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py @@ -5,10 +5,10 @@ Created on Aug 5, 2013 ''' import unittest import uuid -from xmodule.modulestore.split_mongo.split import SplitMongoModuleStore from xmodule.modulestore import Location from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError +from xmodule.modulestore.LocMapperStore import LocMapperStore class TestLocationMapper(unittest.TestCase): @@ -18,29 +18,23 @@ class TestLocationMapper(unittest.TestCase): 'host': 'localhost', 'db': 'test_xmodule', 'collection': 'modulestore{0}'.format(uuid.uuid4().hex), - 'fs_root': '', - 'render_template': render_to_template_mock, - 'default_class': 'xmodule.raw_module.RawDescriptor', } # pylint: disable=W0142 - TestLocationMapper.modulestore = SplitMongoModuleStore(**modulestore_options) + TestLocationMapper.loc_store = LocMapperStore(**modulestore_options) def tearDown(self): - db = TestLocationMapper.modulestore.db - db.drop_collection(TestLocationMapper.modulestore.course_index) - db.drop_collection(TestLocationMapper.modulestore.structures) - db.drop_collection(TestLocationMapper.modulestore.definitions) - db.drop_collection(TestLocationMapper.modulestore.location_map) + db = TestLocationMapper.loc_store.db + db.drop_collection(TestLocationMapper.loc_store.location_map) db.connection.close() - TestLocationMapper.modulestore = None + TestLocationMapper.loc_store = None def test_create_map(self): org = 'foo_org' course = 'bar_course' - modulestore().create_map_entry(Location('i4x', org, course, 'course', 'baz_run')) - entry = modulestore().location_map.find_one({ + loc_mapper().create_map_entry(Location('i4x', org, course, 'course', 'baz_run')) + entry = loc_mapper().location_map.find_one({ '_id': {'org': org, 'course': course, 'name': 'baz_run'} }) self.assertIsNotNone(entry, "Didn't find entry") @@ -49,8 +43,8 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(entry['prod_branch'], 'published') self.assertEqual(entry['block_map'], {}) - modulestore().create_map_entry(Location('i4x', org, course, 'vertical', 'baz_vert')) - entry = modulestore().location_map.find_one({ + loc_mapper().create_map_entry(Location('i4x', org, course, 'vertical', 'baz_vert')) + entry = loc_mapper().location_map.find_one({ '_id': {'org': org, 'course': course} }) self.assertIsNotNone(entry, "Didn't find entry") @@ -59,13 +53,13 @@ class TestLocationMapper(unittest.TestCase): course = 'quux_course' # oldname: {category: newname} block_map = {'abc123': {'problem': 'problem2'}} - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'problem', 'abc123', 'draft'), 'foo_org.geek_dept.quux_course.baz_run', 'wip', 'live', block_map) - entry = modulestore().location_map.find_one({ + entry = loc_mapper().location_map.find_one({ '_id': {'org': org, 'course': course} }) self.assertIsNotNone(entry, "Didn't find entry") @@ -82,7 +76,7 @@ class TestLocationMapper(unittest.TestCase): org = 'foo_org' course = 'bar_course' old_style_course_id = '{}/{}/{}'.format(org, course, 'baz_run') - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( old_style_course_id, Location('i4x', org, course, 'problem', 'abc123'), add_entry_if_missing=False @@ -91,13 +85,13 @@ class TestLocationMapper(unittest.TestCase): new_style_course_id = '{}.geek_dept.{}.baz_run'.format(org, course) block_map = {'abc123': {'problem': 'problem2'}} - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'baz_run'), new_style_course_id, block_map=block_map ) # only one course matches - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( old_style_course_id, Location('i4x', org, course, 'problem', 'abc123'), add_entry_if_missing=False @@ -106,14 +100,14 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(prob_locator.branch, 'published') self.assertEqual(prob_locator.usage_id, 'problem2') # look for w/ only the Location (works b/c there's only one possible course match) - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( None, Location('i4x', org, course, 'problem', 'abc123'), add_entry_if_missing=False ) self.assertEqual(prob_locator.course_id, new_style_course_id) # look for non-existent problem - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( None, Location('i4x', org, course, 'problem', '1def23'), add_entry_if_missing=False @@ -122,12 +116,12 @@ class TestLocationMapper(unittest.TestCase): # add a distractor course block_map = {'abc123': {'problem': 'problem3'}} - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'delta_run'), '{}.geek_dept.{}.{}'.format(org, course, 'delta_run'), block_map=block_map ) - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( old_style_course_id, Location('i4x', org, course, 'problem', 'abc123'), add_entry_if_missing=False @@ -135,7 +129,7 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(prob_locator.course_id, new_style_course_id) self.assertEqual(prob_locator.usage_id, 'problem2') # look for w/ only the Location (not unique; so, just verify it returns something) - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( None, Location('i4x', org, course, 'problem', 'abc123'), add_entry_if_missing=False @@ -143,13 +137,13 @@ class TestLocationMapper(unittest.TestCase): self.assertIsNotNone(prob_locator, "couldn't find ambiguous location") # add a default course pointing to the delta_run - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'problem', '789abc123efg456'), '{}.geek_dept.{}.{}'.format(org, course, 'delta_run'), block_map=block_map ) # now the ambiguous query should return delta - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( None, Location('i4x', org, course, 'problem', 'abc123'), add_entry_if_missing=False @@ -158,7 +152,7 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(prob_locator.usage_id, 'problem3') # get the draft one (I'm sorry this is getting long) - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( None, Location('i4x', org, course, 'problem', 'abc123'), published=False, @@ -178,7 +172,7 @@ class TestLocationMapper(unittest.TestCase): old_style_course_id = '{}/{}/{}'.format(org, course, 'baz_run') problem_name = 'abc123abc123abc123abc123abc123f9' location = Location('i4x', org, course, 'problem', problem_name) - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( old_style_course_id, location, add_entry_if_missing=True @@ -188,7 +182,7 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(prob_locator.branch, 'published') self.assertEqual(prob_locator.usage_id, 'problemabc') # look for w/ only the Location (works b/c there's only one possible course match) - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( None, location, add_entry_if_missing=True @@ -196,12 +190,12 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(prob_locator.course_id, new_style_course_id) # add a distractor course - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'delta_run'), '{}.geek_dept.{}.{}'.format(org, course, 'delta_run'), block_map={problem_name: {'problem': 'problem3'}} ) - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( old_style_course_id, location, add_entry_if_missing=True @@ -209,7 +203,7 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(prob_locator.course_id, new_style_course_id) self.assertEqual(prob_locator.usage_id, 'problemabc') # look for w/ only the Location (not unique; so, just verify it returns something) - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( None, location, add_entry_if_missing=True @@ -217,13 +211,13 @@ class TestLocationMapper(unittest.TestCase): self.assertIsNotNone(prob_locator, "couldn't find ambiguous location") # add a default course pointing to the delta_run - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'problem', '789abc123efg456'), '{}.geek_dept.{}.{}'.format(org, course, 'delta_run'), block_map={problem_name: {'problem': 'problem3'}} ) # now the ambiguous query should return delta - prob_locator = modulestore().translate_location( + prob_locator = loc_mapper().translate_location( None, location, add_entry_if_missing=False @@ -243,10 +237,10 @@ class TestLocationMapper(unittest.TestCase): course_id=new_style_course_id, usage_id='problem2' ) - prob_location = modulestore().translate_locator_to_location(prob_locator) + prob_location = loc_mapper().translate_locator_to_location(prob_locator) self.assertIsNone(prob_location, 'found entry in empty map table') - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'baz_run'), new_style_course_id, block_map={ @@ -255,33 +249,33 @@ class TestLocationMapper(unittest.TestCase): } ) # only one course matches - prob_location = modulestore().translate_locator_to_location(prob_locator) + prob_location = loc_mapper().translate_locator_to_location(prob_locator) # default branch self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None)) # explicit branch prob_locator = BlockUsageLocator(prob_locator, branch='draft') - prob_location = modulestore().translate_locator_to_location(prob_locator) + prob_location = loc_mapper().translate_locator_to_location(prob_locator) self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', 'draft')) prob_locator = BlockUsageLocator( course_id=new_style_course_id, usage_id='problem2', branch='production' ) - prob_location = modulestore().translate_locator_to_location(prob_locator) + prob_location = loc_mapper().translate_locator_to_location(prob_locator) self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None)) # same for chapter except chapter cannot be draft in old system chap_locator = BlockUsageLocator( course_id=new_style_course_id, usage_id='chapter48f' ) - chap_location = modulestore().translate_locator_to_location(chap_locator) + chap_location = loc_mapper().translate_locator_to_location(chap_locator) self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234')) # explicit branch chap_locator = BlockUsageLocator(chap_locator, branch='draft') - chap_location = modulestore().translate_locator_to_location(chap_locator) + chap_location = loc_mapper().translate_locator_to_location(chap_locator) self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234')) chap_locator = BlockUsageLocator( course_id=new_style_course_id, usage_id='chapter48f', branch='production' ) - chap_location = modulestore().translate_locator_to_location(chap_locator) + chap_location = loc_mapper().translate_locator_to_location(chap_locator) self.assertEqual(chap_location, Location('i4x', org, course, 'chapter', '48f23a10395384929234')) # look for non-existent problem @@ -290,21 +284,21 @@ class TestLocationMapper(unittest.TestCase): branch='draft', usage_id='problem3' ) - prob_location = modulestore().translate_locator_to_location(prob_locator2) + prob_location = loc_mapper().translate_locator_to_location(prob_locator2) self.assertIsNone(prob_location, 'Found non-existent problem') # add a distractor course new_style_course_id = '{}.geek_dept.{}.{}'.format(org, course, 'delta_run') - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'delta_run'), new_style_course_id, block_map={'abc123': {'problem': 'problem3'}} ) - prob_location = modulestore().translate_locator_to_location(prob_locator) + prob_location = loc_mapper().translate_locator_to_location(prob_locator) self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123', None)) # add a default course pointing to the delta_run - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'problem', '789abc123efg456'), new_style_course_id, block_map={'abc123': {'problem': 'problem3'}} @@ -315,7 +309,7 @@ class TestLocationMapper(unittest.TestCase): branch='production', usage_id='problem3' ) - prob_location = modulestore().translate_locator_to_location(prob_locator) + prob_location = loc_mapper().translate_locator_to_location(prob_locator) self.assertEqual(prob_location, Location('i4x', org, course, 'problem', 'abc123')) def test_add_block(self): @@ -329,48 +323,48 @@ class TestLocationMapper(unittest.TestCase): problem_name = 'abc123abc123abc123abc123abc123f9' location = Location('i4x', org, course, 'problem', problem_name) with self.assertRaises(ItemNotFoundError): - modulestore().add_block_location_translator(location) + loc_mapper().add_block_location_translator(location) with self.assertRaises(ItemNotFoundError): - modulestore().add_block_location_translator(location, old_style_course_id) + loc_mapper().add_block_location_translator(location, old_style_course_id) # w/ one matching course new_style_course_id = '{}.{}.{}'.format(org, course, 'baz_run') - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'baz_run'), new_style_course_id, ) - new_usage_id = modulestore().add_block_location_translator(location) + new_usage_id = loc_mapper().add_block_location_translator(location) self.assertEqual(new_usage_id, 'problemabc') # look it up - translated_loc = modulestore().translate_location(old_style_course_id, location, add_entry_if_missing=False) + translated_loc = loc_mapper().translate_location(old_style_course_id, location, add_entry_if_missing=False) self.assertEqual(translated_loc.course_id, new_style_course_id) self.assertEqual(translated_loc.usage_id, new_usage_id) # w/ one distractor which has one entry already new_style_course_id = '{}.geek_dept.{}.{}'.format(org, course, 'delta_run') - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'delta_run'), new_style_course_id, block_map={'48f23a10395384929234': {'chapter': 'chapter48f'}} ) # try adding the one added before - new_usage_id2 = modulestore().add_block_location_translator(location) + new_usage_id2 = loc_mapper().add_block_location_translator(location) self.assertEqual(new_usage_id, new_usage_id2) # it should be in the distractor now - new_location = modulestore().translate_locator_to_location( + new_location = loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id=new_style_course_id, usage_id=new_usage_id2) ) self.assertEqual(new_location, location) # add one close to the existing chapter (cause name collision) location = Location('i4x', org, course, 'chapter', '48f23a103953849292341234567890ab') - new_usage_id = modulestore().add_block_location_translator(location) + new_usage_id = loc_mapper().add_block_location_translator(location) self.assertRegexpMatches(new_usage_id, r'^chapter48f\d') # retrievable from both courses - new_location = modulestore().translate_locator_to_location( + new_location = loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id=new_style_course_id, usage_id=new_usage_id) ) self.assertEqual(new_location, location) - new_location = modulestore().translate_locator_to_location( + new_location = loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id='{}.{}.{}'.format(org, course, 'baz_run'), usage_id=new_usage_id) ) self.assertEqual(new_location, location) @@ -378,16 +372,16 @@ class TestLocationMapper(unittest.TestCase): # provoke duplicate item errors location = location.replace(name='44f23a103953849292341234567890ab') with self.assertRaises(DuplicateItemError): - modulestore().add_block_location_translator(location, usage_id=new_usage_id) - new_usage_id = modulestore().add_block_location_translator(location, old_course_id=old_style_course_id) + loc_mapper().add_block_location_translator(location, usage_id=new_usage_id) + new_usage_id = loc_mapper().add_block_location_translator(location, old_course_id=old_style_course_id) other_course_old_style = '{}/{}/{}'.format(org, course, 'delta_run') - new_usage_id2 = modulestore().add_block_location_translator( + new_usage_id2 = loc_mapper().add_block_location_translator( location, old_course_id=other_course_old_style, usage_id='{}b'.format(new_usage_id) ) with self.assertRaises(DuplicateItemError): - modulestore().add_block_location_translator(location) + loc_mapper().add_block_location_translator(location) def test_update_block(self): """ @@ -396,7 +390,7 @@ class TestLocationMapper(unittest.TestCase): org = 'foo_org' course = 'bar_course' new_style_course_id = '{}.geek_dept.{}.baz_run'.format(org, course) - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'baz_run'), new_style_course_id, block_map={ @@ -406,7 +400,7 @@ class TestLocationMapper(unittest.TestCase): } ) new_style_course_id2 = '{}.geek_dept.{}.delta_run'.format(org, course) - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'delta_run'), new_style_course_id2, block_map={ @@ -417,27 +411,27 @@ class TestLocationMapper(unittest.TestCase): ) location = Location('i4x', org, course, 'problem', '1') # change in all courses to same value - modulestore().update_block_location_translator(location, 'problem1') - trans_loc = modulestore().translate_locator_to_location( + loc_mapper().update_block_location_translator(location, 'problem1') + trans_loc = loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id=new_style_course_id, usage_id='problem1') ) self.assertEqual(trans_loc, location) - trans_loc = modulestore().translate_locator_to_location( + trans_loc = loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id=new_style_course_id2, usage_id='problem1') ) self.assertEqual(trans_loc, location) # try to change to overwrite used usage_id location = Location('i4x', org, course, 'chapter', '48f23a10395384929234') with self.assertRaises(DuplicateItemError): - modulestore().update_block_location_translator(location, 'chapter2') + loc_mapper().update_block_location_translator(location, 'chapter2') # just change the one course - modulestore().update_block_location_translator(location, 'chapter2', '{}/{}/{}'.format(org, course, 'baz_run')) - trans_loc = modulestore().translate_locator_to_location( + loc_mapper().update_block_location_translator(location, 'chapter2', '{}/{}/{}'.format(org, course, 'baz_run')) + trans_loc = loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id=new_style_course_id, usage_id='chapter2') ) self.assertEqual(trans_loc.name, '48f23a10395384929234') # but this still points to the old - trans_loc = modulestore().translate_locator_to_location( + trans_loc = loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id=new_style_course_id2, usage_id='chapter2') ) self.assertEqual(trans_loc.name, '1') @@ -450,7 +444,7 @@ class TestLocationMapper(unittest.TestCase): org = 'foo_org' course = 'bar_course' new_style_course_id = '{}.geek_dept.{}.baz_run'.format(org, course) - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'baz_run'), new_style_course_id, block_map={ @@ -460,7 +454,7 @@ class TestLocationMapper(unittest.TestCase): } ) new_style_course_id2 = '{}.geek_dept.{}.delta_run'.format(org, course) - modulestore().create_map_entry( + loc_mapper().create_map_entry( Location('i4x', org, course, 'course', 'delta_run'), new_style_course_id2, block_map={ @@ -471,22 +465,22 @@ class TestLocationMapper(unittest.TestCase): ) location = Location('i4x', org, course, 'problem', '1') # delete from all courses - modulestore().delete_block_location_translator(location) - self.assertIsNone(modulestore().translate_locator_to_location( + loc_mapper().delete_block_location_translator(location) + self.assertIsNone(loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id=new_style_course_id, usage_id='problem1') )) - self.assertIsNone(modulestore().translate_locator_to_location( + self.assertIsNone(loc_mapper().translate_locator_to_location( BlockUsageLocator(course_id=new_style_course_id2, usage_id='problem2') )) # delete from one course location = location.replace(name='abc123') - modulestore().delete_block_location_translator(location, '{}/{}/{}'.format(org, course, 'baz_run')) - self.assertIsNone(modulestore().translate_location( + loc_mapper().delete_block_location_translator(location, '{}/{}/{}'.format(org, course, 'baz_run')) + self.assertIsNone(loc_mapper().translate_location( '{}/{}/{}'.format(org, course, 'baz_run'), location, add_entry_if_missing=False )) - locator = modulestore().translate_location( + locator = loc_mapper().translate_location( '{}/{}/{}'.format(org, course, 'delta_run'), location, add_entry_if_missing=False @@ -495,8 +489,8 @@ class TestLocationMapper(unittest.TestCase): #================================== # functions to mock existing services -def modulestore(): - return TestLocationMapper.modulestore +def loc_mapper(): + return TestLocationMapper.loc_store def render_to_template_mock(*_args): pass From 2ad515d15a6b22395b37f64db786db227d0b96c5 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 12 Aug 2013 13:27:22 -0400 Subject: [PATCH 279/395] Move singleton accessor w/ django dependencies to same place as modulestore() singleton accessor --- common/lib/xmodule/xmodule/modulestore/django.py | 16 ++++++++++++++++ .../{LocMapperStore.py => loc_mapper_store.py} | 16 ---------------- .../modulestore/tests/test_location_mapper.py | 2 +- 3 files changed, 17 insertions(+), 17 deletions(-) rename common/lib/xmodule/xmodule/modulestore/{LocMapperStore.py => loc_mapper_store.py} (97%) diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index 2f0cd126f9..fb427f49ec 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -8,6 +8,7 @@ from __future__ import absolute_import from importlib import import_module from django.conf import settings +from xmodule.modulestore.loc_mapper_store import LocMapperStore _MODULESTORES = {} @@ -53,3 +54,18 @@ def modulestore(name='default'): settings.MODULESTORE[name]['OPTIONS']) return _MODULESTORES[name] + +_loc_singleton = None +def loc_mapper(): + """ + Get the loc mapper which bidirectionally maps Locations to Locators. Used like modulestore() as + a singleton accessor. + """ + # pylint: disable=W0603 + global _loc_singleton + # pylint: disable=W0212 + if _loc_singleton is None: + # instantiate + _loc_singleton = LocMapperStore(settings.modulestore_options) + return _loc_singleton + diff --git a/common/lib/xmodule/xmodule/modulestore/LocMapperStore.py b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py similarity index 97% rename from common/lib/xmodule/xmodule/modulestore/LocMapperStore.py rename to common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py index b71f647a27..06d99cc664 100644 --- a/common/lib/xmodule/xmodule/modulestore/LocMapperStore.py +++ b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py @@ -1,28 +1,15 @@ ''' Method for converting among our differing Location/Locator whatever reprs ''' -from __future__ import absolute_import from random import randint import re import pymongo -from django.conf import settings from xmodule.modulestore.exceptions import InvalidLocationError, ItemNotFoundError, DuplicateItemError from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.mongo import draft from xmodule.modulestore import Location -def loc_mapper(): - """ - Get the loc mapper which bidirectionally maps Locations to Locators. Used like modulestore() as - a singleton accessor. - """ - # pylint: disable=W0212 - if LocMapperStore._singleton is None: - # instantiate - LocMapperStore(settings.modulestore_options) - return LocMapperStore._singleton - class LocMapperStore(object): ''' This store persists mappings among the addressing schemes. At this time, it's between the old i4x Location @@ -38,8 +25,6 @@ class LocMapperStore(object): or dominant store, but that's not a requirement. This store creates its own connection. ''' - _singleton = None - def __init__(self, host, db, collection, port=27017, user=None, password=None, **kwargs): ''' Constructor @@ -55,7 +40,6 @@ class LocMapperStore(object): self.location_map = self.db[collection + '.location_map'] self.location_map.write_concern = {'w': 1} - LocMapperStore._singleton = self # location_map functions diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py index f4addd9ee9..95e9bc53d0 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py @@ -8,7 +8,7 @@ import uuid from xmodule.modulestore import Location from xmodule.modulestore.locator import BlockUsageLocator from xmodule.modulestore.exceptions import ItemNotFoundError, DuplicateItemError -from xmodule.modulestore.LocMapperStore import LocMapperStore +from xmodule.modulestore.loc_mapper_store import LocMapperStore class TestLocationMapper(unittest.TestCase): From bd8a7906f5c2fbc3f088a83eb656874a69d4fa09 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Mon, 12 Aug 2013 13:36:44 -0400 Subject: [PATCH 280/395] Clarify doc/comments on default v explicit mappings --- common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py | 5 +++-- .../xmodule/modulestore/tests/test_location_mapper.py | 2 ++ 2 files changed, 5 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py index 06d99cc664..f663681271 100644 --- a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py +++ b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py @@ -64,8 +64,9 @@ class LocMapperStore(object): throw an error depending on how mongo is configured. :param course_location: a Location preferably whose category is 'course'. Unlike the other - map methods, this one doesn't take the old-style course_id because it's assumed to be called with - a course location not a block location. + map methods, this one doesn't take the old-style course_id. It should be called with + a course location not a block location; however, if called w/ a non-course Location, it creates + a "default" map for the org/course pair to a new course_id. :param course_id: the CourseLocator style course_id :param draft_branch: the branch name to assign for drafts. This is hardcoded because old mongo had a fixed notion that there was 2 and only 2 versions for modules: draft and production. The old mongo diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py index 95e9bc53d0..d3df2b32db 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py @@ -43,6 +43,8 @@ class TestLocationMapper(unittest.TestCase): self.assertEqual(entry['prod_branch'], 'published') self.assertEqual(entry['block_map'], {}) + # ensure create_entry does the right thing when not given a course (creates org/course + # rather than org/course/run course_id) loc_mapper().create_map_entry(Location('i4x', org, course, 'vertical', 'baz_vert')) entry = loc_mapper().location_map.find_one({ '_id': {'org': org, 'course': course} From 8f530d12c759bf1d55817d1a04f22be7a14da575 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Fri, 16 Aug 2013 15:59:04 -0400 Subject: [PATCH 281/395] Raise ItemNotFoundError rather than return None if no map found --- .../xmodule/modulestore/loc_mapper_store.py | 13 ++++--- .../modulestore/tests/test_location_mapper.py | 35 ++++++++++--------- 2 files changed, 26 insertions(+), 22 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py index f663681271..391e1b8a69 100644 --- a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py +++ b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py @@ -107,10 +107,13 @@ class LocMapperStore(object): if the code just trips into this w/o creating translations. The downfall is that ambiguous course locations may generate conflicting block_ids. + Will raise ItemNotFoundError if there's no mapping and add_entry_if_missing is False. + :param old_style_course_id: the course_id used in old mongo not the new one (optional, will use location) :param location: a Location pointing to a module :param published: a boolean to indicate whether the caller wants the draft or published branch. - :param add_entry_if_missing: a boolean as to whether to return None or to create an entry if the course + :param add_entry_if_missing: a boolean as to whether to raise ItemNotFoundError or to create an entry if + the course or block is not found in the map. NOTE: unlike old mongo, draft branches contain the whole course; so, it applies to all category @@ -126,7 +129,7 @@ class LocMapperStore(object): self.create_map_entry(course_location) entry = self.location_map.find_one(location_id) else: - return None + raise ItemNotFoundError() elif maps.count() > 1: # if more than one, prefer the one w/o a name if that exists. Otherwise, choose the first (arbitrary) # a bit odd b/c maps is a cursor and doesn't allow multitraversal w/o requerying db @@ -150,7 +153,7 @@ class LocMapperStore(object): if add_entry_if_missing: usage_id = self._add_to_block_map(location, location_id, entry['block_map']) else: - return None + raise ItemNotFoundError() elif isinstance(usage_id, dict): # name is not unique, look through for the right category if location.category in usage_id: @@ -158,7 +161,7 @@ class LocMapperStore(object): elif add_entry_if_missing: usage_id = self._add_to_block_map(location, location_id, entry['block_map']) else: - return None + raise ItemNotFoundError() else: raise InvalidLocationError() @@ -179,7 +182,7 @@ class LocMapperStore(object): :param locator: a BlockUsageLocator """ - # Does not use _lookup_course b/c it doesn't actually require that the course exist in the active_version + # This does not require that the course exist in any modulestore # only that it has a mapping entry. maps = self.location_map.find({'course_id': locator.course_id}) # look for one which maps to this block usage_id diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py index d3df2b32db..6797870fdb 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location_mapper.py @@ -78,12 +78,12 @@ class TestLocationMapper(unittest.TestCase): org = 'foo_org' course = 'bar_course' old_style_course_id = '{}/{}/{}'.format(org, course, 'baz_run') - prob_locator = loc_mapper().translate_location( - old_style_course_id, - Location('i4x', org, course, 'problem', 'abc123'), - add_entry_if_missing=False - ) - self.assertIsNone(prob_locator, 'found entry in empty map table') + with self.assertRaises(ItemNotFoundError): + _ = loc_mapper().translate_location( + old_style_course_id, + Location('i4x', org, course, 'problem', 'abc123'), + add_entry_if_missing=False + ) new_style_course_id = '{}.geek_dept.{}.baz_run'.format(org, course) block_map = {'abc123': {'problem': 'problem2'}} @@ -109,12 +109,12 @@ class TestLocationMapper(unittest.TestCase): ) self.assertEqual(prob_locator.course_id, new_style_course_id) # look for non-existent problem - prob_locator = loc_mapper().translate_location( - None, - Location('i4x', org, course, 'problem', '1def23'), - add_entry_if_missing=False - ) - self.assertIsNone(prob_locator, "Found non-existent problem") + with self.assertRaises(ItemNotFoundError): + prob_locator = loc_mapper().translate_location( + None, + Location('i4x', org, course, 'problem', '1def23'), + add_entry_if_missing=False + ) # add a distractor course block_map = {'abc123': {'problem': 'problem3'}} @@ -477,11 +477,12 @@ class TestLocationMapper(unittest.TestCase): # delete from one course location = location.replace(name='abc123') loc_mapper().delete_block_location_translator(location, '{}/{}/{}'.format(org, course, 'baz_run')) - self.assertIsNone(loc_mapper().translate_location( - '{}/{}/{}'.format(org, course, 'baz_run'), - location, - add_entry_if_missing=False - )) + with self.assertRaises(ItemNotFoundError): + loc_mapper().translate_location( + '{}/{}/{}'.format(org, course, 'baz_run'), + location, + add_entry_if_missing=False + ) locator = loc_mapper().translate_location( '{}/{}/{}'.format(org, course, 'delta_run'), location, From 998a25e7b349b10f8bd100031074af4841035113 Mon Sep 17 00:00:00 2001 From: Don Mitchell Date: Fri, 16 Aug 2013 16:00:29 -0400 Subject: [PATCH 282/395] Use a sort of name to choose if > 1 map and some minor comment changes. --- .../xmodule/modulestore/loc_mapper_store.py | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py index 391e1b8a69..8aed06be79 100644 --- a/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py +++ b/common/lib/xmodule/xmodule/modulestore/loc_mapper_store.py @@ -121,7 +121,7 @@ class LocMapperStore(object): """ location_id = self._interpret_location_course_id(old_style_course_id, location) - maps = self.location_map.find(location_id) + maps = self.location_map.find(location_id).sort('_id.name', pymongo.ASCENDING) if maps.count() == 0: if add_entry_if_missing: # create a new map @@ -131,15 +131,8 @@ class LocMapperStore(object): else: raise ItemNotFoundError() elif maps.count() > 1: - # if more than one, prefer the one w/o a name if that exists. Otherwise, choose the first (arbitrary) - # a bit odd b/c maps is a cursor and doesn't allow multitraversal w/o requerying db - entry = None - for candidate in maps: - if entry is None: - entry = candidate # pick off first ele in case we don't find one w/o a name - if 'name' not in candidate['_id']: - entry = candidate - break + # if more than one, prefer the one w/o a name if that exists. Otherwise, choose the first (alphabetically) + entry = maps[0] else: entry = maps[0] @@ -240,7 +233,7 @@ class LocMapperStore(object): raise ItemNotFoundError() # turn maps from cursor to list - map_list = [map_entry for map_entry in maps] + map_list = list(maps) # check whether there's already a usage_id for this location (and it agrees w/ any passed in or found) for map_entry in map_list: if (location.name in map_entry['block_map'] and @@ -336,7 +329,7 @@ class LocMapperStore(object): def _add_to_block_map(self, location, location_id, block_map): '''add the given location to the block_map and persist it''' if self._block_id_is_guid(location.name): - # I'm having second thoughts about this even though it will make the ids more meaningful. + # This makes the ids more meaningful with a small probability of name collision. # The downside is that if there's more than one course mapped to from the same org/course root # the block ids will likely be out of sync and collide from an id perspective. HOWEVER, # if there are few == org/course roots or their content is unrelated, this will work well. From f17987c13ae7b2745376649baf304579af19dc3e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 17:37:53 -0400 Subject: [PATCH 283/395] Fix flagging, select rubric labels in peer grading --- .../xmodule/css/combinedopenended/display.scss | 11 +++++++++++ .../js/src/peergrading/peer_grading_problem.coffee | 9 ++++++++- common/lib/xmodule/xmodule/peer_grading_module.py | 9 ++++++--- 3 files changed, 25 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 120ea0c52d..551d1c181f 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -234,6 +234,9 @@ section.combined-open-ended-status { &:hover { background-color: #eee; } + .rubric-label-selected{ + border: 2px solid #666; + } } span.rubric-category { @@ -952,3 +955,11 @@ section.open-ended-child { } } } + +section.peer-grading-container{ + div.peer-grading{ + section.calibration-feedback { + padding: 20px; + } + } +} diff --git a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee index 8771b4fe91..4151bda33d 100644 --- a/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee +++ b/common/lib/xmodule/xmodule/js/src/peergrading/peer_grading_problem.coffee @@ -338,13 +338,15 @@ class @PeerGradingProblem remove_flag: () => @flag_student_checkbox.removeAttr("checked") @close_dialog_box() + @submit_button.attr('disabled', true) close_dialog_box: () => - @$(@flag_submission_confirmation_sel).dialog('close') + $(@flag_submission_confirmation_sel).dialog('close') flag_box_checked: () => if @flag_student_checkbox.is(':checked') @$(@flag_submission_confirmation_sel).dialog({ height: 400, width: 400 }) + @submit_button.attr('disabled', false) # called after we perform an is_student_calibrated check calibration_check_callback: (response) => @@ -397,6 +399,10 @@ class @PeerGradingProblem # called after a grade is selected on the interface graded_callback: (event) => + ev = @$(event.target).parent().parent() + ul = ev.parent().parent() + ul.find(".rubric-label-selected").removeClass('rubric-label-selected') + ev.addClass('rubric-label-selected') # check to see whether or not any categories have not been scored if @rub.check_complete() # show button if we have scores for all categories @@ -479,6 +485,7 @@ class @PeerGradingProblem @answer_unknown_container.show() @feedback_area.val("") + @flag_student_checkbox.removeAttr("checked") @submit_button.show() @submit_button.unbind('click') @submit_button.click @submit_grade diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index e8409948e9..d60f448d3b 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -310,13 +310,16 @@ class PeerGradingModule(PeerGradingFields, XModule): error: if there was an error in the submission, this is the error message """ - required = set(['location', 'submission_id', 'submission_key', 'score', 'feedback', 'rubric_scores[]', 'submission_flagged', 'answer_unknown']) - success, message = self._check_required(data, required) + required = ['location', 'submission_id', 'submission_key', 'score', 'feedback', 'submission_flagged', 'answer_unknown'] + if 'submission_flagged' not in data or data['submission_flagged'] in ["false", False, "False"]: + required.append("rubric_scores[]") + success, message = self._check_required(data, set(required)) if not success: return self._err_response(message) data_dict = {k:data.get(k) for k in required} - data_dict['rubric_scores'] = data.getlist('rubric_scores[]') + if 'rubric_scores[]' in required: + data_dict['rubric_scores'] = data.getlist('rubric_scores[]') data_dict['grader_id'] = self.system.anonymous_student_id try: From 033f922ec03f8323f44380e653ab1b867e9947a0 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Sat, 17 Aug 2013 10:15:03 -0400 Subject: [PATCH 284/395] add a try catch around the staticstorage lookup, which can throw an exception --- common/djangoapps/static_replace/__init__.py | 11 +++++++++- common/test/data/toy/course/2012_Fall.xml | 1 + common/test/data/toy/html/badlink.html | 1 + common/test/data/toy/html/badlink.xml | 1 + .../courseware/tests/test_module_render.py | 21 ++++++++++++++++++- 5 files changed, 33 insertions(+), 2 deletions(-) create mode 100644 common/test/data/toy/html/badlink.html create mode 100644 common/test/data/toy/html/badlink.xml diff --git a/common/djangoapps/static_replace/__init__.py b/common/djangoapps/static_replace/__init__.py index 63c576cdd2..d7f2df8322 100644 --- a/common/djangoapps/static_replace/__init__.py +++ b/common/djangoapps/static_replace/__init__.py @@ -119,7 +119,15 @@ def replace_static_urls(text, data_directory, course_id=None): elif course_id and modulestore().get_modulestore_type(course_id) != XML_MODULESTORE_TYPE: # first look in the static file pipeline and see if we are trying to reference # a piece of static content which is in the mitx repo (e.g. JS associated with an xmodule) - if staticfiles_storage.exists(rest): + + exists_in_staticfiles_storage = False + try: + exists_in_staticfiles_storage = staticfiles_storage.exists(rest) + except Exception as err: + log.warning("staticfiles_storage couldn't find path {0}: {1}".format( + rest, str(err))) + + if exists_in_staticfiles_storage: url = staticfiles_storage.url(rest) else: # if not, then assume it's courseware specific content and then look in the @@ -142,6 +150,7 @@ def replace_static_urls(text, data_directory, course_id=None): return "".join([quote, url, quote]) + return re.sub( _url_replace_regex('/static/(?!{data_dir})'.format(data_dir=data_directory)), replace_static_url, diff --git a/common/test/data/toy/course/2012_Fall.xml b/common/test/data/toy/course/2012_Fall.xml index 9b14d49dcd..ec75ef0b9d 100644 --- a/common/test/data/toy/course/2012_Fall.xml +++ b/common/test/data/toy/course/2012_Fall.xml @@ -7,6 +7,7 @@ +
    - -
    @@ -30,8 +28,6 @@
    - -
    From fb7fe13061ca3c1611f184f106b46a2be22c8d11 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Tue, 20 Aug 2013 09:21:04 -0400 Subject: [PATCH 316/395] adds in the .reset type of action to the x module capa styling --- common/lib/xmodule/xmodule/css/capa/display.scss | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 9d35e8cd40..2d8fb765d9 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -577,7 +577,7 @@ section.problem { section.action { margin-top: 20px; - .save, .check, .show { + .save, .check, .show, .reset { height: ($baseline*2); font-weight: 600; vertical-align: middle; From 38165efd18ef1a84a23e6ef6bbe5978691c4d0c4 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 20 Aug 2013 12:42:36 -0400 Subject: [PATCH 317/395] Fix failing grade label being editable but not saved. --- .../js/views/settings/settings_grading_view.js | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cms/static/js/views/settings/settings_grading_view.js b/cms/static/js/views/settings/settings_grading_view.js index 8c2af25f8c..b6fac04899 100644 --- a/cms/static/js/views/settings/settings_grading_view.js +++ b/cms/static/js/views/settings/settings_grading_view.js @@ -8,7 +8,7 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ // Leaving change in as fallback for older browsers "change input" : "updateModel", "change textarea" : "updateModel", - "input span[contenteditable]" : "updateDesignation", + "input span[contenteditable=true]" : "updateDesignation", "click .settings-extra header" : "showSettingsExtras", "click .new-grade-button" : "addNewGrade", "click .remove-button" : "removeGrade", @@ -20,7 +20,7 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ initialize : function() { // load template for grading view var self = this; - this.gradeCutoffTemplate = _.template('
  • ' + + this.gradeCutoffTemplate = _.template('
  • ' + '<%= descriptor %>' + '' + '<% if (removable) {%>remove<% ;} %>' + @@ -168,9 +168,12 @@ CMS.Views.Settings.Grading = CMS.Views.ValidatingView.extend({ }, this); // add fail which is not in data - var failBar = this.gradeCutoffTemplate({ descriptor : this.failLabel(), - width : nextWidth, removable : false}); - $(failBar).find("span[contenteditable=true]").attr("contenteditable", false); + var failBar = $(this.gradeCutoffTemplate({ + descriptor : this.failLabel(), + width : nextWidth, + removable : false + })); + failBar.find("span[contenteditable=true]").attr("contenteditable", false); gradelist.append(failBar); gradelist.children().last().resizable({ handles: "e", From 533e0da1011d4af888c55c7b2935b42ecbac2fd0 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 20 Aug 2013 13:34:41 -0400 Subject: [PATCH 318/395] Add acceptance test for noneditable failing grade range. --- .../contentstore/features/grading.feature | 6 ++++++ cms/djangoapps/contentstore/features/grading.py | 13 +++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cms/djangoapps/contentstore/features/grading.feature b/cms/djangoapps/contentstore/features/grading.feature index 4b5cacc159..0e0ce561ee 100644 --- a/cms/djangoapps/contentstore/features/grading.feature +++ b/cms/djangoapps/contentstore/features/grading.feature @@ -93,3 +93,9 @@ Feature: Course Grading And I press the "Save" notification button And I reload the page Then I see the highest grade range is "Good" + + Scenario: User cannot edit failing grade range name + Given I have opened a new course in Studio + And I have populated the course + And I am viewing the grading settings + Then I cannot edit the "Fail" grade range diff --git a/cms/djangoapps/contentstore/features/grading.py b/cms/djangoapps/contentstore/features/grading.py index 40cba61edc..bedab86bd9 100644 --- a/cms/djangoapps/contentstore/features/grading.py +++ b/cms/djangoapps/contentstore/features/grading.py @@ -4,6 +4,7 @@ from lettuce import world, step from common import * from terrain.steps import reload_the_page +from selenium.common.exceptions import InvalidElementStateException @step(u'I am viewing the grading settings') @@ -130,6 +131,18 @@ def i_see_highest_grade_range(_step, range_name): grade = world.css_find(range_css).first assert grade.value == range_name + +@step(u'I cannot edit the "Fail" grade range$') +def cannot_edit_fail(_step): + range_css = 'span.letter-grade' + ranges = world.css_find(range_css) + assert len(ranges) == 2 + try: + ranges.last.value = 'Failure' + assert False, "Should not be able to edit failing range" + except InvalidElementStateException: + pass # We should get this exception on failing to edit the element + def get_type_index(name): name_id = '#course-grading-assignment-name' all_types = world.css_find(name_id) From ab2570a7bdd542cdb93c88f0de46dd2616688d85 Mon Sep 17 00:00:00 2001 From: Peter Baratta Date: Tue, 20 Aug 2013 13:01:59 -0400 Subject: [PATCH 319/395] Add docs for changes in #726 --- docs/course_authors/source/appendices/e.rst | 43 ++++++++++++++++++--- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/docs/course_authors/source/appendices/e.rst b/docs/course_authors/source/appendices/e.rst index 9eb74adf09..d6e06f0d9c 100644 --- a/docs/course_authors/source/appendices/e.rst +++ b/docs/course_authors/source/appendices/e.rst @@ -473,12 +473,39 @@ Answers with scripts .. image:: ../Images/numericalresponse.png - + +```` + ++------------+----------------------------------------------+-------------------------------+ +| Attribute | Description | Notes | ++============+==============================================+===============================+ +| ``answer`` | A value to which student input must be | Note that any numeric | +| | equivalent. Note that this expression can be | expression provided by the | +| | expressed in terms of a variable that is | student will be automatically | +| | computed in a script provided in the problem | simplified on the grader's | +| | by preceding the appropriate variable name | backend. | +| | with a dollar sign. | | +| | | | +| | This answer will be evaluated similar to a | | +| | student's input. Thus '1/3' and 'sin(pi/5)' | | +| | are valid, as well as simpler expressions, | | +| | such as '0.3' and '42' | | ++------------+----------------------------------------------+-------------------------------+ - .. image:: ../Images/numericalresponse2.png ++------------------------+--------------------------------------------+--------------------------------------+ +| Children | Description | Notes | ++========================+============================================+======================================+ +| ``responseparam`` | used to specify a tolerance on the accepted| | +| | values of a number. See description below. | | ++------------------------+--------------------------------------------+--------------------------------------+ +|``formulaequationinput``| An input specifically for taking math | | +| | input from students. See below. | | ++------------------------+--------------------------------------------+--------------------------------------+ +| ``textline`` | A format to take input from students, see | Deprecated for NumericalResponse. | +| | description below. | Use ``formulaequationinput`` instead.| ++------------------------+--------------------------------------------+--------------------------------------+ -Children may include ````. @@ -494,7 +521,9 @@ size (optional) defines the size (i.e. the width) typing their math expression. ========= ============================================= ===== - (While is supported, its use is extremely discouraged. We urge usage of . See the opening paragraphs of the Numerical Response section for more information.) + (While is supported, its use is extremely discouraged. +We urge usage of . See the opening paragraphs of the +`Numerical Response`_ section for more information.) .. image:: ../Images/numericalresponse5.png @@ -563,7 +592,8 @@ by ``||``. For example, an input of ``1 || 2`` would represent the resistance of a pair of parallel resistors (of resistance 1 and 2 ohms), evaluating to 2/3 (ohms). -At the time of writing, factorials written in the form '3!' are invalid, but there is a workaround. Students can specify ``fact(3)`` or ``factorial(3)`` to +At the time of writing, factorials written in the form '3!' are invalid, but +there is a workaround. Students can specify ``fact(3)`` or ``factorial(3)`` to access the factorial function. The default included functions are the following: @@ -573,7 +603,8 @@ The default included functions are the following: - Other common functions: sqrt, log10, log2, ln, exp, abs - Factorial: ``fact(3)`` or ``factorial(3)`` are valid. However, you must take care to only input integers. For example, ``fact(1.5)`` would fail. -- Hyperbolic trig functions and their inverses: sinh, cosh, tanh, sech, csch, coth, arcsinh, arccosh, arctanh, arcsech, arccsch, arccoth +- Hyperbolic trig functions and their inverses: sinh, cosh, tanh, sech, csch, + coth, arcsinh, arccosh, arctanh, arcsech, arccsch, arccoth .. raw:: latex From cb7a3ad8eb4fa92efde7bc27e1bab9374800a8df Mon Sep 17 00:00:00 2001 From: Miles Steele Date: Mon, 19 Aug 2013 11:25:44 -0400 Subject: [PATCH 320/395] switch from log_event to Logger.log keep log_event for compatibility --- common/static/coffee/src/logger.coffee | 4 +++- common/static/js/pdfviewer.js | 6 +++--- lms/static/coffee/spec/navigation_spec.coffee | 5 ++--- lms/static/coffee/src/navigation.coffee | 2 +- lms/templates/staticbook.html | 6 +++--- 5 files changed, 12 insertions(+), 11 deletions(-) diff --git a/common/static/coffee/src/logger.coffee b/common/static/coffee/src/logger.coffee index 980d293ad8..5a13ca8264 100644 --- a/common/static/coffee/src/logger.coffee +++ b/common/static/coffee/src/logger.coffee @@ -42,5 +42,7 @@ class @Logger page: window.location.href async: false -# Keeping this for compatibility issue only. + +# log_event exists for compatibility reasons +# and will soon be deprecated. @log_event = Logger.log diff --git a/common/static/js/pdfviewer.js b/common/static/js/pdfviewer.js index c81709fa0f..258541e0ad 100644 --- a/common/static/js/pdfviewer.js +++ b/common/static/js/pdfviewer.js @@ -157,7 +157,7 @@ PDFJS.disableWorker = true; } // Update logging: - log_event("book", { "type" : "gotopage", "old" : pageNum, "new" : num }); + Logger.log("book", { "type" : "gotopage", "old" : pageNum, "new" : num }); parentElement = viewerElement; while (parentElement.hasChildNodes()) @@ -207,7 +207,7 @@ PDFJS.disableWorker = true; if (pageNum <= 1) return; renderPage(pageNum - 1); - log_event("book", { "type" : "prevpage", "new" : pageNum }); + Logger.log("book", { "type" : "prevpage", "new" : pageNum }); } // Go to next page @@ -215,7 +215,7 @@ PDFJS.disableWorker = true; if (pageNum >= pdfDocument.numPages) return; renderPage(pageNum + 1); - log_event("book", { "type" : "nextpage", "new" : pageNum }); + Logger.log("book", { "type" : "nextpage", "new" : pageNum }); } selectScaleOption = function(value) { diff --git a/lms/static/coffee/spec/navigation_spec.coffee b/lms/static/coffee/spec/navigation_spec.coffee index b351164b63..4d1f00f5a7 100644 --- a/lms/static/coffee/spec/navigation_spec.coffee +++ b/lms/static/coffee/spec/navigation_spec.coffee @@ -57,8 +57,7 @@ describe 'Navigation', -> describe 'log', -> beforeEach -> - window.log_event = -> - spyOn window, 'log_event' + spyOn Logger, 'log' it 'submit event log', -> @navigation.log {}, { @@ -68,6 +67,6 @@ describe 'Navigation', -> text: -> "old" } - expect(window.log_event).toHaveBeenCalledWith 'accordion', + expect(Logger.log).toHaveBeenCalledWith 'accordion', newheader: 'new' oldheader: 'old' diff --git a/lms/static/coffee/src/navigation.coffee b/lms/static/coffee/src/navigation.coffee index 3f89a6ce1d..06c38b781a 100644 --- a/lms/static/coffee/src/navigation.coffee +++ b/lms/static/coffee/src/navigation.coffee @@ -20,7 +20,7 @@ class @Navigation $('#accordion a').click @setChapter log: (event, ui) -> - log_event 'accordion', + Logger.log 'accordion', newheader: ui.newHeader.text() oldheader: ui.oldHeader.text() diff --git a/lms/templates/staticbook.html b/lms/templates/staticbook.html index 09741e0a9e..3b32585c7a 100644 --- a/lms/templates/staticbook.html +++ b/lms/templates/staticbook.html @@ -25,7 +25,7 @@ $(document).ready(function(){ }); function goto_page(n) { - log_event("book", {"type":"gotopage","old":page,"new":n}); + Logger.log("book", {"type":"gotopage","old":page,"new":n}); page=n; var prefix = ""; if(n<100) { @@ -42,14 +42,14 @@ function prev_page() { var newpage=page-1; if(newpage< ${start_page}) newpage=${start_page}; goto_page(newpage); - log_event("book", {"type":"prevpage","new":page}); + Logger.log("book", {"type":"prevpage","new":page}); } function next_page() { var newpage=page+1; if(newpage> ${end_page}) newpage=${end_page}; goto_page(newpage); - log_event("book", {"type":"nextpage","new":page}); + Logger.log("book", {"type":"nextpage","new":page}); } $("#open_close_accordion a").click(function(){ From 210fa112f325a96d8a207b3538d994c1375e4c63 Mon Sep 17 00:00:00 2001 From: ichuang Date: Tue, 20 Aug 2013 08:56:03 -0400 Subject: [PATCH 321/395] modify handling of info/handouts and module_render to honor static_asset_path --- lms/djangoapps/courseware/courses.py | 5 ++++- lms/djangoapps/courseware/module_render.py | 26 +++++++++++++++------- 2 files changed, 22 insertions(+), 9 deletions(-) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index eb7d690116..b63828eba5 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -196,6 +196,8 @@ def get_course_info_section(request, course, section_key): loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key) + log.debug("in get_course_info, static_asset_path=%s" % course.lms.static_asset_path) + # Use an empty cache model_data_cache = ModelDataCache([], course.id, request.user) info_module = get_module( @@ -204,7 +206,8 @@ def get_course_info_section(request, course, section_key): loc, model_data_cache, course.id, - wrap_xmodule_display=False + wrap_xmodule_display=False, + static_asset_path=course.lms.static_asset_path ) html = '' diff --git a/lms/djangoapps/courseware/module_render.py b/lms/djangoapps/courseware/module_render.py index ba4654ce9c..981ccde441 100644 --- a/lms/djangoapps/courseware/module_render.py +++ b/lms/djangoapps/courseware/module_render.py @@ -124,7 +124,8 @@ def toc_for_course(user, request, course, active_chapter, active_section, model_ def get_module(user, request, location, model_data_cache, course_id, position=None, not_found_ok=False, wrap_xmodule_display=True, - grade_bucket_type=None, depth=0): + grade_bucket_type=None, depth=0, + static_asset_path=''): """ Get an instance of the xmodule class identified by location, setting the state based on an existing StudentModule, or creating one if none @@ -141,6 +142,10 @@ def get_module(user, request, location, model_data_cache, course_id, position within module - depth : number of levels of descendents to cache when loading this module. None means cache all descendents + - static_asset_path : static asset path to use (overrides descriptor's value); needed + by get_course_info_section, because info section modules + do not have a course as the parent module, and thus do not + inherit this lms key value. Returns: xmodule instance, or None if the user does not have access to the module. If there's an error, will try to return an instance of ErrorModule @@ -152,7 +157,8 @@ def get_module(user, request, location, model_data_cache, course_id, return get_module_for_descriptor(user, request, descriptor, model_data_cache, course_id, position=position, wrap_xmodule_display=wrap_xmodule_display, - grade_bucket_type=grade_bucket_type) + grade_bucket_type=grade_bucket_type, + static_asset_path=static_asset_path) except ItemNotFoundError: if not not_found_ok: log.exception("Error in get_module") @@ -179,7 +185,8 @@ def get_xqueue_callback_url_prefix(request): def get_module_for_descriptor(user, request, descriptor, model_data_cache, course_id, - position=None, wrap_xmodule_display=True, grade_bucket_type=None): + position=None, wrap_xmodule_display=True, grade_bucket_type=None, + static_asset_path=''): """ Implements get_module, extracting out the request-specific functionality. @@ -194,12 +201,14 @@ def get_module_for_descriptor(user, request, descriptor, model_data_cache, cours return get_module_for_descriptor_internal(user, descriptor, model_data_cache, course_id, track_function, xqueue_callback_url_prefix, - position, wrap_xmodule_display, grade_bucket_type) + position, wrap_xmodule_display, grade_bucket_type, + static_asset_path) def get_module_for_descriptor_internal(user, descriptor, model_data_cache, course_id, track_function, xqueue_callback_url_prefix, - position=None, wrap_xmodule_display=True, grade_bucket_type=None): + position=None, wrap_xmodule_display=True, grade_bucket_type=None, + static_asset_path=''): """ Actually implement get_module, without requiring a request. @@ -282,7 +291,8 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours # inner_get_module, not the parent's callback. Add it as an argument.... return get_module_for_descriptor_internal(user, descriptor, model_data_cache, course_id, track_function, make_xqueue_callback, - position, wrap_xmodule_display, grade_bucket_type) + position, wrap_xmodule_display, grade_bucket_type, + static_asset_path) def xblock_model_data(descriptor): return DbModel( @@ -349,7 +359,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours static_replace.replace_static_urls, data_directory=getattr(descriptor, 'data_dir', None), course_id=course_id, - static_asset_path=descriptor.lms.static_asset_path, + static_asset_path=static_asset_path or descriptor.lms.static_asset_path, ), replace_course_urls=partial( static_replace.replace_course_urls, @@ -409,7 +419,7 @@ def get_module_for_descriptor_internal(user, descriptor, model_data_cache, cours _get_html, getattr(descriptor, 'data_dir', None), course_id=course_id, - static_asset_path=descriptor.lms.static_asset_path + static_asset_path=static_asset_path or descriptor.lms.static_asset_path ) # Allow URLs of the form '/course/' refer to the root of multicourse directory From 2fe4895e43a24e23995968fe2e673665893568ed Mon Sep 17 00:00:00 2001 From: ichuang Date: Tue, 20 Aug 2013 17:03:44 -0400 Subject: [PATCH 322/395] more static_asset_path handling in courses.py --- lms/djangoapps/courseware/courses.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index b63828eba5..23890ba8a7 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -156,7 +156,8 @@ def get_course_about_section(course, section_key): model_data_cache, course.id, not_found_ok=True, - wrap_xmodule_display=False + wrap_xmodule_display=False, + static_asset_path=course.lms.static_asset_path ) html = '' @@ -245,7 +246,8 @@ def get_course_syllabus_section(course, section_key): return replace_static_urls( htmlFile.read().decode('utf-8'), getattr(course, 'data_dir', None), - course_id=course.location.course_id + course_id=course.location.course_id, + static_asset_path=course.lms.static_asset_path, ) except ResourceNotFoundError: log.exception("Missing syllabus section {key} in course {url}".format( From fedfa7cab7a37049c7057ab504da7ae6a7c4f580 Mon Sep 17 00:00:00 2001 From: ichuang Date: Tue, 20 Aug 2013 17:57:46 -0400 Subject: [PATCH 323/395] fix tabs.py to properly handle static_asset_path --- lms/djangoapps/courseware/tabs.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/tabs.py b/lms/djangoapps/courseware/tabs.py index 38f81a476c..d7e1158e99 100644 --- a/lms/djangoapps/courseware/tabs.py +++ b/lms/djangoapps/courseware/tabs.py @@ -380,7 +380,8 @@ def get_static_tab_contents(request, course, tab): loc = Location(course.location.tag, course.location.org, course.location.course, 'static_tab', tab['url_slug']) model_data_cache = ModelDataCache.cache_for_descriptor_descendents(course.id, request.user, modulestore().get_instance(course.id, loc), depth=0) - tab_module = get_module(request.user, request, loc, model_data_cache, course.id) + tab_module = get_module(request.user, request, loc, model_data_cache, course.id, + static_asset_path=course.lms.static_asset_path) logging.debug('course_module = {0}'.format(tab_module)) From 91bf6ad86dd4de9f9263f9bb2b2fde585a66ba2d Mon Sep 17 00:00:00 2001 From: ichuang Date: Tue, 20 Aug 2013 18:11:40 -0400 Subject: [PATCH 324/395] remove extra debugging line from courses.py --- lms/djangoapps/courseware/courses.py | 2 -- 1 file changed, 2 deletions(-) diff --git a/lms/djangoapps/courseware/courses.py b/lms/djangoapps/courseware/courses.py index 23890ba8a7..676fdf4941 100644 --- a/lms/djangoapps/courseware/courses.py +++ b/lms/djangoapps/courseware/courses.py @@ -197,8 +197,6 @@ def get_course_info_section(request, course, section_key): loc = Location(course.location.tag, course.location.org, course.location.course, 'course_info', section_key) - log.debug("in get_course_info, static_asset_path=%s" % course.lms.static_asset_path) - # Use an empty cache model_data_cache = ModelDataCache([], course.id, request.user) info_module = get_module( From a2bbb65dcf6df51e9c4914a6343681bcd795db5e Mon Sep 17 00:00:00 2001 From: Kevin Chugh Date: Tue, 20 Aug 2013 19:42:03 -0400 Subject: [PATCH 325/395] refactor to add access control to already_existing access control routines in access.py --- lms/djangoapps/courseware/access.py | 9 +++++++++ .../django_comment_client/forum/views.py | 17 ++++++----------- 2 files changed, 15 insertions(+), 11 deletions(-) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 8259507617..7c85e1e787 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -114,6 +114,7 @@ def _has_access_course_desc(user, course, action): Valid actions: 'load' -- load the courseware, see inside the course + 'load_forum' -- can load and contribute to the forums (one access level for now) 'enroll' -- enroll. Checks for enrollment window, ACCESS_REQUIRE_STAFF_FOR_COURSE, 'see_exists' -- can see that the course exists. @@ -128,6 +129,13 @@ def _has_access_course_desc(user, course, action): # delegate to generic descriptor check to check start dates return _has_access_descriptor(user, course, 'load') + def can_load_forum(): + """ + Can this user access the forums in this course? + """ + return (CourseEnrollment.is_enrolled(request.user, course_id) or \ + _has_staff_access_to_descriptor(user, course) + def can_enroll(): """ First check if restriction of enrollment by login method is enabled, both @@ -193,6 +201,7 @@ def _has_access_course_desc(user, course, action): checkers = { 'load': can_load, + 'load_forum': can_load_forum, 'enroll': can_enroll, 'see_exists': see_exists, 'staff': lambda: _has_staff_access_to_descriptor(user, course), diff --git a/lms/djangoapps/django_comment_client/forum/views.py b/lms/djangoapps/django_comment_client/forum/views.py index 1d4bb033f6..4f8de29145 100644 --- a/lms/djangoapps/django_comment_client/forum/views.py +++ b/lms/djangoapps/django_comment_client/forum/views.py @@ -109,7 +109,7 @@ def inline_discussion(request, course_id, discussion_id): """ Renders JSON for DiscussionModules """ - course = get_course_with_access(request.user, course_id, 'load') + course = get_course_with_access(request.user, course_id, 'load_forum') try: threads, query_params = get_threads(request, course_id, discussion_id, per_page=INLINE_THREADS_PER_PAGE) @@ -169,13 +169,8 @@ def forum_form_discussion(request, course_id): """ Renders the main Discussion page, potentially filtered by a search query """ - if not CourseEnrollment.is_enrolled(request.user, course_id) and \ - not has_access(request.user, course_id, 'staff'): - access_violation_msg = "Unenrolled user {} tried to access forum for {}" - log.warning(access_violation_msg.format(request.user, course_id)) - raise Http404 - course = get_course_with_access(request.user, course_id, 'load') + course = get_course_with_access(request.user, course_id, 'load_forum') category_map = utils.get_discussion_category_map(course) try: @@ -245,7 +240,7 @@ def forum_form_discussion(request, course_id): @login_required def single_thread(request, course_id, discussion_id, thread_id): - course = get_course_with_access(request.user, course_id, 'load') + course = get_course_with_access(request.user, course_id, 'load_forum') cc_user = cc.User.from_django_user(request.user) user_info = cc_user.to_dict() @@ -280,7 +275,7 @@ def single_thread(request, course_id, discussion_id, thread_id): log.error("Error loading single thread.") raise Http404 - course = get_course_with_access(request.user, course_id, 'load') + course = get_course_with_access(request.user, course_id, 'load_forum') for thread in threads: courseware_context = get_courseware_context(thread, course) @@ -340,7 +335,7 @@ def single_thread(request, course_id, discussion_id, thread_id): @login_required def user_profile(request, course_id, user_id): #TODO: Allow sorting? - course = get_course_with_access(request.user, course_id, 'load') + course = get_course_with_access(request.user, course_id, 'load_forum') try: profiled_user = cc.User(id=user_id, course_id=course_id) @@ -381,7 +376,7 @@ def user_profile(request, course_id, user_id): def followed_threads(request, course_id, user_id): - course = get_course_with_access(request.user, course_id, 'load') + course = get_course_with_access(request.user, course_id, 'load_forum') try: profiled_user = cc.User(id=user_id, course_id=course_id) From 08aafc58ea2dd1a5ee9c78bee8c136a8a0e1a2f6 Mon Sep 17 00:00:00 2001 From: Kevin Chugh Date: Tue, 20 Aug 2013 19:43:46 -0400 Subject: [PATCH 326/395] refactor to add access control to already_existing access control routines in access.py --- lms/djangoapps/courseware/access.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/courseware/access.py b/lms/djangoapps/courseware/access.py index 7c85e1e787..02289a045e 100644 --- a/lms/djangoapps/courseware/access.py +++ b/lms/djangoapps/courseware/access.py @@ -134,7 +134,7 @@ def _has_access_course_desc(user, course, action): Can this user access the forums in this course? """ return (CourseEnrollment.is_enrolled(request.user, course_id) or \ - _has_staff_access_to_descriptor(user, course) + _has_staff_access_to_descriptor(user, course)) def can_enroll(): """ From 5989bf6bdc19aa7a107b184bd6d28af96d26022e Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 20 Aug 2013 21:50:28 -0400 Subject: [PATCH 327/395] update the PDF textbook url paths as well as update wiki_slug when importing --- .../contentstore/tests/test_contentstore.py | 19 +++++++++++++++++- .../xmodule/xmodule/contentstore/content.py | 10 ++++++++++ .../xmodule/modulestore/xml_importer.py | 20 +++++++++++++++++++ common/test/data/toy/policies/2012_Fall.json | 11 +++++++++- 4 files changed, 58 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 96b0b84e36..2f9f6600c6 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1475,12 +1475,14 @@ class ContentStoreTest(ModuleStoreTestCase): 'run': target_location.name } + target_course_id = '{0}/{1}/{2}'.format(target_location.org, target_location.course, target_location.name) + resp = self.client.post(reverse('create_new_course'), course_data) self.assertEqual(resp.status_code, 200) data = parse_json(resp) self.assertEqual(data['id'], target_location.url()) - import_from_xml(module_store, 'common/test/data/', ['simple'], target_location_namespace=target_location) + import_from_xml(module_store, 'common/test/data/', ['toy'], target_location_namespace=target_location) modules = module_store.get_items(Location([ target_location.tag, target_location.org, target_location.course, None, None, None])) @@ -1489,6 +1491,21 @@ class ContentStoreTest(ModuleStoreTestCase): # we can't specify an exact number since it'll always be changing self.assertGreater(len(modules), 10) + # + # test various re-namespacing elements + # + + # first check PDF textbooks, to make sure the url paths got updated + course_module = module_store.get_instance(target_course_id, target_location) + + self.assertEquals(len(course_module.pdf_textbooks), 1) + self.assertEquals(len(course_module.pdf_textbooks[0]["chapters"]), 2) + self.assertEquals(course_module.pdf_textbooks[0]["chapters"][0]["url"], '/c4x/MITx/999/asset/Chapter1.pdf') + self.assertEquals(course_module.pdf_textbooks[0]["chapters"][1]["url"], '/c4x/MITx/999/asset/Chapter2.pdf') + + # check that URL slug got updated to new course slug + self.assertEquals(course_module.wiki_slug, '999') + def test_import_metadata_with_attempts_empty_string(self): module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['simple']) diff --git a/common/lib/xmodule/xmodule/contentstore/content.py b/common/lib/xmodule/xmodule/contentstore/content.py index 9d767482d5..8d5bb3f1d5 100644 --- a/common/lib/xmodule/xmodule/contentstore/content.py +++ b/common/lib/xmodule/xmodule/contentstore/content.py @@ -58,6 +58,16 @@ class StaticContent(object): else: return None + @staticmethod + def is_c4x_path(path_string): + return path_string.startswith('/{0}/'.format(XASSET_LOCATION_TAG)) + + @staticmethod + def renamespace_c4x_path(path_string, target_location): + location = StaticContent.get_location_from_path(path_string) + location = location.replace(org=target_location.org, course=target_location.course) + return StaticContent.get_url_path_from_location(location) + @staticmethod def get_static_path_from_location(location): """ diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index 7bea0fdcac..dac0aea273 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -329,8 +329,28 @@ def remap_namespace(module, target_location_namespace): module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, course=target_location_namespace.course) else: + original_location = module.location + # + # module is a course module + # module.location = module.location._replace(tag=target_location_namespace.tag, org=target_location_namespace.org, course=target_location_namespace.course, name=target_location_namespace.name) + # + # There is more re-namespacing work we have to do when importing course modules + # + + # remap pdf_textbook urls + for entry in module.pdf_textbooks: + for chapter in entry.get('chapters', []): + if StaticContent.is_c4x_path(chapter.get('url', '')): + chapter['url'] = StaticContent.renamespace_c4x_path(chapter['url'], target_location_namespace) + + # if there is a wiki_slug which is the same as the original location (aka default value), + # then remap that so the wiki doesn't point to the old Wiki. + if module.wiki_slug == original_location.course: + module.wiki_slug = target_location_namespace.course + + module.save() # then remap children pointers since they too will be re-namespaced if hasattr(module, 'children'): diff --git a/common/test/data/toy/policies/2012_Fall.json b/common/test/data/toy/policies/2012_Fall.json index 464184fac8..cfa5014a9b 100644 --- a/common/test/data/toy/policies/2012_Fall.json +++ b/common/test/data/toy/policies/2012_Fall.json @@ -12,7 +12,16 @@ {"type": "discussion", "name": "Discussion"}, {"type": "wiki", "name": "Wiki"}, {"type": "progress", "name": "Progress"} - ] + ], + "pdf_textbooks": [ + { + "tab_title": "Sample Multi Chapter Textbook", + "id": "MyTextbook", + "chapters": [ + { "url": "/c4x/edX/toy/asset/Chapter1.pdf", "title": "Chapter 1"}, + { "url": "/c4x/edX/toy/asset/Chapter2.pdf", "title": "Chapter 2"} + ] + }] }, "chapter/Overview": { "display_name": "Overview" From 5a7bcd7bb3f5b3ad5269b173397b1c56692575fb Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 20 Aug 2013 22:43:53 -0400 Subject: [PATCH 328/395] always serialize out HTML content to a separate .html file --- .../contentstore/tests/test_contentstore.py | 25 +++++++++++++++++++ common/lib/xmodule/xmodule/html_module.py | 7 +----- common/test/data/toy/course/2012_Fall.xml | 1 + 3 files changed, 27 insertions(+), 6 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 96b0b84e36..282753fcf9 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1057,6 +1057,31 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): # It should now contain empty data self.assertEquals(imported_word_cloud.data, '') + def test_html_export_roundtrip(self): + """ + Test that a course which has HTML that has style formatting is preserved in export/import + """ + module_store = modulestore('direct') + content_store = contentstore() + + import_from_xml(module_store, 'common/test/data/', ['toy']) + + location = CourseDescriptor.id_to_location('edX/toy/2012_Fall') + + # Export the course + root_dir = path(mkdtemp_clean()) + export_to_xml(module_store, content_store, location, root_dir, 'test_roundtrip') + + # Reimport and get the video back + import_from_xml(module_store, root_dir) + + # get the sample HTML with styling information + html_module = module_store.get_instance( + 'edX/toy/2012_Fall', + Location(['i4x', 'edX', 'toy', 'html', 'with_styling']) + ) + self.assertIn('

    ', html_module.data) + def test_course_handouts_rewrites(self): module_store = modulestore('direct') diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 567f5c7eef..726cac77f8 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -164,14 +164,9 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # TODO (vshnayder): make export put things in the right places. def definition_to_xml(self, resource_fs): - '''If the contents are valid xml, write them to filename.xml. Otherwise, - write just to filename.xml, and the html + ''' Write to filename.xml, and the html string to filename.html. ''' - try: - return etree.fromstring(self.data) - except etree.XMLSyntaxError: - pass # Not proper format. Write html to file, return an empty tag pathname = name_to_pathname(self.url_name) diff --git a/common/test/data/toy/course/2012_Fall.xml b/common/test/data/toy/course/2012_Fall.xml index ec75ef0b9d..ebbc2bb75e 100644 --- a/common/test/data/toy/course/2012_Fall.xml +++ b/common/test/data/toy/course/2012_Fall.xml @@ -8,6 +8,7 @@ +

    Red text here

    \ No newline at end of file diff --git a/common/test/data/toy/html/with_styling.xml b/common/test/data/toy/html/with_styling.xml new file mode 100644 index 0000000000..1ee6ca5c24 --- /dev/null +++ b/common/test/data/toy/html/with_styling.xml @@ -0,0 +1 @@ + \ No newline at end of file From b8a8b202a46c20a68b7a556557729fdf737fa8ef Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 20 Aug 2013 22:53:03 -0400 Subject: [PATCH 331/395] update comments and some code violation drive by fixes --- common/lib/xmodule/xmodule/html_module.py | 31 +++++++++++++++-------- 1 file changed, 21 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/html_module.py b/common/lib/xmodule/xmodule/html_module.py index 726cac77f8..7a68c42ac9 100644 --- a/common/lib/xmodule/xmodule/html_module.py +++ b/common/lib/xmodule/xmodule/html_module.py @@ -33,11 +33,13 @@ class HtmlFields(object): class HtmlModule(HtmlFields, XModule): - js = {'coffee': [resource_string(__name__, 'js/src/javascript_loader.coffee'), - resource_string(__name__, 'js/src/collapsible.coffee'), - resource_string(__name__, 'js/src/html/display.coffee') - ] - } + js = { + 'coffee': [ + resource_string(__name__, 'js/src/javascript_loader.coffee'), + resource_string(__name__, 'js/src/collapsible.coffee'), + resource_string(__name__, 'js/src/html/display.coffee') + ] + } js_module_name = "HTMLModule" css = {'scss': [resource_string(__name__, 'css/html/display.scss')]} @@ -118,8 +120,10 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): # from .html # 'filename' in html pointers is a relative path # (not same as 'html/blah.html' when the pointer is in a directory itself) - pointer_path = "{category}/{url_path}".format(category='html', - url_path=name_to_pathname(location.name)) + pointer_path = "{category}/{url_path}".format( + category='html', + url_path=name_to_pathname(location.name) + ) base = path(pointer_path).dirname() # log.debug("base = {0}, base.dirname={1}, filename={2}".format(base, base.dirname(), filename)) filepath = "{base}/{name}.html".format(base=base, name=filename) @@ -168,10 +172,12 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): string to filename.html. ''' - # Not proper format. Write html to file, return an empty tag + # Write html to file, return an empty tag pathname = name_to_pathname(self.url_name) - filepath = u'{category}/{pathname}.html'.format(category=self.category, - pathname=pathname) + filepath = u'{category}/{pathname}.html'.format( + category=self.category, + pathname=pathname + ) resource_fs.makedir(os.path.dirname(filepath), recursive=True, allow_recreate=True) with resource_fs.open(filepath, 'w') as filestream: @@ -185,6 +191,7 @@ class HtmlDescriptor(HtmlFields, XmlDescriptor, EditingDescriptor): elt.set("filename", relname) return elt + class AboutFields(object): display_name = String( help="Display name for this module", @@ -197,12 +204,14 @@ class AboutFields(object): scope=Scope.content ) + class AboutModule(AboutFields, HtmlModule): """ Overriding defaults but otherwise treated as HtmlModule. """ pass + class AboutDescriptor(AboutFields, HtmlDescriptor): """ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located @@ -211,6 +220,7 @@ class AboutDescriptor(AboutFields, HtmlDescriptor): template_dir_name = "about" module_class = AboutModule + class StaticTabFields(object): """ The overrides for Static Tabs @@ -236,6 +246,7 @@ class StaticTabModule(StaticTabFields, HtmlModule): """ pass + class StaticTabDescriptor(StaticTabFields, HtmlDescriptor): """ These pieces of course content are treated as HtmlModules but we need to overload where the templates are located From d523167bddd852a69f28891d8a7ef3ba75473356 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Tue, 20 Aug 2013 23:16:39 -0400 Subject: [PATCH 332/395] add another piece of test data. just an img tag. --- cms/djangoapps/contentstore/tests/test_contentstore.py | 7 +++++++ common/test/data/toy/course/2012_Fall.xml | 1 + common/test/data/toy/html/just_img.html | 1 + common/test/data/toy/html/just_img.xml | 1 + 4 files changed, 10 insertions(+) create mode 100644 common/test/data/toy/html/just_img.html create mode 100644 common/test/data/toy/html/just_img.xml diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 282753fcf9..93f353015d 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1082,6 +1082,13 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): ) self.assertIn('

    ', html_module.data) + # get the sample HTML with just a simple tag information + html_module = module_store.get_instance( + 'edX/toy/2012_Fall', + Location(['i4x', 'edX', 'toy', 'html', 'just_img']) + ) + self.assertIn('', html_module.data) + def test_course_handouts_rewrites(self): module_store = modulestore('direct') diff --git a/common/test/data/toy/course/2012_Fall.xml b/common/test/data/toy/course/2012_Fall.xml index ebbc2bb75e..ce8a2399b5 100644 --- a/common/test/data/toy/course/2012_Fall.xml +++ b/common/test/data/toy/course/2012_Fall.xml @@ -9,6 +9,7 @@ +

    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 z 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

    ={;$SNp35G_5{ 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

    yf7jNf^~a4i%Wfb{H>KYWE*KLz2;# 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 file should be in the contentstore

    diff --git a/common/test/data/test_import_course/vertical/vertical_test.xml b/common/test/data/test_import_course/vertical/vertical_test.xml new file mode 100644 index 0000000000..68c5745f37 --- /dev/null +++ b/common/test/data/test_import_course/vertical/vertical_test.xml @@ -0,0 +1,9 @@ + + diff --git a/common/test/data/test_import_course/video/separate_file_video.xml b/common/test/data/test_import_course/video/separate_file_video.xml new file mode 100644 index 0000000000..b90ea9d8c4 --- /dev/null +++ b/common/test/data/test_import_course/video/separate_file_video.xml @@ -0,0 +1 @@ +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 z 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

    ={;$SNp35G_5{ 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

    yf7jNf^~a4i%Wfb{H>KYWE*KLz2;# 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 Date: Wed, 21 Aug 2013 07:14:16 -0400 Subject: [PATCH 336/395] dummy commit - trigger jenkins rebuild --- common/test/data/test_import_course/about/end_date.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/test/data/test_import_course/about/end_date.html b/common/test/data/test_import_course/about/end_date.html index 2fd9f95700..a0990367ef 100644 --- a/common/test/data/test_import_course/about/end_date.html +++ b/common/test/data/test_import_course/about/end_date.html @@ -1 +1 @@ -TBD \ No newline at end of file +TBD From ce1a13f32c5d261ceb4054d39676429f3c089849 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 13 Aug 2013 13:22:55 -0400 Subject: [PATCH 337/395] Generalize file uploader. Previously the file upload dialog was PDF- and textbook-specific. The changes are adding parameters to the FileUpload model for the file type, and adding an onSuccess callback to the UploadDialog view. Also moved upload-specific SASS into its own file. --- cms/envs/common.py | 1 + .../coffee/spec/views/textbook_spec.coffee | 12 +- cms/static/js/models/textbook.js | 22 +- cms/static/js/models/uploads.js | 23 ++ cms/static/js/views/textbook.js | 124 +---------- cms/static/js/views/uploads.js | 105 +++++++++ cms/static/sass/base-style.scss | 1 + cms/static/sass/views/_textbooks.scss | 209 ------------------ cms/static/sass/views/_uploads.scss | 209 ++++++++++++++++++ cms/templates/textbooks.html | 2 +- 10 files changed, 363 insertions(+), 345 deletions(-) create mode 100644 cms/static/js/models/uploads.js create mode 100644 cms/static/js/views/uploads.js create mode 100644 cms/static/sass/views/_uploads.scss diff --git a/cms/envs/common.py b/cms/envs/common.py index d91e2aeeba..86b3d3f1df 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -244,6 +244,7 @@ PIPELINE_JS = { 'js/models/course.js', 'js/models/section.js', 'js/views/section.js', 'js/models/metadata_model.js', 'js/views/metadata_editor_view.js', + 'js/models/uploads.js', 'js/views/uploads.js', 'js/models/textbook.js', 'js/views/textbook.js', 'js/views/assets.js', 'js/utility.js'], 'output_filename': 'js/cms-application.js', diff --git a/cms/static/coffee/spec/views/textbook_spec.coffee b/cms/static/coffee/spec/views/textbook_spec.coffee index 981659abfa..5185c9fb47 100644 --- a/cms/static/coffee/spec/views/textbook_spec.coffee +++ b/cms/static/coffee/spec/views/textbook_spec.coffee @@ -301,7 +301,7 @@ describe "CMS.Views.EditChapter", -> @view.render().$(".action-upload").click() ctorOptions = uploadSpies.constructor.mostRecentCall.args[0] expect(ctorOptions.model.get('title')).toMatch(/abcde/) - expect(ctorOptions.chapter).toBe(@model) + expect(typeof ctorOptions.onSuccess).toBe('function') expect(uploadSpies.show).toHaveBeenCalled() it "saves content when opening upload dialog", -> @@ -323,7 +323,15 @@ describe "CMS.Views.UploadDialog", -> @model = new CMS.Models.FileUpload() @chapter = new CMS.Models.Chapter() - @view = new CMS.Views.UploadDialog({model: @model, chapter: @chapter}) + @view = new CMS.Views.UploadDialog( + model: @model + onSuccess: (response) => + options = {} + if !@chapter.get('name') + options.name = response.displayname + options.asset_path = response.url + @chapter.set(options) + ) spyOn(@view, 'remove').andCallThrough() # create mock file input, so that we aren't subject to browser restrictions diff --git a/cms/static/js/models/textbook.js b/cms/static/js/models/textbook.js index cdd86023dc..72c9dfb0be 100644 --- a/cms/static/js/models/textbook.js +++ b/cms/static/js/models/textbook.js @@ -155,24 +155,4 @@ CMS.Collections.ChapterSet = Backbone.Collection.extend({ return this.length === 0 || this.every(function(m) { return m.isEmpty(); }); } }); -CMS.Models.FileUpload = Backbone.Model.extend({ - defaults: { - "title": "", - "message": "", - "selectedFile": null, - "uploading": false, - "uploadedBytes": 0, - "totalBytes": 0, - "finished": false - }, - // NOTE: validation functions should return non-internationalized error - // messages. The messages will be passed through gettext in the template. - validate: function(attrs, options) { - if(attrs.selectedFile && attrs.selectedFile.type !== "application/pdf") { - return { - message: "Only PDF files can be uploaded. Please select a file ending in .pdf to upload.", - attributes: {selectedFile: true} - }; - } - } -}); + diff --git a/cms/static/js/models/uploads.js b/cms/static/js/models/uploads.js new file mode 100644 index 0000000000..ba557306eb --- /dev/null +++ b/cms/static/js/models/uploads.js @@ -0,0 +1,23 @@ +CMS.Models.FileUpload = Backbone.Model.extend({ + defaults: { + "title": "", + "message": "", + "selectedFile": null, + "uploading": false, + "uploadedBytes": 0, + "totalBytes": 0, + "finished": false, + "mimeType": "application/pdf", + "fileType": "PDF" + }, + // NOTE: validation functions should return non-internationalized error + // messages. The messages will be passed through gettext in the template. + validate: function(attrs, options) { + if(attrs.selectedFile && attrs.selectedFile.type !== this.attributes.mimeType) { + return { + message: "Only " + this.attributes.fileType + " files can be uploaded. Please select a file ending in ." + this.attributes.fileType.toLowerCase() + " to upload.", + attributes: {selectedFile: true} + }; + } + } +}); diff --git a/cms/static/js/views/textbook.js b/cms/static/js/views/textbook.js index f666143c0a..992ac5cd53 100644 --- a/cms/static/js/views/textbook.js +++ b/cms/static/js/views/textbook.js @@ -245,118 +245,18 @@ CMS.Views.EditChapter = Backbone.View.extend({ {name: section.escape('name')}), message: "Files must be in PDF format." }); - var view = new CMS.Views.UploadDialog({model: msg, chapter: this.model}); + var that = this; + var view = new CMS.Views.UploadDialog({ + model: msg, + onSuccess: function(response) { + var options = {}; + if(!that.model.get('name')) { + options.name = response.displayname; + } + options.asset_path = response.url; + that.model.set(options); + }, + }); $(".wrapper-view").after(view.show().el); } }); - -CMS.Views.UploadDialog = Backbone.View.extend({ - options: { - shown: true, - successMessageTimeout: 2000 // 2 seconds - }, - initialize: function() { - this.template = _.template($("#upload-dialog-tpl").text()); - this.listenTo(this.model, "change", this.render); - }, - render: function() { - var isValid = this.model.isValid(); - var selectedFile = this.model.get('selectedFile'); - var oldInput = this.$("input[type=file]").get(0); - this.$el.html(this.template({ - shown: this.options.shown, - url: CMS.URL.UPLOAD_ASSET, - title: this.model.escape('title'), - message: this.model.escape('message'), - selectedFile: selectedFile, - uploading: this.model.get('uploading'), - uploadedBytes: this.model.get('uploadedBytes'), - totalBytes: this.model.get('totalBytes'), - finished: this.model.get('finished'), - error: this.model.validationError - })); - // Ideally, we'd like to tell the browser to pre-populate the - // with the selectedFile if we have one -- but - // browser security prohibits that. So instead, we'll swap out the - // new input (that has no file selected) with the old input (that - // already has the selectedFile selected). However, we only want to do - // this if the selected file is valid: if it isn't, we want to render - // a blank input to prompt the user to upload a different (valid) file. - if (selectedFile && isValid) { - $(oldInput).removeClass("error"); - this.$('input[type=file]').replaceWith(oldInput); - } - return this; - }, - events: { - "change input[type=file]": "selectFile", - "click .action-cancel": "hideAndRemove", - "click .action-upload": "upload" - }, - selectFile: function(e) { - this.model.set({ - selectedFile: e.target.files[0] || null - }); - }, - show: function(e) { - if(e && e.preventDefault) { e.preventDefault(); } - this.options.shown = true; - $body.addClass('dialog-is-shown'); - return this.render(); - }, - hide: function(e) { - if(e && e.preventDefault) { e.preventDefault(); } - this.options.shown = false; - $body.removeClass('dialog-is-shown'); - return this.render(); - }, - hideAndRemove: function(e) { - if(e && e.preventDefault) { e.preventDefault(); } - return this.hide().remove(); - }, - upload: function(e) { - this.model.set('uploading', true); - this.$("form").ajaxSubmit({ - success: _.bind(this.success, this), - error: _.bind(this.error, this), - uploadProgress: _.bind(this.progress, this), - data: { - // don't show the generic error notification; we're in a modal, - // and we're better off modifying it instead. - notifyOnError: false - } - }); - }, - progress: function(event, position, total, percentComplete) { - this.model.set({ - "uploadedBytes": position, - "totalBytes": total - }); - }, - success: function(response, statusText, xhr, form) { - this.model.set({ - uploading: false, - finished: true - }); - var chapter = this.options.chapter; - if(chapter) { - var options = {}; - if(!chapter.get("name")) { - options.name = response.displayname; - } - options.asset_path = response.url; - chapter.set(options); - } - var that = this; - this.removalTimeout = setTimeout(function() { - that.hide().remove(); - }, this.options.successMessageTimeout); - }, - error: function() { - this.model.set({ - "uploading": false, - "uploadedBytes": 0, - "title": gettext("We're sorry, there was an error") - }); - } -}); diff --git a/cms/static/js/views/uploads.js b/cms/static/js/views/uploads.js new file mode 100644 index 0000000000..9ce111e805 --- /dev/null +++ b/cms/static/js/views/uploads.js @@ -0,0 +1,105 @@ +CMS.Views.UploadDialog = Backbone.View.extend({ + options: { + shown: true, + successMessageTimeout: 2000 // 2 seconds + }, + initialize: function() { + this.template = _.template($("#upload-dialog-tpl").text()); + this.listenTo(this.model, "change", this.render); + }, + render: function() { + var isValid = this.model.isValid(); + var selectedFile = this.model.get('selectedFile'); + var oldInput = this.$("input[type=file]").get(0); + this.$el.html(this.template({ + shown: this.options.shown, + url: CMS.URL.UPLOAD_ASSET, + title: this.model.escape('title'), + message: this.model.escape('message'), + selectedFile: selectedFile, + uploading: this.model.get('uploading'), + uploadedBytes: this.model.get('uploadedBytes'), + totalBytes: this.model.get('totalBytes'), + finished: this.model.get('finished'), + error: this.model.validationError + })); + // Ideally, we'd like to tell the browser to pre-populate the + // with the selectedFile if we have one -- but + // browser security prohibits that. So instead, we'll swap out the + // new input (that has no file selected) with the old input (that + // already has the selectedFile selected). However, we only want to do + // this if the selected file is valid: if it isn't, we want to render + // a blank input to prompt the user to upload a different (valid) file. + if (selectedFile && isValid) { + $(oldInput).removeClass("error"); + this.$('input[type=file]').replaceWith(oldInput); + } + return this; + }, + events: { + "change input[type=file]": "selectFile", + "click .action-cancel": "hideAndRemove", + "click .action-upload": "upload" + }, + selectFile: function(e) { + this.model.set({ + selectedFile: e.target.files[0] || null + }); + }, + show: function(e) { + if(e && e.preventDefault) { e.preventDefault(); } + this.options.shown = true; + $body.addClass('dialog-is-shown'); + return this.render(); + }, + hide: function(e) { + if(e && e.preventDefault) { e.preventDefault(); } + this.options.shown = false; + $body.removeClass('dialog-is-shown'); + return this.render(); + }, + hideAndRemove: function(e) { + if(e && e.preventDefault) { e.preventDefault(); } + return this.hide().remove(); + }, + upload: function(e) { + if(e && e.preventDefault) { e.preventDefault(); } + this.model.set('uploading', true); + this.$("form").ajaxSubmit({ + success: _.bind(this.success, this), + error: _.bind(this.error, this), + uploadProgress: _.bind(this.progress, this), + data: { + // don't show the generic error notification; we're in a modal, + // and we're better off modifying it instead. + notifyOnError: false + } + }); + }, + progress: function(event, position, total, percentComplete) { + this.model.set({ + "uploadedBytes": position, + "totalBytes": total + }); + }, + success: function(response, statusText, xhr, form) { + this.model.set({ + uploading: false, + finished: true + }); + if(this.options.onSuccess) { + this.options.onSuccess(response, statusText, xhr, form); + } + var that = this; + this.removalTimeout = setTimeout(function() { + that.hide().remove(); + }, this.options.successMessageTimeout); + }, + error: function() { + this.model.set({ + "uploading": false, + "uploadedBytes": 0, + "title": gettext("We're sorry, there was an error") + }); + } +}); diff --git a/cms/static/sass/base-style.scss b/cms/static/sass/base-style.scss index 7011089527..757ffe02bd 100644 --- a/cms/static/sass/base-style.scss +++ b/cms/static/sass/base-style.scss @@ -59,6 +59,7 @@ @import 'views/users'; @import 'views/checklists'; @import 'views/textbooks'; +@import 'views/uploads'; // temp - inherited @import 'assets/content-types'; diff --git a/cms/static/sass/views/_textbooks.scss b/cms/static/sass/views/_textbooks.scss index b83d22414b..ac644b6ff7 100644 --- a/cms/static/sass/views/_textbooks.scss +++ b/cms/static/sass/views/_textbooks.scss @@ -370,213 +370,4 @@ body.course.textbooks { .content-supplementary { width: flex-grid(3, 12); } - - // dialog -.wrapper-dialog { - @extend .ui-depth5; - @include transition(all 0.05s ease-in-out); - position: fixed; - top: 0; - background: $black-t2; - width: 100%; - height: 100%; - text-align: center; - - &:before { - content: ''; - display: inline-block; - height: 100%; - vertical-align: middle; - margin-right: -0.25em; /* Adjusts for spacing */ - } - - .dialog { - @include box-sizing(border-box); - box-shadow: 0px 0px 7px $shadow-d1; - border-radius: ($baseline/5); - background-color: $gray-l4; - display: inline-block; - vertical-align: middle; - width: $baseline*23; - padding: 7px; - text-align: left; - - .title { - @extend .t-title5; - margin-bottom: ($baseline/2); - font-weight: 600; - color: $black; - } - - .message { - @extend .t-copy-sub2; - color: $gray; - } - - .error { - color: $white; - } - - form { - padding: 0; - - .form-content { - box-shadow: 0 0 3px $shadow-d1; - padding: ($baseline*1.5); - background-color: $white; - } - - input[type="file"] { - @extend .t-copy-sub2; - } - - .status-upload { - height: 30px; - margin-top: $baseline; - - .wrapper-progress { - box-shadow: inset 0 0 3px $shadow-d1; - display: block; - border-radius: ($baseline*0.75); - background-color: $gray-l5; - padding: 1px 8px 2px 8px; - height: 25px; - - progress { - display: inline-block; - vertical-align: middle; - width: 100%; - border: none; - border-radius: ($baseline*0.75); - background-color: $gray-l5; - - &::-webkit-progress-bar { - background-color: transparent; - border-radius: ($baseline*0.75); - } - - &::-webkit-progress-value { - background-color: $pink; - border-radius: ($baseline*0.75); - } - - &::-moz-progress-bar { - background-color: $pink; - border-radius: ($baseline*0.75); - } - - } - - } - - .message-status { - @include border-top-radius(2px); - @include box-sizing(border-box); - @include font-size(14); - display: none; - border-bottom: 2px solid $yellow; - margin: 0 0 20px 0; - padding: 10px 20px; - font-weight: 500; - background: $paleYellow; - - .text { - display: inline-block; - } - - &.error { - border-color: $red-d2; - background: $red-l1; - color: $white; - } - - &.confirm { - border-color: $green-d2; - background: $green-l1; - color: $white; - } - - &.is-shown { - display: block; - } - } - } - - .actions { - padding: ($baseline*0.75) $baseline ($baseline/2) $baseline; - - - - .action-item { - @extend .t-action4; - display: inline-block; - margin-right: ($baseline*0.75); - - &:last-child { - margin-right: 0; - } - } - - .action-primary { - @include blue-button(); - @include font-size(12); // needed due to bad button mixins for now - border-color: $blue-d1; - color: $white; - } - - a { - color: $blue; - - &:hover { - color: $blue-s2; - } - } - - } - - } - - } - -} - -// ==================== - -// js enabled -.js { - - // dialog set-up - .wrapper-dialog { - visibility: hidden; - pointer-events: none; - - .dialog { - opacity: 0; - } - } - - // dialog showing/hiding - &.dialog-is-shown { - - .wrapper-dialog { - -webkit-filter: blur(2px) grayscale(25%); - filter: blur(2px) grayscale(25%); - } - - .wrapper-dialog.is-shown { - visibility: visible; - pointer-events: auto; - - .dialog { - opacity: 1.0; - } - } - } - -} - - - - - } diff --git a/cms/static/sass/views/_uploads.scss b/cms/static/sass/views/_uploads.scss new file mode 100644 index 0000000000..f564ac1c13 --- /dev/null +++ b/cms/static/sass/views/_uploads.scss @@ -0,0 +1,209 @@ +// studio - views - uploads +// ======================== + +body.course.file-upload-dialog { + + // dialog + .wrapper-dialog { + @extend .ui-depth5; + @include transition(all 0.05s ease-in-out); + position: fixed; + top: 0; + background: $black-t2; + width: 100%; + height: 100%; + text-align: center; + + &:before { + content: ''; + display: inline-block; + height: 100%; + vertical-align: middle; + margin-right: -0.25em; /* Adjusts for spacing */ + } + + .dialog { + @include box-sizing(border-box); + box-shadow: 0px 0px 7px $shadow-d1; + border-radius: ($baseline/5); + background-color: $gray-l4; + display: inline-block; + vertical-align: middle; + width: $baseline*23; + padding: 7px; + text-align: left; + + .title { + @extend .t-title5; + margin-bottom: ($baseline/2); + font-weight: 600; + color: $black; + } + + .message { + @extend .t-copy-sub2; + color: $gray; + } + + .error { + color: $white; + } + + form { + padding: 0; + + .form-content { + box-shadow: 0 0 3px $shadow-d1; + padding: ($baseline*1.5); + background-color: $white; + } + + input[type="file"] { + @extend .t-copy-sub2; + } + + .status-upload { + height: 30px; + margin-top: $baseline; + + .wrapper-progress { + box-shadow: inset 0 0 3px $shadow-d1; + display: block; + border-radius: ($baseline*0.75); + background-color: $gray-l5; + padding: 1px 8px 2px 8px; + height: 25px; + + progress { + display: inline-block; + vertical-align: middle; + width: 100%; + border: none; + border-radius: ($baseline*0.75); + background-color: $gray-l5; + + &::-webkit-progress-bar { + background-color: transparent; + border-radius: ($baseline*0.75); + } + + &::-webkit-progress-value { + background-color: $pink; + border-radius: ($baseline*0.75); + } + + &::-moz-progress-bar { + background-color: $pink; + border-radius: ($baseline*0.75); + } + + } + + } + + .message-status { + @include border-top-radius(2px); + @include box-sizing(border-box); + @include font-size(14); + display: none; + border-bottom: 2px solid $yellow; + margin: 0 0 20px 0; + padding: 10px 20px; + font-weight: 500; + background: $paleYellow; + + .text { + display: inline-block; + } + + &.error { + border-color: $red-d2; + background: $red-l1; + color: $white; + } + + &.confirm { + border-color: $green-d2; + background: $green-l1; + color: $white; + } + + &.is-shown { + display: block; + } + } + } + + .actions { + padding: ($baseline*0.75) $baseline ($baseline/2) $baseline; + + + + .action-item { + @extend .t-action4; + display: inline-block; + margin-right: ($baseline*0.75); + + &:last-child { + margin-right: 0; + } + } + + .action-primary { + @include blue-button(); + @include font-size(12); // needed due to bad button mixins for now + border-color: $blue-d1; + color: $white; + } + + a { + color: $blue; + + &:hover { + color: $blue-s2; + } + } + + } + + } + + } + + } + + // ==================== + + // js enabled + .js { + + // dialog set-up + .wrapper-dialog { + visibility: hidden; + pointer-events: none; + + .dialog { + opacity: 0; + } + } + + // dialog showing/hiding + &.dialog-is-shown { + + .wrapper-dialog { + -webkit-filter: blur(2px) grayscale(25%); + filter: blur(2px) grayscale(25%); + } + + .wrapper-dialog.is-shown { + visibility: visible; + pointer-events: auto; + + .dialog { + opacity: 1.0; + } + } + } + + } +} diff --git a/cms/templates/textbooks.html b/cms/templates/textbooks.html index 28349b5436..5b79b7f265 100644 --- a/cms/templates/textbooks.html +++ b/cms/templates/textbooks.html @@ -4,7 +4,7 @@ <%! from django.utils.translation import ugettext as _ %> <%block name="title">${_("Textbooks")} -<%block name="bodyclass">is-signedin course textbooks +<%block name="bodyclass">is-signedin course textbooks file-upload-dialog <%block name="header_extras"> % for template_name in ["edit-textbook", "show-textbook", "edit-chapter", "no-textbooks", "upload-dialog"]: From e4302e62d2791d5d5318c6ec761b4fa71b2163b2 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Wed, 14 Aug 2013 16:51:14 -0400 Subject: [PATCH 338/395] Allow course image uploads in the settings page. Authors can upload an image (or choose an existing one) from the settings page, using the in-context uploader from PDF textbooks. Includes tests for backwards compatibility with XML courses -- they used a magic filename (images/course_image.jpg) which is mapped to a location in the Mongo contentstore. Still needs some UX work, though the backend plumbing is there. --- .../features/course-settings.feature | 8 +++ .../contentstore/features/course-settings.py | 34 +++++++++++++ .../contentstore/tests/test_contentstore.py | 23 +++++++++ .../tests/test_course_settings.py | 9 ++++ .../contentstore/tests/test_utils.py | 11 +++++ cms/djangoapps/contentstore/utils.py | 8 +++ cms/djangoapps/contentstore/views/course.py | 7 ++- .../models/settings/course_details.py | 10 +++- .../js/models/settings/course_details.js | 4 +- .../js/views/settings/main_settings_view.js | 46 ++++++++++++++++-- cms/static/sass/views/_settings.scss | 14 ++++++ cms/templates/settings.html | 23 ++++++++- common/lib/xmodule/xmodule/course_module.py | 6 +++ common/test/data/uploads/image.jpg | Bin 0 -> 13811 bytes lms/djangoapps/courseware/courses.py | 2 +- 15 files changed, 197 insertions(+), 8 deletions(-) create mode 100644 common/test/data/uploads/image.jpg diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index 5c79dc7ee3..69183bc3da 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -57,6 +57,7 @@ Feature: 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 @@ -71,3 +72,10 @@ Feature: Course Settings When I select Schedule and Details And I change the "Course Start Date" field to "" Then the save 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 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 da72d893cf..0847c62a18 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -5,9 +5,13 @@ from lettuce import world, step from terrain.steps import reload_the_page from selenium.webdriver.common.keys import Keys from common import type_in_codemirror +from django.conf import settings +import os from nose.tools import assert_true, assert_false, assert_equal +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT + COURSE_START_DATE_CSS = "#course-start-date" COURSE_END_DATE_CSS = "#course-end-date" ENROLLMENT_START_DATE_CSS = "#course-enrollment-start-date" @@ -146,6 +150,36 @@ def test_change_course_overview(_step): type_in_codemirror(0, "

    Overview

    ") +@step('I upload a new course image$') +def upload_new_course_image(_step): + upload_css = '.action-upload-image' + world.css_click(upload_css) + file_css = '.upload-dialog input[type=file]' + upload = world.css_find(file_css) + path = os.path.join(TEST_ROOT, 'image.jpg') + upload._element.send_keys(os.path.abspath(path)) + button_css = '.upload-dialog .action-upload' + world.css_click(button_css) + + +@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 = '/c4x/MITx/999/asset/image.jpg' + # Don't worry about the domain in the URL + assert img['src'].endswith(expected_src) + + +@step('the image URL should be present in the field') +def image_url_present(_step): + field_css = '#course-image-url' + field = world.css_find(field_css).first + expected_value = '/c4x/MITx/999/asset/image.jpg' + assert field.value == expected_value + ############### HELPER METHODS #################### def set_date_or_time(css, date_or_time): diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 96b0b84e36..216edc6b88 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1608,6 +1608,29 @@ class ContentStoreTest(ModuleStoreTestCase): # is this test too strict? i.e., it requires the dicts to be == self.assertEqual(course.checklists, fetched_course.checklists) + def test_image_import(self): + """Test backwards compatibilty of course image.""" + module_store = modulestore('direct') + + content_store = contentstore() + + # Use conditional_and_poll, as it's got an image already + import_from_xml( + module_store, + 'common/test/data/', + ['conditional_and_poll'], + static_content_store=content_store + ) + + course = module_store.get_courses()[0] + + # Make sure the course image is set to the right place + self.assertEqual(course.course_image, 'images_course_image.jpg') + + # Ensure that the imported course image is present -- this shouldn't raise an exception + location = course.location._replace(tag='c4x', category='asset', name=course.course_image) + content_store.find(location) + class MetadataSaveTestCase(ModuleStoreTestCase): """Test that metadata is correctly cached and decached.""" diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2007ba2f69..dbdf8b3f6e 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -30,6 +30,7 @@ class CourseDetailsTestCase(CourseTestCase): def test_virgin_fetch(self): details = CourseDetails.fetch(self.course.location) self.assertEqual(details.course_location, self.course.location, "Location not copied into") + self.assertEqual(details.course_image_name, self.course.course_image) self.assertIsNotNone(details.start_date.tzinfo) self.assertIsNone(details.end_date, "end date somehow initialized " + str(details.end_date)) self.assertIsNone(details.enrollment_start, "enrollment_start date somehow initialized " + str(details.enrollment_start)) @@ -43,6 +44,7 @@ class CourseDetailsTestCase(CourseTestCase): jsondetails = json.dumps(details, cls=CourseSettingsEncoder) jsondetails = json.loads(jsondetails) self.assertTupleEqual(Location(jsondetails['course_location']), self.course.location, "Location !=") + self.assertEqual(jsondetails['course_image_name'], self.course.course_image) self.assertIsNone(jsondetails['end_date'], "end date somehow initialized ") self.assertIsNone(jsondetails['enrollment_start'], "enrollment_start date somehow initialized ") self.assertIsNone(jsondetails['enrollment_end'], "enrollment_end date somehow initialized ") @@ -97,6 +99,11 @@ class CourseDetailsTestCase(CourseTestCase): CourseDetails.update_from_json(jsondetails.__dict__).start_date, jsondetails.start_date ) + jsondetails.course_image_name = "an_image.jpg" + self.assertEqual( + CourseDetails.update_from_json(jsondetails.__dict__).course_image_name, + jsondetails.course_image_name + ) @override_settings(MKTG_URLS={'ROOT': 'dummy-root'}) def test_marketing_site_fetch(self): @@ -188,6 +195,7 @@ class CourseDetailsViewTest(CourseTestCase): self.alter_field(url, details, 'overview', "Overview") self.alter_field(url, details, 'intro_video', "intro_video") self.alter_field(url, details, 'effort', "effort") + self.alter_field(url, details, 'course_image_name', "course_image_name") def compare_details_with_encoding(self, encoded, details, context): self.compare_date_fields(details, encoded, context, 'start_date') @@ -197,6 +205,7 @@ class CourseDetailsViewTest(CourseTestCase): self.assertEqual(details['overview'], encoded['overview'], context + " overviews not ==") self.assertEqual(details['intro_video'], encoded.get('intro_video', None), context + " intro_video not ==") self.assertEqual(details['effort'], encoded['effort'], context + " efforts not ==") + self.assertEqual(details['course_image_name'], encoded['course_image_name'], context + " images not ==") def compare_date_fields(self, details, encoded, context, field): if details[field] is not None: diff --git a/cms/djangoapps/contentstore/tests/test_utils.py b/cms/djangoapps/contentstore/tests/test_utils.py index c3335aaaa0..3d6d1d0c56 100644 --- a/cms/djangoapps/contentstore/tests/test_utils.py +++ b/cms/djangoapps/contentstore/tests/test_utils.py @@ -5,6 +5,7 @@ import collections import copy from django.test import TestCase from django.test.utils import override_settings +from xmodule.modulestore.tests.factories import CourseFactory class LMSLinksTestCase(TestCase): @@ -150,3 +151,13 @@ class ExtraPanelTabTestCase(TestCase): changed, actual_tabs = utils.remove_extra_panel_tab(tab_type, course) self.assertFalse(changed) self.assertEqual(actual_tabs, expected_tabs) + + +class CourseImageTestCase(TestCase): + """Tests for course image URLs.""" + + def test_get_image_url(self): + """Test image URL formatting.""" + course = CourseFactory.create(org='edX', course='999') + url = utils.course_image_url(course) + self.assertEquals(url, '/c4x/edX/999/asset/{0}'.format(course.course_image)) diff --git a/cms/djangoapps/contentstore/utils.py b/cms/djangoapps/contentstore/utils.py index d956a903b6..e5ae6bb66b 100644 --- a/cms/djangoapps/contentstore/utils.py +++ b/cms/djangoapps/contentstore/utils.py @@ -4,6 +4,7 @@ from django.conf import settings from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError +from xmodule.contentstore.content import StaticContent from django.core.urlresolvers import reverse import copy import logging @@ -153,6 +154,13 @@ def get_lms_link_for_about_page(location): return lms_link +def course_image_url(course): + """Returns the image url for the course.""" + loc = course.location._replace(tag='c4x', category='asset', name=course.course_image) + path = StaticContent.get_url_path_from_location(loc) + return path + + class UnitState(object): draft = 'draft' private = 'private' diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index 753df66fe0..aad56e4a2e 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -276,7 +276,12 @@ def get_course_settings(request, org, course, name): "section": "details"}), 'about_page_editable': not settings.MITX_FEATURES.get( 'ENABLE_MKTG_SITE', False - ) + ), + 'upload_asset_url': reverse('upload_asset', kwargs={ + 'org': org, + 'course': course, + 'coursename': name, + }) }) diff --git a/cms/djangoapps/models/settings/course_details.py b/cms/djangoapps/models/settings/course_details.py index 78c5dcff33..99ce00b891 100644 --- a/cms/djangoapps/models/settings/course_details.py +++ b/cms/djangoapps/models/settings/course_details.py @@ -3,7 +3,7 @@ from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.inheritance import own_metadata import json from json.encoder import JSONEncoder -from contentstore.utils import get_modulestore +from contentstore.utils import get_modulestore, course_image_url from models.settings import course_grading from contentstore.utils import update_item from xmodule.fields import Date @@ -23,6 +23,8 @@ class CourseDetails(object): self.overview = "" # html to render as the overview self.intro_video = None # a video pointer self.effort = None # int hours/week + self.course_image_name = "" + self.course_image_asset_path = "" # URL of the course image @classmethod def fetch(cls, course_location): @@ -40,6 +42,8 @@ class CourseDetails(object): course.end_date = descriptor.end course.enrollment_start = descriptor.enrollment_start course.enrollment_end = descriptor.enrollment_end + course.course_image_name = descriptor.course_image + course.course_image_asset_path = course_image_url(descriptor) temploc = course_location.replace(category='about', name='syllabus') try: @@ -121,6 +125,10 @@ class CourseDetails(object): dirty = True descriptor.enrollment_end = converted + if 'course_image_name' in jsondict and jsondict['course_image_name'] != descriptor.course_image: + descriptor.course_image = jsondict['course_image_name'] + dirty = True + if dirty: # Save the data that we've just changed to the underlying # MongoKeyValueStore before we update the mongo datastore. diff --git a/cms/static/js/models/settings/course_details.js b/cms/static/js/models/settings/course_details.js index 4d048bab81..b66f2bbba9 100644 --- a/cms/static/js/models/settings/course_details.js +++ b/cms/static/js/models/settings/course_details.js @@ -10,7 +10,9 @@ CMS.Models.Settings.CourseDetails = Backbone.Model.extend({ syllabus: null, overview: "", intro_video: null, - effort: null // an int or null + effort: null, // an int or null, + course_image_name: '', // the filename + course_image_asset_path: '' // the full URL (/c4x/org/course/num/asset/filename) }, // When init'g from html script, ensure you pass {parse: true} as an option (2nd arg to reset) diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 0cbf573ba9..36bee79d80 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -13,8 +13,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ 'mouseover #timezone' : "updateTime", // would love to move to a general superclass, but event hashes don't inherit in backbone :-( 'focus :input' : "inputFocus", - 'blur :input' : "inputUnfocus" - + 'blur :input' : "inputUnfocus", + 'click .action-upload-image': "uploadImage" }, initialize : function() { @@ -51,6 +51,10 @@ CMS.Views.Settings.Details = CMS.Views.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); + return this; }, fieldToSelectorMap : { @@ -60,7 +64,8 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ 'enrollment_end' : 'enrollment-end', 'overview' : 'course-overview', 'intro_video' : 'course-introduction-video', - 'effort' : "course-effort" + 'effort' : "course-effort", + 'course_image_asset_path': 'course-image-url' }, updateTime : function(e) { @@ -121,6 +126,17 @@ CMS.Views.Settings.Details = CMS.Views.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); + break; case 'course-effort': this.setField(event); break; @@ -216,6 +232,30 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ this.save_message, _.bind(this.saveView, this), _.bind(this.revertView, this)); + }, + + uploadImage: function(event) { + event.preventDefault(); + var upload = new CMS.Models.FileUpload({ + title: gettext("Upload your course image."), + message: gettext("Files must be in JPG format."), + mimeType: "image/jpeg", + fileType: "JPG" + }); + var self = this; + var modal = new CMS.Views.UploadDialog({ + model: upload, + onSuccess: function(response) { + var options = { + 'course_image_name': response.displayname, + 'course_image_asset_path': response.url + } + self.model.set(options); + self.render(); + $('#course-image').attr('src', self.model.get('course_image_asset_path')) + } + }); + $('.wrapper-view').after(modal.show().el); } }); diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index 1430c41368..ca48244f64 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -432,6 +432,20 @@ body.course.settings { } } + // specific fields - course image + #field-course-image { + .current-course-image { + position: relative; + + .action-upload-image { + @extend .ui-btn-flat-outline; + position: absolute; + bottom: 3px; + right: 0; + } + } + } + // specific fields - requirements &.requirements { diff --git a/cms/templates/settings.html b/cms/templates/settings.html index 1f5d89b2b9..96a8e59d9d 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -2,7 +2,7 @@ <%inherit file="base.html" /> <%block name="title">${_("Schedule & Details Settings")} -<%block name="bodyclass">is-signedin course schedule settings +<%block name="bodyclass">is-signedin course schedule settings file-upload-dialog <%namespace name='static' file='static_content.html'/> <%! @@ -22,6 +22,10 @@ from contentstore import utils + + @@ -208,6 +214,21 @@ from contentstore import utils ${overview_text()}
  • +
  • + +
    + % if context_course.course_image: + ${_('Course Image')} + % endif + +
    + +
    + + ${_("Enter your course image's filename.")} +
    +
  • +
  • diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 57b13c10b3..4555395fef 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -338,6 +338,12 @@ class CourseFields(object): show_timezone = Boolean(help="True if timezones should be shown on dates in the courseware", scope=Scope.settings, default=True) enrollment_domain = String(help="External login method associated with user accounts allowed to register in course", scope=Scope.settings) + course_image = String( + help="Filename of the course image", + scope=Scope.settings, + # Ensure that courses imported from XML keep their image + default="images_course_image.jpg" + ) # An extra property is used rather than the wiki_slug/number because # there are courses that change the number for different runs. This allows diff --git a/common/test/data/uploads/image.jpg b/common/test/data/uploads/image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..21ece4ef43ef2528c474f52167068ecf37978554 GIT binary patch literal 13811 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 Date: Mon, 19 Aug 2013 11:27:17 -0400 Subject: [PATCH 339/395] Address PR comments: - Internationalize upload errors. - Move upload tests into their own files. - Refactor upload dialog acceptance tests. --- .../contentstore/features/common.py | 13 ++ .../features/course-settings.feature | 1 + .../contentstore/features/course-settings.py | 18 ++- .../contentstore/features/textbooks.py | 12 +- .../coffee/spec/models/textbook_spec.coffee | 29 ----- .../coffee/spec/models/upload_spec.coffee | 33 +++++ .../coffee/spec/views/textbook_spec.coffee | 118 ------------------ .../coffee/spec/views/upload_spec.coffee | 118 ++++++++++++++++++ cms/static/js/models/uploads.js | 7 +- cms/templates/js/upload-dialog.underscore | 2 +- cms/templates/settings.html | 2 +- 11 files changed, 184 insertions(+), 169 deletions(-) create mode 100644 cms/static/coffee/spec/models/upload_spec.coffee create mode 100644 cms/static/coffee/spec/views/upload_spec.coffee diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 516659fadb..1cc71097a0 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -5,9 +5,11 @@ from lettuce import world, step from nose.tools import assert_true from auth.authz import get_user_by_email, get_course_groupname_for_role +from django.conf import settings from selenium.webdriver.common.keys import Keys import time +import os from django.contrib.auth.models import Group from logging import getLogger @@ -15,6 +17,8 @@ logger = getLogger(__name__) from terrain.browser import reset_data +TEST_ROOT = settings.COMMON_TEST_DATA_ROOT + ########### STEP HELPERS ############## @@ -257,3 +261,12 @@ def type_in_codemirror(index, text): g._element.send_keys(text) if world.is_firefox(): world.trigger_event('div.CodeMirror', index=index, event='blur') + + +def upload_file(filename): + file_css = '.upload-dialog input[type=file]' + upload = world.css_find(file_css).first + path = os.path.join(TEST_ROOT, filename) + upload._element.send_keys(os.path.abspath(path)) + button_css = '.upload-dialog .action-upload' + world.css_click(button_css) diff --git a/cms/djangoapps/contentstore/features/course-settings.feature b/cms/djangoapps/contentstore/features/course-settings.feature index 69183bc3da..8f00452efe 100644 --- a/cms/djangoapps/contentstore/features/course-settings.feature +++ b/cms/djangoapps/contentstore/features/course-settings.feature @@ -76,6 +76,7 @@ Feature: Course Settings 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 0847c62a18..570c49a8c4 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -4,9 +4,8 @@ from lettuce import world, step from terrain.steps import reload_the_page from selenium.webdriver.common.keys import Keys -from common import type_in_codemirror +from common import type_in_codemirror, upload_file from django.conf import settings -import os from nose.tools import assert_true, assert_false, assert_equal @@ -150,16 +149,15 @@ 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_css = '.action-upload-image' - world.css_click(upload_css) - file_css = '.upload-dialog input[type=file]' - upload = world.css_find(file_css) - path = os.path.join(TEST_ROOT, 'image.jpg') - upload._element.send_keys(os.path.abspath(path)) - button_css = '.upload-dialog .action-upload' - world.css_click(button_css) + upload_file('image.jpg') @step('I should see the new course image$') diff --git a/cms/djangoapps/contentstore/features/textbooks.py b/cms/djangoapps/contentstore/features/textbooks.py index d9c08ec6eb..b432b84d4f 100644 --- a/cms/djangoapps/contentstore/features/textbooks.py +++ b/cms/djangoapps/contentstore/features/textbooks.py @@ -3,7 +3,7 @@ from lettuce import world, step from django.conf import settings -import os +from common import upload_file TEST_ROOT = settings.COMMON_TEST_DATA_ROOT @@ -24,14 +24,8 @@ def assert_create_new_textbook_msg(_step): @step(u'I upload the textbook "([^"]*)"$') -def upload_file(_step, file_name): - file_css = '.upload-dialog input[type=file]' - upload = world.css_find(file_css) - # uploading the file itself - path = os.path.join(TEST_ROOT, 'uploads', file_name) - upload._element.send_keys(os.path.abspath(path)) - button_css = ".upload-dialog .action-upload" - world.css_click(button_css) +def upload_textbook(_step, file_name): + upload_file(file_name) @step(u'I click (on )?the New Textbook button') diff --git a/cms/static/coffee/spec/models/textbook_spec.coffee b/cms/static/coffee/spec/models/textbook_spec.coffee index 6e601ecf68..d88e09f57a 100644 --- a/cms/static/coffee/spec/models/textbook_spec.coffee +++ b/cms/static/coffee/spec/models/textbook_spec.coffee @@ -196,32 +196,3 @@ describe "CMS.Collections.ChapterSet", -> # try going back one @collection.remove(@collection.last()) expect(@collection.nextOrder()).toEqual(2) - - -describe "CMS.Models.FileUpload", -> - beforeEach -> - @model = new CMS.Models.FileUpload() - - it "is unfinished by default", -> - expect(@model.get("finished")).toBeFalsy() - - it "is not uploading by default", -> - expect(@model.get("uploading")).toBeFalsy() - - it "is valid by default", -> - expect(@model.isValid()).toBeTruthy() - - it "is valid for PDF files", -> - file = {"type": "application/pdf"} - @model.set("selectedFile", file); - expect(@model.isValid()).toBeTruthy() - - it "is invalid for text files", -> - file = {"type": "text/plain"} - @model.set("selectedFile", file); - expect(@model.isValid()).toBeFalsy() - - it "is invalid for PNG files", -> - file = {"type": "image/png"} - @model.set("selectedFile", file); - expect(@model.isValid()).toBeFalsy() diff --git a/cms/static/coffee/spec/models/upload_spec.coffee b/cms/static/coffee/spec/models/upload_spec.coffee new file mode 100644 index 0000000000..e4be3b9a80 --- /dev/null +++ b/cms/static/coffee/spec/models/upload_spec.coffee @@ -0,0 +1,33 @@ +describe "CMS.Models.FileUpload", -> + beforeEach -> + @model = new CMS.Models.FileUpload() + + it "is unfinished by default", -> + expect(@model.get("finished")).toBeFalsy() + + it "is not uploading by default", -> + expect(@model.get("uploading")).toBeFalsy() + + it "is valid by default", -> + expect(@model.isValid()).toBeTruthy() + + it "is valid for PDF files by default", -> + file = {"type": "application/pdf"} + @model.set("selectedFile", file); + expect(@model.isValid()).toBeTruthy() + + it "is invalid for text files by default", -> + file = {"type": "text/plain"} + @model.set("selectedFile", file); + expect(@model.isValid()).toBeFalsy() + + it "is invalid for PNG files by default", -> + file = {"type": "image/png"} + @model.set("selectedFile", file); + expect(@model.isValid()).toBeFalsy() + + it "can accept non-PDF files when explicitly set", -> + file = {"type": "image/png"} + @model.set("mimeType": "image/png") + @model.set("selectedFile", file) + expect(@model.isValid()).toBeTruthy() diff --git a/cms/static/coffee/spec/views/textbook_spec.coffee b/cms/static/coffee/spec/views/textbook_spec.coffee index 5185c9fb47..ade8c4cb6e 100644 --- a/cms/static/coffee/spec/views/textbook_spec.coffee +++ b/cms/static/coffee/spec/views/textbook_spec.coffee @@ -311,121 +311,3 @@ describe "CMS.Views.EditChapter", -> @view.$(".action-upload").click() expect(@model.get("name")).toEqual("rainbows") expect(@model.get("asset_path")).toEqual("unicorns") - - -describe "CMS.Views.UploadDialog", -> - tpl = readFixtures("upload-dialog.underscore") - - beforeEach -> - setFixtures($(" From c626c773d885bcc15f863e9c7277801c7eef96b0 Mon Sep 17 00:00:00 2001 From: Brian Talbot Date: Mon, 19 Aug 2013 14:17:44 -0400 Subject: [PATCH 340/395] Studio: revises styling/copy around course image management in settings --- cms/static/sass/views/_settings.scss | 61 +++++++++++++++++++++++----- cms/templates/settings.html | 23 ++++++++--- 2 files changed, 69 insertions(+), 15 deletions(-) diff --git a/cms/static/sass/views/_settings.scss b/cms/static/sass/views/_settings.scss index ca48244f64..bddb630bbb 100644 --- a/cms/static/sass/views/_settings.scss +++ b/cms/static/sass/views/_settings.scss @@ -131,7 +131,7 @@ body.course.settings { list-style: none; .field { - margin: 0 0 $baseline 0; + margin: 0 0 ($baseline*2) 0; &:last-child { margin-bottom: 0; @@ -434,16 +434,57 @@ body.course.settings { // specific fields - course image #field-course-image { - .current-course-image { - position: relative; - .action-upload-image { - @extend .ui-btn-flat-outline; - position: absolute; - bottom: 3px; - right: 0; - } + .current-course-image { + margin-bottom: ($baseline/2); + padding: ($baseline/2) $baseline; + background: $gray-l5; + text-align: center; + + .wrapper-course-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; } + + .course-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-image { + @extend .ui-btn-flat-outline; + float: right; + width: flex-grid(2,9); + margin-top: ($baseline/4); + padding: ($baseline/2) $baseline; + } + } } // specific fields - requirements @@ -459,7 +500,7 @@ body.course.settings { margin-bottom: ($baseline*3); .grade-controls { - @include clearfix; + @include clearfix(); width: flex-grid(9,9); } diff --git a/cms/templates/settings.html b/cms/templates/settings.html index ae1051d357..30ae544d18 100644 --- a/cms/templates/settings.html +++ b/cms/templates/settings.html @@ -218,14 +218,27 @@ from contentstore import utils
    % if context_course.course_image: - ${_('Course Image')} + + ${_('Course Image')} + + + <% ctx_loc = context_course.location %> + ${_("You can manage this image along with all of your other")} ${_("files & uploads")} + + % else: + + ${_('Course Image')} + + ${_("Your course currently does not have an image. Please upload one (.jpg format and mimimum suggested dimensions are 375px wide by 200px tall)")} % endif -
    -
    - - ${_("Enter your course image's filename.")} +
    +
    + + ${_("please provide a valid path and name to your course image (Note: only .jpg format supported)")} +
    +
  • From f9aecb2778957573ed074824c3ff51deafb39108 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 20 Aug 2013 12:23:45 -0400 Subject: [PATCH 341/395] Add support for PNGs as course images. Also change the file uploader to accept multiple file types. --- .../coffee/spec/models/upload_spec.coffee | 2 +- cms/static/js/models/uploads.js | 51 +++++++++++++++---- .../js/views/settings/main_settings_view.js | 3 +- 3 files changed, 43 insertions(+), 13 deletions(-) diff --git a/cms/static/coffee/spec/models/upload_spec.coffee b/cms/static/coffee/spec/models/upload_spec.coffee index e4be3b9a80..2b77c2147d 100644 --- a/cms/static/coffee/spec/models/upload_spec.coffee +++ b/cms/static/coffee/spec/models/upload_spec.coffee @@ -28,6 +28,6 @@ describe "CMS.Models.FileUpload", -> it "can accept non-PDF files when explicitly set", -> file = {"type": "image/png"} - @model.set("mimeType": "image/png") + @model.set("mimeTypes": ["image/png"]) @model.set("selectedFile", file) expect(@model.isValid()).toBeTruthy() diff --git a/cms/static/js/models/uploads.js b/cms/static/js/models/uploads.js index 131c554afd..aca115cccf 100644 --- a/cms/static/js/models/uploads.js +++ b/cms/static/js/models/uploads.js @@ -7,22 +7,53 @@ CMS.Models.FileUpload = Backbone.Model.extend({ "uploadedBytes": 0, "totalBytes": 0, "finished": false, - "mimeType": "application/pdf", - "fileType": "PDF" + "mimeTypes": ["application/pdf"] }, - // NOTE: validation functions should return non-internationalized error - // messages. The messages will be passed through gettext in the template. validate: function(attrs, options) { - if(attrs.selectedFile && attrs.selectedFile.type !== this.attributes.mimeType) { + if(attrs.selectedFile && !_.contains(this.attributes.mimeTypes, attrs.selectedFile.type)) { return { message: _.template( - gettext("Only {fileType} files can be uploaded. Please select a file ending in .{fileExtension} to upload."), - { - fileType: this.attributes.fileType, - fileExtension: this.attributes.fileType.toLowerCase() - }), + gettext("Only <%= fileTypes %> files can be uploaded. Please select a file ending in <%= fileExtensions %> to upload."), + this.formatValidTypes() + ), attributes: {selectedFile: true} }; } + }, + // Return a list of this uploader's valid file types + fileTypes: function() { + return _.map( + this.attributes.mimeTypes, + function(type) { + return type.split('/')[1].toUpperCase(); + } + ); + }, + // Return strings for the valid file types and extensions this + // uploader accepts, formatted as natural language + formatValidTypes: function() { + if(this.attributes.mimeTypes.length === 1) { + return { + fileTypes: this.fileTypes()[0], + fileExtensions: this.fileTypes()[0].toLowerCase() + }; + } + var or = gettext('or'); + var formatTypes = function(types) { + return _.template('<%= initial %> <%= or %> <%= last %>', { + initial: _.initial(types).join(', '), + or: or, + last: _.last(types) + }); + }; + return { + fileTypes: formatTypes(this.fileTypes()), + fileExtensions: formatTypes( + _.map(this.fileTypes(), + function(type) { + return '.' + type.toLowerCase(); + }) + ) + }; } }); diff --git a/cms/static/js/views/settings/main_settings_view.js b/cms/static/js/views/settings/main_settings_view.js index 36bee79d80..7304f8e7c0 100644 --- a/cms/static/js/views/settings/main_settings_view.js +++ b/cms/static/js/views/settings/main_settings_view.js @@ -239,8 +239,7 @@ CMS.Views.Settings.Details = CMS.Views.ValidatingView.extend({ var upload = new CMS.Models.FileUpload({ title: gettext("Upload your course image."), message: gettext("Files must be in JPG format."), - mimeType: "image/jpeg", - fileType: "JPG" + mimeTypes: ['image/jpeg', 'image/png'] }); var self = this; var modal = new CMS.Views.UploadDialog({ From a601ede7811226681faf61f8b2e1b199bbd54287 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 20 Aug 2013 13:01:23 -0400 Subject: [PATCH 342/395] Update CHANGELOG. --- CHANGELOG.rst | 3 +++ 1 file changed, 3 insertions(+) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index cd43777e96..749b9ef56e 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -5,6 +5,9 @@ These are notable changes in edx-platform. This is a rolling list of changes, in roughly chronological order, most recent first. Add your entries at or near the top. Include a label indicating the component affected. +Studio: Allow course authors to set their course image on the schedule +and details page, with support for JPEG and PNG images. + Blades: Took videoalpha out of alpha, replacing the old video player Common: Allow instructors to input complicated expressions as answers to From b539a4cb94ee40c8e281d5caf792827f97e0f40b Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Tue, 20 Aug 2013 15:38:01 -0400 Subject: [PATCH 343/395] Update copy to include PNGs, add tests, remove default MIME type. --- .../coffee/spec/models/upload_spec.coffee | 35 +++++++++++++++---- .../coffee/spec/views/upload_spec.coffee | 6 ++-- cms/static/js/models/uploads.js | 4 +-- .../js/views/settings/main_settings_view.js | 2 +- cms/static/js/views/textbook.js | 3 +- cms/templates/settings.html | 4 +-- 6 files changed, 40 insertions(+), 14 deletions(-) diff --git a/cms/static/coffee/spec/models/upload_spec.coffee b/cms/static/coffee/spec/models/upload_spec.coffee index 2b77c2147d..610898745b 100644 --- a/cms/static/coffee/spec/models/upload_spec.coffee +++ b/cms/static/coffee/spec/models/upload_spec.coffee @@ -11,11 +11,6 @@ describe "CMS.Models.FileUpload", -> it "is valid by default", -> expect(@model.isValid()).toBeTruthy() - it "is valid for PDF files by default", -> - file = {"type": "application/pdf"} - @model.set("selectedFile", file); - expect(@model.isValid()).toBeTruthy() - it "is invalid for text files by default", -> file = {"type": "text/plain"} @model.set("selectedFile", file); @@ -26,8 +21,36 @@ describe "CMS.Models.FileUpload", -> @model.set("selectedFile", file); expect(@model.isValid()).toBeFalsy() - it "can accept non-PDF files when explicitly set", -> + it "can accept a file type when explicitly set", -> file = {"type": "image/png"} @model.set("mimeTypes": ["image/png"]) @model.set("selectedFile", file) expect(@model.isValid()).toBeTruthy() + + it "can accept multiple file types", -> + file = {"type": "image/gif"} + @model.set("mimeTypes": ["image/png", "image/jpeg", "image/gif"]) + @model.set("selectedFile", file) + expect(@model.isValid()).toBeTruthy() + + describe "fileTypes", -> + it "returns a list of the uploader's file types", -> + @model.set('mimeTypes', ['image/png', 'application/json']) + expect(@model.fileTypes()).toEqual(['PNG', 'JSON']) + + describe "formatValidTypes", -> + it "returns a map of formatted file types and extensions", -> + @model.set('mimeTypes', ['image/png', 'image/jpeg', 'application/json']) + formatted = @model.formatValidTypes() + expect(formatted).toEqual( + fileTypes: 'PNG, JPEG or JSON', + fileExtensions: '.png, .jpeg or .json' + ) + + it "does not format with only one mime type", -> + @model.set('mimeTypes', ['application/pdf']) + formatted = @model.formatValidTypes() + expect(formatted).toEqual( + fileTypes: 'PDF', + fileExtensions: '.pdf' + ) diff --git a/cms/static/coffee/spec/views/upload_spec.coffee b/cms/static/coffee/spec/views/upload_spec.coffee index 39f1519e05..ebf850be75 100644 --- a/cms/static/coffee/spec/views/upload_spec.coffee +++ b/cms/static/coffee/spec/views/upload_spec.coffee @@ -8,10 +8,12 @@ describe "CMS.Views.UploadDialog", -> appendSetFixtures($("