From 0cadc8af36d088a57eddb550d73cdaeed2762e10 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 12 Jul 2013 14:13:53 -0400 Subject: [PATCH 001/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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 @@
- +
From 1a4be13d4c693dc002a53fc42fc16c4a873ded4c Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 25 Jul 2013 15:40:09 -0400 Subject: [PATCH 017/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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/244] 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 56dee21142d6a453370983cc9373e9cb93b99523 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 7 Aug 2013 16:35:14 -0400 Subject: [PATCH 031/244] 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 203b1176cdee75f4e1096db6d906d36cb082ce01 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 12:14:57 -0400 Subject: [PATCH 032/244] 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 033/244] 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 034/244] 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 035/244] 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 036/244] 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 803b7eb2af4a833bf78c1c7b1164e2142575e1a2 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 16:31:36 -0400 Subject: [PATCH 037/244] 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 038/244] 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 039/244] 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 900377e4eecea5520b7c886a431e3c0ef7e5c616 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 17:53:29 -0400 Subject: [PATCH 040/244] 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 041/244] 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 e4bcfa5c219eb2270eaea15754e82e3103245c60 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 8 Aug 2013 19:31:34 -0400 Subject: [PATCH 042/244] 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 043/244] 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 044/244] 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 045/244] 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 046/244] 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 047/244] 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 33da3ec1cfa5080250bf70a5b10f41c7ccc8e069 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 14:50:57 -0400 Subject: [PATCH 048/244] 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 c4a2f7dcdc52d28988fca6dc54b4820ece22bcc7 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 9 Aug 2013 17:01:07 -0400 Subject: [PATCH 049/244] Fix invalid state --- .../combined_open_ended_modulev1.py | 42 +++++++++++++++++++ 1 file changed, 42 insertions(+) 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..ba36e1e2ee 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 @@ -135,8 +135,50 @@ class CombinedOpenEndedV1Module(): self.task_xml = definition['task_xml'] self.location = location + self.fix_invalid_state() self.setup_next_task() + def fix_invalid_state(self): + """ + Sometimes a teacher will change the xml definition of a problem in Studio. + This means that the state passed to the module is invalid. + If that is the case, delete it. + """ + + #If we are on a task that is greater than the number of available tasks, it is an invalid state + if self.current_task_number>len(self.task_states): + self.task_states = [] + self.current_task_number = 0 + #If the current task number is greater than the number of tasks we have in the xml definition, our state is invalid. + elif self.current_task_number>len(self.task_xml): + self.task_states = [] + self.current_task_number = 0 + #if the length of the task xml is less than the length of the task states, state is invalid + elif len(self.task_xml) Date: Fri, 9 Aug 2013 18:04:11 -0400 Subject: [PATCH 050/244] Better handle edge cases --- .../combined_open_ended_modulev1.py | 26 +++++++++++++------ 1 file 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 ba36e1e2ee..abf6474b00 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 @@ -145,18 +145,17 @@ class CombinedOpenEndedV1Module(): If that is the case, delete it. """ + info_message = "Combined open ended user state for user {0} in location {1} was invalid. Reset it.".format(self.system.anonymous_student_id, self.location.url()) #If we are on a task that is greater than the number of available tasks, it is an invalid state - if self.current_task_number>len(self.task_states): - self.task_states = [] - self.current_task_number = 0 #If the current task number is greater than the number of tasks we have in the xml definition, our state is invalid. - elif self.current_task_number>len(self.task_xml): - self.task_states = [] - self.current_task_number = 0 - #if the length of the task xml is less than the length of the task states, state is invalid + if self.current_task_number>len(self.task_states) or self.current_task_number>len(self.task_xml): + self.current_task_number = min([len(self.task_states),len(self.task_xml)]) + log.info(info_message) + #If the length of the task xml is less than the length of the task states, state is invalid elif len(self.task_xml) Date: Fri, 9 Aug 2013 18:44:15 -0400 Subject: [PATCH 051/244] 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 ab810006651a03cc9500a4d5bc376ba27cb65c7e Mon Sep 17 00:00:00 2001 From: marco Date: Tue, 13 Aug 2013 14:42:34 -0400 Subject: [PATCH 052/244] visual styling changes to staff grading and peer grading, along with additional instances of the use of the baseline variable instead of pixel definitions for padding and margins. --- .../lib/xmodule/xmodule/css/capa/display.scss | 11 +- .../css/combinedopenended/display.scss | 693 ++++++++++-------- lms/static/sass/course/_staff_grading.scss | 200 +++-- .../combined_open_ended_results.html | 2 +- .../peer_grading/peer_grading_problem.html | 6 +- 5 files changed, 483 insertions(+), 429 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index a35dc01633..4bc8fb3f55 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -282,10 +282,9 @@ section.problem { .grader-status { @include clearfix; - margin-bottom: $baseline; - padding: 9px; - border: 1px solid #ddd; - border-top: 0; + margin: $baseline/2 0; + padding: $baseline/2; + border-radius: 5px; background: #F6F6F6; span { @@ -311,11 +310,11 @@ section.problem { } &.file { - background: #fff; margin-top: $baseline; padding: $baseline 0 0 0; border: 0; border-top: 1px solid #eee; + background: #fff; p.debug { display: none; @@ -335,8 +334,8 @@ section.problem { .feedback-on-feedback { - height: 100px; margin-right: $baseline; + height: 100px; } .evaluation-response { diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index d4a79ff825..c0235d5df9 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -23,8 +23,8 @@ h2 { div.name{ padding-bottom: 15px; - h2{ - display: inline; + h2 { + display: inline; } .progress-container { @@ -77,8 +77,8 @@ div.problemwrapper { } .assessments-container { - padding: $baseline/2 $baseline $baseline/2 $baseline/2; float: right; + padding: $baseline/2 $baseline $baseline/2 $baseline/2; .assessment-text { display: inline-block; @@ -93,9 +93,9 @@ div.problemwrapper { } .result-container { - float:left; + float: left; width: 100%; - position:relative; + position: relative; } } @@ -107,7 +107,7 @@ section.legend-container { display: inline; padding: $baseline/2; width: 20%; - background-color : #eee; + background-color: #eee; font-size: .9em; } } @@ -119,18 +119,18 @@ section.combined-open-ended-status { display: table-cell; padding: $baseline/2; width: 30px; + border-right: 1px solid lightgray; background-color: #eee; color: #2c2c2c; font-size: .9em; - border-right: 1px solid lightgray; &:first-child { border-radius: $baseline/4 0 0 $baseline/4; } &:last-child { - border-radius: 0 $baseline/4 $baseline/4 0; border-right: 0px; + border-radius: 0 $baseline/4 $baseline/4 0; } &:only-child { @@ -183,37 +183,36 @@ section.combined-open-ended-status { .icon-caret-right { display: inline-block; - vertical-align: baseline; margin-right: ($baseline/4); + vertical-align: baseline; } } - // Problem Section Controls +// Problem Section Controls .visibility-control, .visibility-control-prompt { display: block; - height: 40px; - width: 100%; + width: 100%; + height: 40px; .inner { float: left; - height: 5px; margin-top: $baseline; - border-top: 1px dotted #ddd; - width: 85%; + width: 85%; + height: 5px; + border-top: 1px dotted #ddd; } } .section-header { display: block; - text-align: center; - width: 15%; float: right; padding-top: $baseline/2; + width: 15%; + text-align: center; font-size: .9em; } - // Rubric Styling .wrapper-score-selection { @@ -221,7 +220,6 @@ section.combined-open-ended-status { padding: 0 $baseline/2; width: 20px; vertical-align: middle; - } .wrappable { @@ -240,17 +238,17 @@ section.combined-open-ended-status { span.rubric-category { display: block; + margin-bottom: $baseline/2; + padding-top: $baseline/2; width: 100%; border-bottom: 1px solid lightgray; font-size: 1.1em; - padding-top: $baseline/2; - margin-bottom: $baseline/2; } div.combined-rubric-container { margin: 15px; - padding-bottom: 5px; - padding-top: 10px; + padding-top: $baseline/2; + padding-bottom: $baseline/4; ul.rubric-list { margin: 0 $baseline $baseline/2 $baseline; @@ -265,10 +263,11 @@ div.combined-rubric-container { } } } + h4 { - padding-top: 10px; - border-top: 1px solid; + padding-top: $baseline/2; border-color: lightgray; + border-top: 1px solid; } span.rubric-category { @@ -286,12 +285,12 @@ div.combined-rubric-container { } } - label.choicegroup_partialcorrect { - &:before { - margin-right: 15px; - content: url('../images/partially-correct-icon.png'); - } + label.choicegroup_partialcorrect { + &:before { + margin-right: 15px; + content: url('../images/partially-correct-icon.png'); } + } label.choicegroup_incorrect { &:before { @@ -300,150 +299,184 @@ div.combined-rubric-container { } } } - + div.result-container { - padding-top: 10px; - padding-bottom: 5px; - .evaluation { + padding-top: $baseline/2; + padding-bottom: $baseline/4; - p { - margin-bottom: 1px; + .evaluation { + p { + margin-bottom: 1px; + } + } + + .feedback-on-feedback { + height: 100px; + margin-right: 0; + } + + .evaluation-response { + margin-bottom: 2px; + + header { + a { + font-size: .85em; + } + } + } + + .evaluation-scoring { + .scoring-list { + margin-left: 3px; + list-style-type: none; + + li { + display:inline; + margin-left: 0; + + &:first-child { + margin-left: 0; } - } - .feedback-on-feedback { - height: 100px; - margin-right: 0px; - } - - .evaluation-response { - margin-bottom: 2px; - header { - a { - font-size: .85em; - } + label { + font-size: .9em; } + } } - .evaluation-scoring { - .scoring-list { - list-style-type: none; - margin-left: 3px; + } - li { - &:first-child { - margin-left: 0px; - } - display:inline; - margin-left: 0px; + .submit-message-container { + margin: $baseline/2 0; + } - label { - font-size: .9em; - } - } + .external-grader-message { + margin-bottom: $baseline/4; + + section { + padding-left: $baseline; + background-color: #fafafa; + color: #2c2c2c; + font-family: monospace; + font-size: 1em; + padding-top: $baseline/2; + padding-bottom:30px; + + header { + font-size: 1.4em; + } + + .shortform { + font-weight: bold; + } + + .longform { + padding: 0; + margin: 0; + + .result-errors { + 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; + } } - } - .submit-message-container { - margin: 10px 0px ; - } - .external-grader-message { - margin-bottom: 5px; - section { - padding-left: 20px; - background-color: #FAFAFA; - color: #2C2C2C; - font-family: monospace; - font-size: 1em; - padding-top: 10px; - padding-bottom:30px; - header { - font-size: 1.4em; + .result-output { + margin: $baseline/4; + padding: $baseline 0 15px 50px; + border-top: 1px solid #ddd; + border-left: 20px solid #fafafa; + + h4 { + font-size: 1em; + font-family: monospace; } - .shortform { - font-weight: bold; + dl { + margin: 0; } - .longform { - padding: 0px; - margin: 0px; + dt { + margin-top: $baseline; + } - .result-errors { - margin: 5px; - padding: 10px 10px 10px 40px; - background: url('../images/incorrect-icon.png') center left no-repeat; - li { - color: #B00; - } - } + dd { + margin-left: 24pt; + } + } - .result-output { - margin: 5px; - padding: 20px 0px 15px 50px; - border-top: 1px solid #DDD; - border-left: 20px solid #FAFAFA; + .markup-text{ + margin: $baseline/4; + padding: $baseline 0 15px 50px; + border-top: 1px solid #ddd; + border-left: 20px solid #fafafa; - h4 { - font-family: monospace; - font-size: 1em; - } + bs { + color: #bb0000; + } - dl { - margin: 0px; - } - - dt { - margin-top: 20px; - } - - dd { - margin-left: 24pt; - } - } - - .markup-text{ - margin: 5px; - padding: 20px 0px 15px 50px; - border-top: 1px solid #DDD; - border-left: 20px solid #FAFAFA; - - bs { - color: #BB0000; - } - - bg { - color: #BDA046; - } - } + bg { + color: #bda046; } } } + } + } + .rubric-result-container { + padding: 2px; + margin: 0px; + display : inline; + .rubric-result { font-size: .9em; padding: 2px; display: inline-table; } - padding: 2px; - margin: 0px; - display : inline; } } 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; - } + ul.rubric-list{ + margin: 0 $baseline $baseline/2 $baseline; + padding: 0; + list-style: none; + list-style-type: none; + + li { + &.rubric-list-item { + margin-bottom: 2px; + padding: $baseline/2; + border-radius: $baseline/4; + + &:hover { + background-color: #eee; + } + + .wrapper-score-selection { + display: table-cell; + padding: 0 $baseline/2; + width: 20px; + vertical-align: middle; + } + + .wrappable { + display: table-cell; + padding: $baseline/4; + } } } + } + + span.rubric-category { + display: block; + width: 100%; + border-bottom: 1px solid lightgray; + font-weight: bold; + font-size: .9em; + } } @@ -490,188 +523,186 @@ section.open-ended-child { } } - p { - &.answer { - margin-top: -2px; - } - &.status { - margin: 8px 0 0 $baseline/2; - text-indent: -9999px; - } + p { + &.answer { + margin-top: -2px; } - - div.unanswered { - p.status { - @include inline-block(); - background: url('../images/unanswered-icon.png') center center no-repeat; - height: 14px; - width: 14px; - } + &.status { + margin: 8px 0 0 $baseline/2; + text-indent: -9999px; } + } - div.correct, div.ui-icon-check { - p.status { - @include inline-block(); - width: 25px; - height: 20px; - background: url('../images/correct-icon.png') center center no-repeat; - } - - input { - border-color: green; - } - } - - div.processing { - p.status { - @include inline-block(); - width: 20px; - height: 20px; - background: url('../images/spinner.gif') center center no-repeat; - } - - input { - border-color: #aaa; - } - } - - div.incorrect, div.ui-icon-close { - p.status { - @include inline-block(); - width: 20px; - height: 20px; - background: url('../images/incorrect-icon.png') center center no-repeat; - text-indent: -9999px; - } - - input { - border-color: red; - } - } - - > span { - display: block; - margin-bottom: lh(.5); - } - - p.answer { + div.unanswered { + p.status { @include inline-block(); - margin-bottom: 0; - margin-left: 10px; + width: 14px; + height: 14px; + background: url('../images/unanswered-icon.png') center center no-repeat; + } + } + div.correct, div.ui-icon-check { + p.status { + @include inline-block(); + width: 25px; + height: 20px; + background: url('../images/correct-icon.png') center center no-repeat; + } + + input { + border-color: green; + } + } + + div.processing { + p.status { + @include inline-block(); + width: 20px; + height: 20px; + background: url('../images/spinner.gif') center center no-repeat; + } + + input { + border-color: #aaa; + } + } + + div.incorrect, div.ui-icon-close { + p.status { + @include inline-block(); + width: 20px; + height: 20px; + background: url('../images/incorrect-icon.png') center center no-repeat; + text-indent: -9999px; + } + + input { + border-color: red; + } + } + + > span { + display: block; + margin-bottom: lh(.5); + } + + p.answer { + @include inline-block(); + margin-bottom: 0; + margin-left: $baseline/2; + + &:before { + content: "Answer: "; + font-weight: bold; + display: inline; + + } + &:empty { &:before { - content: "Answer: "; - font-weight: bold; - display: inline; - - } - &:empty { - &:before { - display: none; - } + display: none; } } + } + + span { + &.unanswered, &.ui-icon-bullet { + @include inline-block(); + 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(); + position: relative; + top: 6px; + width: 25px; + height: 20px; + background: url('../images/spinner.gif') center center no-repeat; + } + + &.correct, &.ui-icon-check { + @include inline-block(); + 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(); + position: relative; + top: 6px; + width: 20px; + height: 20px; + background: url('../images/incorrect-icon.png') center center no-repeat; + } + } + + .reload { + float:right; + margin: $baseline/2; + } + + div.short-form-response { + @include clearfix; + overflow-y: auto; + margin-bottom: 0; + padding: $baseline/2; + min-height: 20px; + height: auto; + border: 1px solid #ddd; + background: #f6f6f6; + } + + .grader-status { + @include clearfix; + margin: $baseline/2 0; + padding: $baseline/2; + border-radius: 5px; + background: #f6f6f6; span { - &.unanswered, &.ui-icon-bullet { - @include inline-block(); - 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(); - position: relative; - top: 6px; - width: 25px; - height: 20px; - background: url('../images/spinner.gif') center center no-repeat; - } - - &.correct, &.ui-icon-check { - @include inline-block(); - 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(); - position: relative; - top: 6px; - width: 20px; - height: 20px; - background: url('../images/incorrect-icon.png') center center no-repeat; - } + display: block; + float: left; + overflow: hidden; + margin: -7px 7px 0 0; + text-indent: -9999px; } - .reload { - float:right; - margin: 10px; + .grading { + margin: 0 7px 0 0; + padding-left: 25px; + background: url('../images/info-icon.png') left center no-repeat; + text-indent: 0; } - div.short-form-response { - @include clearfix; - overflow-y: auto; + p { + float: left; margin-bottom: 0; - padding: $baseline/2; - height: auto; - min-height: 20px; - border: 1px solid #ddd; - background: #f6f6f6; + line-height: 20px; } - .grader-status { - @include clearfix; - margin-bottom: $baseline; - padding: 9px; - border: 1px solid #ddd; - border-top: 0; - background: #f6f6f6; + &.file { + margin-top: $baseline; + padding: $baseline 0 0 0; + border: 0; + border-top: 1px solid #eee; + background: #fff; - span { - display: block; + p.debug { + display: none; + } + + input { float: left; - overflow: hidden; - margin: -7px 7px 0 0; - text-indent: -9999px; } - - .grading { - margin: 0 7px 0 0; - padding-left: 25px; - background: url('../images/info-icon.png') left center no-repeat; - text-indent: 0; - } - - p { - float: left; - margin-bottom: 0; - line-height: 20px; - } - - &.file { - margin-top: $baseline; - padding: $baseline 0 0 0; - border: 0; - border-top: 1px solid #eee; - background: #fff; - - p.debug { - display: none; - } - - input { - float: left; - } - } - } + } form.option-input { margin: -$baseline/2 0 $baseline; @@ -688,19 +719,20 @@ section.open-ended-child { margin-left: .75rem; } - 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; - } + 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; } } + } ol { margin-bottom: lh(); @@ -860,18 +892,49 @@ section.open-ended-child { .oe-tools { display: inline-block; + padding-left: $baseline; width: 100%; border-radius: 5px; .oe-tools-label { - font-size: 0.8em; display: inline-block; + padding: $baseline/2; vertical-align: middle; - padding: 10px; + font-size: 0.8em; } + .next-step-button { + margin: $baseline/2; + } .reset-button { vertical-align: middle; } } +// Staff Grading +.problem-list-container { + margin: $baseline/2; + + .instructions { + padding-bottom: $baseline/2; + } +} + +.staff-grading { + + .breadcrumbs { + padding: $baseline/10; + background-color: #f6f6f6; + border-radius: 5px; + margin-bottom: $baseline/2; + } + + .prompt-wrapper { + padding-top: $baseline/2; + + .meta-info-wrapper { + padding: $baseline/2; + border-radius: $baseline/4; + } + } +} diff --git a/lms/static/sass/course/_staff_grading.scss b/lms/static/sass/course/_staff_grading.scss index b387d753d1..4dd225199c 100644 --- a/lms/static/sass/course/_staff_grading.scss +++ b/lms/static/sass/course/_staff_grading.scss @@ -1,46 +1,50 @@ div.staff-grading, div.peer-grading{ + padding: $baseline; + border: none; + textarea.feedback-area { + margin: 0; height: 75px; - margin: 0px; } ul.rubric-list{ + margin: 0; + padding: 0; list-style-type: none; - padding:0; - margin:0; + li { - &.rubric-list-item{ - margin-bottom: 0px; - padding: 0px; - } + &.rubric-list-item{ + margin-bottom: 0; + padding: 0; + } } } h1 { - margin : 0 0 0 10px; + margin: 0 0 0 $baseline/2; } - h2{ - a - { + h2 { + a { text-size: .5em; } } div { - margin: 0px; + margin: 0; + &.submission-container{ - overflow-y: auto; - height: 150px; - background: #F6F6F6; - border: 1px solid #ddd; - @include clearfix; + @include clearfix; + overflow-y: auto; + height: 150px; + border: 1px solid #ddd; + background: #F6F6F6; } } label { - margin: 0px; + margin: 0; padding: 2px; min-width: 50px; background-color: white; @@ -58,143 +62,127 @@ div.peer-grading{ display: none; } - .problem-list - { - text-align: center; - table-layout: auto; + .problem-list { width:100%; - th - { + table-layout: auto; + text-align: center; + + th { padding: 2px; } - td - { - padding:2px; + + td { + padding: 2px; } - td.problem-name - { - text-align:left; + + td.problem-name { + text-align: left; } - .ui-progressbar - { - height:1em; - margin:0px; - padding:0px; + + .ui-progressbar { + margin: 0; + padding: 0; + height: 1em; } } .prompt-information-container, .rubric-wrapper, .calibration-feedback-wrapper, - .grading-container - { - padding: 2px; + .grading-container { + padding: $baseline/2 0; } - .error-container - { - background-color: #FFCCCC; + + .error-container { + margin-left: 0; padding: 2px; - margin-left: 0px; + background-color: #ffcccc; } - .submission-wrapper - { - h3 - { - margin-bottom: 2px; - } - p - { - margin-left:2px; - } + + .submission-wrapper { padding: 2px; padding-bottom: 15px; + + h3 { + margin-bottom: 2px; + } + + p { + margin-left: 2px; + } } - .meta-info-wrapper - { - background-color: #eee; + .meta-info-wrapper { padding:2px; - div - { - display : inline; + background-color: #eee; + + div { + display: inline; } } .message-container, - .grading-message - { - background-color: $yellow; + .grading-message { + margin-left: 0; padding: 2px; - margin-left:0px; + background-color: $yellow; } - .breadcrumbs - { - margin-top:2px; - margin-left:0px; - margin-bottom:2px; + .breadcrumbs { + margin: $baseline/2 $baseline/4; font-size: .8em; } - .instructions-panel - { - + .instructions-panel { + @include clearfix; margin-right:2px; - > div - { - padding: 2px; + + > div { margin-bottom: 5px; + padding: 2px; + width: 47.6%; background: #eee; - width:47.6%; - h3 - { - text-align:center; - text-transform:uppercase; + + h3 { color: #777; + text-align: center; + text-transform: uppercase; } - p - { + + p{ color: #777; } } - .calibration-panel - { - float:left; + .calibration-panel { + float: left; } - .grading-panel - { - float:right; + + .grading-panel { + float: right; } - .current-state - { - background: #1D9DD9; - h3, p - { + .current-state { + background: #1d9dd9; + + h3, p { color: white; } } - @include clearfix; } + .collapsible { + margin-left: 0; - .collapsible - { - margin-left: 0px; - header - { - margin-top:2px; - margin-bottom:2px; + header { + margin-top: 2px; + margin-bottom: 2px; font-size: 1.2em; } } - .interstitial-page - { + .interstitial-page { text-align: center; - input[type=button] - { - margin-top: 20px; + + input[type=button] { + margin-top: $baseline; } } - padding: 15px; - border: none; } diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 99ea6dc46d..95817e4588 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -12,7 +12,7 @@ Submitted Rubric
    - Assessment Tools: + % if len(results)>1: Previous diff --git a/lms/templates/peer_grading/peer_grading_problem.html b/lms/templates/peer_grading/peer_grading_problem.html index d99e14c706..a46f9135a0 100644 --- a/lms/templates/peer_grading/peer_grading_problem.html +++ b/lms/templates/peer_grading/peer_grading_problem.html @@ -14,7 +14,11 @@
    -

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

    +
    +
    +
    + ${_('Hide Prompt')} +
    From 3a970d14904c831fe6727e4ae6966361cf707deb Mon Sep 17 00:00:00 2001 From: marco Date: Tue, 13 Aug 2013 20:56:53 -0400 Subject: [PATCH 053/244] restyled controls area for motion through multiple scored peer reviews --- .../css/combinedopenended/display.scss | 14 +++++++++++++- .../combined_open_ended_results.html | 19 +++++++++++-------- 2 files changed, 24 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index c0235d5df9..ef39d8a1b6 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -896,13 +896,25 @@ section.open-ended-child { width: 100%; border-radius: 5px; - .oe-tools-label { + .oe-tools-label, .oe-tools-scores-label { display: inline-block; padding: $baseline/2; vertical-align: middle; font-size: 0.8em; } + .rubric-button { + padding: 8px $baseline/4; + } + + .rubric-previous-button { + margin-right: $baseline/4; + } + + .rubric-next-button { + margin-left: $baseline/4; + } + .next-step-button { margin: $baseline/2; } diff --git a/lms/templates/combinedopenended/combined_open_ended_results.html b/lms/templates/combinedopenended/combined_open_ended_results.html index 95817e4588..b013586d4f 100644 --- a/lms/templates/combinedopenended/combined_open_ended_results.html +++ b/lms/templates/combinedopenended/combined_open_ended_results.html @@ -12,15 +12,18 @@ Submitted Rubric
    - + Rubric: - % if len(results)>1: - Previous - % endif - ${result['task_name']} from grader ${i+1} - % if len(results)>1: - Next - % endif + + Scores: + % if len(results)>1: + + % endif + ${result['task_name']} from grader ${i+1} + % if len(results)>1: + + % endif +
    ${result['result'] | n}
    From c9807db308fa7f17d8eb77564cc916ccf53e8ab6 Mon Sep 17 00:00:00 2001 From: marco Date: Wed, 14 Aug 2013 14:12:04 -0400 Subject: [PATCH 054/244] 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 @@
    - 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 6f657c1124190ab388f24f93b156d6b217344007 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 10:30:16 -0400 Subject: [PATCH 055/244] 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: Thu, 15 Aug 2013 13:56:17 -0400 Subject: [PATCH 056/244] 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 057/244] 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 058/244] 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 059/244] 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 060/244] 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 35a540a0f64b2fb26cdb82a5585cc9a15e2ac87d Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 17:25:04 -0400 Subject: [PATCH 061/244] Fix bug with leaving open ended response page and coming back --- .../js/src/combinedopenended/display.coffee | 43 ++++++++++++++----- 1 file changed, 33 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index f2888c0144..a1792a3a3d 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -124,6 +124,7 @@ class @CombinedOpenEnded rubric_collapse_sel: '.rubric-collapse' next_rubric_sel: '.rubric-next-button' previous_rubric_sel: '.rubric-previous-button' + oe_alert_sel: '.open-ended-alert' constructor: (el) -> @el=el @@ -137,6 +138,7 @@ class @CombinedOpenEnded $(selector, @el) reinitialize: (element) -> + @has_been_reset = false @wrapper=@$(@wrapper_sel) @coe = @$(@coe_sel) @@ -253,6 +255,30 @@ class @CombinedOpenEnded if response.hide_reset @reset_button.hide() + get_last_response: () => + @submit_button.hide() + @answer_area.attr("disabled", true) + data = {} + $.postWithPrefix "#{@ajax_url}/get_last_response", data, (response) => + if response.success && response.response != "" + @answer_area.html(response.response) + if response.state!='initial' + @submit_button.hide() + @answer_area.attr("disabled", true) + if @has_been_reset + @submit_button.show() + @answer_area.attr("disabled", false) + @gentle_alert "Here is your previous answer to this qu + estion." + else if @allow_reset=="True" + @reset_button.show() + @gentle_alert "You may reset and answer this question again." + else + @gentle_alert "You have answered this question." + else + @submit_button.show() + @answer_area.attr("disabled", false) + @$(@oe_alert_sel).animate(opacity: 0, 700) show_status_current: () => data = {} @@ -261,12 +287,6 @@ class @CombinedOpenEnded @status_container.after(response.html).remove() @status_container= $(@status_container_sel) - get_last_response: () => - data = {} - $.postWithPrefix "#{@ajax_url}/get_last_response", data, (response) => - if response.success - console.log(response.response) - message_post: (event)=> external_grader_message=$(event.target).parent().parent().parent() evaluation_scoring = $(event.target).parent() @@ -301,6 +321,7 @@ class @CombinedOpenEnded rebind: () => + @get_last_response() # rebind to the appropriate function for the current state @submit_button.unbind('click') @submit_button.show() @@ -308,6 +329,7 @@ class @CombinedOpenEnded @hide_file_upload() @next_problem_button.hide() @hint_area.attr('disabled', false) + if @task_number>1 or @child_state!='initial' @show_status_current() @@ -376,6 +398,7 @@ class @CombinedOpenEnded save_answer: (event) => event.preventDefault() @submit_button.hide() + @answer_area.attr("disabled", true) max_filesize = 2*1000*1000 #2MB pre_can_upload_files = @can_upload_files if @child_state == 'initial' @@ -414,7 +437,6 @@ class @CombinedOpenEnded @gentle_alert response.error $.ajaxWithPrefix("#{@ajax_url}/save_answer",settings) - else @errors_area.html(@out_of_sync_message) @@ -492,6 +514,7 @@ class @CombinedOpenEnded @coe.after(response.html).remove() @allow_reset="False" @reinitialize(@element) + @has_been_reset = true @rebind() @reset_button.hide() else @@ -523,11 +546,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(@oe_alert_sel).length + @$el.find(@oe_alert_sel).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(@oe_alert_sel).css(opacity: 0).animate(opacity: 1, 700) queueing: => if @child_state=="assessing" and @child_type=="openended" From b6a58085484216ab810f9c2095b7cdd699ff63f3 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 17:50:36 -0400 Subject: [PATCH 062/244] Show submit button correctly for self assessment --- .../xmodule/js/src/combinedopenended/display.coffee | 11 +++++++++-- 1 file changed, 9 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 a1792a3a3d..fc1e2243bc 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -273,8 +273,6 @@ class @CombinedOpenEnded else if @allow_reset=="True" @reset_button.show() @gentle_alert "You may reset and answer this question again." - else - @gentle_alert "You have answered this question." else @submit_button.show() @answer_area.attr("disabled", false) @@ -362,6 +360,8 @@ class @CombinedOpenEnded @queueing() @grader_status = @$(@grader_status_sel) @grader_status.html("Your response has been submitted. Please check back later for your grade. ") + else if @child_type == "selfassessment" + @setup_score_selection() else if @child_state == 'post_assessment' if @child_type=="openended" @skip_button.show() @@ -691,3 +691,10 @@ class @CombinedOpenEnded info_rubric_elements = @$(@info_rubric_elements_sel) info_rubric_elements.slideToggle() return false + + setup_score_selection: () => + @$("input[class='score-selection']").change @graded_callback + + graded_callback: () => + if @rub.check_complete() + @submit_button.show() From a49da5d530ccd0e61ac17358362ad9b918908723 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 18:55:42 -0400 Subject: [PATCH 063/244] Fix rubrics, strip out get results --- .../js/src/combinedopenended/display.coffee | 94 ++++--------------- .../combined_open_ended_modulev1.py | 57 ++--------- 2 files changed, 28 insertions(+), 123 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index fc1e2243bc..8be83acd23 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -15,7 +15,6 @@ class @Rubric # display the 'current' carat @categories = @$(@rubric_category_sel) @category = @$(@categories.first()) - @category.prepend('> ') @category_index = 0 # locally scoped jquery. @@ -46,12 +45,8 @@ class @Rubric return inputs.filter("input[value=#{selected}]").click() - # move to the next category - old_category_text = @category.html().substring(5) - @category.html(old_category_text) @category_index++ @category = @$(@categories[@category_index]) - @category.prepend('> ') tracking_callback: (event) => target_selection = $(event.target).val() @@ -98,7 +93,6 @@ class @CombinedOpenEnded 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' @@ -137,15 +131,18 @@ class @CombinedOpenEnded $: (selector) -> $(selector, @el) - reinitialize: (element) -> + reinitialize: () -> @has_been_reset = false @wrapper=@$(@wrapper_sel) @coe = @$(@coe_sel) + @ajax_url = @coe.data('ajax-url') + @get_html() + @coe = @$(@coe_sel) + #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') @@ -163,8 +160,6 @@ class @CombinedOpenEnded @next_problem_button = @$(@next_step_sel) @next_problem_button.click @next_problem - @status_container = @$(@status_container_sel) - #setup show results @show_results_button=@$(@show_results_sel) @show_results_button.click @show_results @@ -183,6 +178,7 @@ class @CombinedOpenEnded # Where to put the rubric once we load it @oe = @$(@open_ended_child_sel) + @errors_area = @$(@oe).find(@error_sel) @answer_area = @$(@oe).find(@answer_area_sel) @prompt_container = @$(@oe).find(@prompt_sel) @@ -212,33 +208,18 @@ class @CombinedOpenEnded @rebind() - show_results_current: () => - data = {'task_number' : @task_number-1} - $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => - if response.success - 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 - $('.evaluation-response a').click @log_feedback_click - $('input[name="evaluation-score"]').change @log_feedback_selection + get_html_callback: (response) => + @coe.replaceWith(response.html) - show_results: (event) => - status_item = $(event.target).parent() - status_number = status_item.data('status-number') - data = {'task_number' : status_number} - $.postWithPrefix "#{@ajax_url}/get_results", data, (response) => - if response.success - @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) - else - @gentle_alert response.error + get_html: () => + url = "#{@ajax_url}/get_html" + $.ajaxWithPrefix({ + type: 'POST', + url: url, + data: {}, + success: @get_html_callback, + async:false + }); show_combined_rubric_current: () => data = {} @@ -255,36 +236,6 @@ class @CombinedOpenEnded if response.hide_reset @reset_button.hide() - get_last_response: () => - @submit_button.hide() - @answer_area.attr("disabled", true) - data = {} - $.postWithPrefix "#{@ajax_url}/get_last_response", data, (response) => - if response.success && response.response != "" - @answer_area.html(response.response) - if response.state!='initial' - @submit_button.hide() - @answer_area.attr("disabled", true) - if @has_been_reset - @submit_button.show() - @answer_area.attr("disabled", false) - @gentle_alert "Here is your previous answer to this qu - estion." - else if @allow_reset=="True" - @reset_button.show() - @gentle_alert "You may reset and answer this question again." - else - @submit_button.show() - @answer_area.attr("disabled", false) - @$(@oe_alert_sel).animate(opacity: 0, 700) - - show_status_current: () => - data = {} - $.postWithPrefix "#{@ajax_url}/get_status", data, (response) => - if response.success - @status_container.after(response.html).remove() - @status_container= $(@status_container_sel) - message_post: (event)=> external_grader_message=$(event.target).parent().parent().parent() evaluation_scoring = $(event.target).parent() @@ -319,7 +270,6 @@ class @CombinedOpenEnded rebind: () => - @get_last_response() # rebind to the appropriate function for the current state @submit_button.unbind('click') @submit_button.show() @@ -328,9 +278,6 @@ class @CombinedOpenEnded @next_problem_button.hide() @hint_area.attr('disabled', false) - if @task_number>1 or @child_state!='initial' - @show_status_current() - if @task_number==1 and @child_state=='assessing' @prompt_hide() if @child_state == 'done' @@ -338,7 +285,7 @@ class @CombinedOpenEnded if @child_type=="openended" @skip_button.hide() if @allow_reset=="True" - @show_results_current + @show_combined_rubric_current() @reset_button.show() @submit_button.hide() @answer_area.attr("disabled", true) @@ -375,7 +322,6 @@ class @CombinedOpenEnded @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() @@ -539,7 +485,7 @@ class @CombinedOpenEnded @gentle_alert "Moved to next step." else @gentle_alert "Your score did not meet the criteria to move to the next step." - @show_results_current() + @show_combined_rubric_current() else @errors_area.html(response.error) else @@ -591,7 +537,7 @@ class @CombinedOpenEnded # wrap this so that it can be mocked reload: -> - location.reload() + @reinitialize() collapse_question: (event) => @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 0294a022b9..7e95e27089 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 @@ -67,7 +67,6 @@ class CombinedOpenEndedV1Module(): ajax actions implemented by combined open ended module are: 'reset' -- resets the whole combined open ended module and returns to the first child moduleresource_string 'next_problem' -- moves to the next child module - 'get_results' -- gets results from a given child module Types of children. Task is synonymous with child module, so each combined open ended module incorporates multiple children (tasks): @@ -351,6 +350,9 @@ class CombinedOpenEndedV1Module(): self.update_task_states() return self.current_task.get_html(self.system) + def get_html_ajax(self, data): + return {'html' : self.get_html()} + def get_current_attributes(self, task_number): """ Gets the min and max score to attempt attributes of the specified task. @@ -592,53 +594,6 @@ class CombinedOpenEndedV1Module(): html = self.system.render_template('{0}/combined_open_ended_legend.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} - def get_results(self, _data): - """ - Gets the results of a given grader via ajax. - Input: AJAX data dictionary - 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): - all_responses.append(self.get_last_response(i)) - context_list = [] - for ri in all_responses: - for i in xrange(0, len(ri['rubric_scores'])): - feedback = ri['feedback_dicts'][i].get('feedback', '') - rubric_data = self.rubric_renderer.render_rubric(stringify_children(self.static_data['rubric']), - ri['rubric_scores'][i]) - if rubric_data['success']: - rubric_html = rubric_data['html'] - else: - rubric_html = '' - context = { - 'rubric_html': rubric_html, - 'grader_type': ri['grader_type'], - 'feedback': feedback, - 'grader_id': ri['grader_ids'][i], - 'submission_id': ri['submission_ids'][i], - } - context_list.append(context) - feedback_table = self.system.render_template('{0}/open_ended_result_table.html'.format(self.TEMPLATE_DIR), { - 'context_list': context_list, - 'grader_type_image_dict': GRADER_TYPE_IMAGE_DICT, - 'human_grader_types': HUMAN_GRADER_TYPE, - 'rows': 50, - 'cols': 50, - }) - context = { - 'results': feedback_table, - 'task_name': "Feedback", - 'class_name': "result-container", - } - html = self.system.render_template('{0}/combined_open_ended_results.html'.format(self.TEMPLATE_DIR), context) - return {'html': html, 'success': True} - def get_status_ajax(self, _data): """ Gets the results of a given grader via ajax. @@ -662,11 +617,12 @@ class CombinedOpenEndedV1Module(): handlers = { 'next_problem': self.next_problem, 'reset': self.reset, - 'get_results': self.get_results, '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, + 'get_current_state': self.get_current_state, + 'get_html': self.get_html_ajax, } if dispatch not in handlers: @@ -676,6 +632,9 @@ class CombinedOpenEndedV1Module(): d = handlers[dispatch](data) return json.dumps(d, cls=ComplexEncoder) + def get_current_state(self, data): + return self.get_context() + def get_last_response_ajax(self,data): return self.get_last_response(self.current_task_number) From b97dbc37a6d10e43bc48efe4694f202c4c648834 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 18:57:32 -0400 Subject: [PATCH 064/244] Fix test, strip out more of show results --- .../xmodule/xmodule/js/src/combinedopenended/display.coffee | 5 ----- common/lib/xmodule/xmodule/tests/test_combined_open_ended.py | 2 +- 2 files changed, 1 insertion(+), 6 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 8be83acd23..ec9ddb1329 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -93,7 +93,6 @@ class @CombinedOpenEnded coe_sel: 'section.combined-open-ended' reset_button_sel: '.reset-button' next_step_sel: '.next-step-button' - show_results_sel: '.show-results-button' question_header_sel: '.question-header' submit_evaluation_sel: '.submit-evaluation-button' result_container_sel: 'div.result-container' @@ -160,10 +159,6 @@ class @CombinedOpenEnded @next_problem_button = @$(@next_step_sel) @next_problem_button.click @next_problem - #setup show results - @show_results_button=@$(@show_results_sel) - @show_results_button.click @show_results - @question_header = @$(@question_header_sel) @question_header.click @collapse_question 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 05e8df0ad8..83f6dc6833 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -636,7 +636,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): self.assertTrue(isinstance(legend, basestring)) #Get all results - module.handle_ajax("get_results", {}) + module.handle_ajax("get_combined_rubric", {}) #reset the problem module.handle_ajax("reset", {}) From 8218a4a220bbdaae1eaddafdca08557166be11c6 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 19:09:09 -0400 Subject: [PATCH 065/244] Show the right rubric, be more explicit about "next step" --- .../js/src/combinedopenended/display.coffee | 2 + .../combined_open_ended_modulev1.py | 44 +++++++------------ 2 files changed, 18 insertions(+), 28 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index ec9ddb1329..2fe8979652 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -286,6 +286,8 @@ class @CombinedOpenEnded @answer_area.attr("disabled", true) @replace_text_inputs() @hint_area.attr('disabled', true) + if @task_number<@task_count + @gentle_alert "Your score did not meet the criteria to move to the next step." else if @child_state == 'initial' @answer_area.attr("disabled", false) @submit_button.prop('value', 'Submit') 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 7e95e27089..bdcbc7054c 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 @@ -556,25 +556,23 @@ class CombinedOpenEndedV1Module(): if not can_see_rubric: 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 = [] - for i in xrange(0, loop_up_to_task): - response = self.get_last_response(i) - score_length = len(response['grader_types']) - 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]]] - 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', - 'feedback' : feedback - }) + response = self.get_last_response(self.current_task_number + 1) + score_length = len(response['grader_types']) + 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]]] + 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', + 'feedback' : feedback + }) context = { 'results': contexts, @@ -594,15 +592,6 @@ class CombinedOpenEndedV1Module(): html = self.system.render_template('{0}/combined_open_ended_legend.html'.format(self.TEMPLATE_DIR), context) return {'html': html, 'success': True} - def get_status_ajax(self, _data): - """ - Gets the results of a given grader via ajax. - Input: AJAX data dictionary - Output: Dictionary to be rendered via ajax that contains the result html. - """ - html = self.get_status(True) - return {'html': html, 'success': True} - def handle_ajax(self, dispatch, data): """ This is called by courseware.module_render, to handle an AJAX call. @@ -618,7 +607,6 @@ class CombinedOpenEndedV1Module(): 'next_problem': self.next_problem, 'reset': self.reset, '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, 'get_current_state': self.get_current_state, From 7b9dde78fec860dfd26195aae5c6391e0d800406 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 15 Aug 2013 19:21:28 -0400 Subject: [PATCH 066/244] Fetch the correct rubric --- .../combined_open_ended_modulev1.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) 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 bdcbc7054c..23fa9c28c8 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 @@ -557,7 +557,10 @@ class CombinedOpenEndedV1Module(): return {'html' : self.system.render_template('{0}/combined_open_ended_hidden_results.html'.format(self.TEMPLATE_DIR), {'error' : error}), 'success' : True, 'hide_reset' : True} contexts = [] - response = self.get_last_response(self.current_task_number + 1) + rubric_number = self.current_task_number + if self.ready_to_reset: + rubric_number+=1 + response = self.get_last_response(rubric_number) score_length = len(response['grader_types']) for z in xrange(0,score_length): feedback = response['feedback_dicts'][z].get('feedback', '') From be3291eeb075e574de689c95814aa503b47b79f4 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 13:22:34 -0400 Subject: [PATCH 067/244] Proper scoping, show status to staff, fix line break issues, multiple self assessments on one page work --- .../js/src/combinedopenended/display.coffee | 15 +++++++++------ .../open_ended_module.py | 2 +- .../self_assessment_module.py | 1 + .../coffee/src/staff_grading/staff_grading.coffee | 11 ++++++++++- .../openended/open_ended_rubric.html | 6 ++++-- lms/templates/instructor/staff_grading.html | 4 ++-- 6 files changed, 27 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 2fe8979652..e846717030 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -23,7 +23,7 @@ class @Rubric keypress_callback: (event) => # don't try to do this when user is typing in a text input - if $(event.target).is('input, textarea') + if @$(event.target).is('input, textarea') return # for when we select via top row if event.which >= 48 and event.which <= 57 @@ -49,9 +49,9 @@ class @Rubric @category = @$(@categories[@category_index]) tracking_callback: (event) => - target_selection = $(event.target).val() + 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") + category = @$(event.target).data("category") location = @$(@rubric_sel).data('location') # probably want the original problem location as well @@ -187,9 +187,9 @@ class @CombinedOpenEnded @skip_button = @$(@oe).find(@skip_button_sel) @skip_button.click @skip_post_assessment - @file_upload_area = $(@oe).find(@file_upload_sel) + @file_upload_area = @$(@oe).find(@file_upload_sel) @can_upload_files = false - @open_ended_child= $(@oe).find(@open_ended_child_sel) + @open_ended_child= @$(@oe).find(@open_ended_child_sel) @out_of_sync_message = 'The problem state got out of sync. Try reloading the page.' @@ -257,7 +257,7 @@ class @CombinedOpenEnded contentType: false success: (response) => @gentle_alert response.msg - $('section.evaluation').slideToggle() + @$('section.evaluation').slideToggle() @message_wrapper.html(response.message_html) @@ -299,6 +299,7 @@ class @CombinedOpenEnded @hide_file_upload() @submit_button.prop('value', 'Submit assessment') @submit_button.click @save_assessment + @submit_button.attr("disabled",false) if @child_type == "openended" @submit_button.hide() @queueing() @@ -339,6 +340,7 @@ class @CombinedOpenEnded @hint_area = @$('textarea.post_assessment') save_answer: (event) => + @submit_button.attr("disabled",true) event.preventDefault() @submit_button.hide() @answer_area.attr("disabled", true) @@ -640,4 +642,5 @@ class @CombinedOpenEnded graded_callback: () => if @rub.check_complete() + @submit_button.attr("disabled",false) @submit_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 04eff33159..dc2c3fb01b 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 @@ -699,7 +699,7 @@ class OpenEndedModule(openendedchild.OpenEndedChild): post_assessment = "" correct = "" previous_answer = "" - + previous_answer = previous_answer.replace("\n","
    ") context = { 'prompt': self.child_prompt, 'previous_answer': previous_answer, 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 674fab0d30..5c40aca116 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 @@ -61,6 +61,7 @@ class SelfAssessmentModule(openendedchild.OpenEndedChild): else: previous_answer = '' + previous_answer = previous_answer.replace("\n","
    ") context = { 'prompt': self.child_prompt, 'previous_answer': previous_answer, diff --git a/lms/static/coffee/src/staff_grading/staff_grading.coffee b/lms/static/coffee/src/staff_grading/staff_grading.coffee index e7e7c189ac..e8cee60001 100644 --- a/lms/static/coffee/src/staff_grading/staff_grading.coffee +++ b/lms/static/coffee/src/staff_grading/staff_grading.coffee @@ -146,6 +146,8 @@ The standard chunk of Lorem Ipsum used since the 1500s is reproduced below for t class @StaffGrading + grading_message_sel: '.grading-message' + constructor: (backend) -> AjaxPrefix.addAjaxPrefix(jQuery, -> "") @backend = backend @@ -278,6 +280,7 @@ class @StaffGrading location: @location skipped: true submission_flagged: false + @gentle_alert "Skipped the submission." @backend.post('save_grade', data, @ajax_callback) get_problem_list: () -> @@ -292,9 +295,15 @@ class @StaffGrading submission_id: @submission_id location: @location submission_flagged: @flag_submission_checkbox.is(':checked') - + @gentle_alert "Grades saved. Fetching the next submission to grade." @backend.post('save_grade', data, @ajax_callback) + gentle_alert: (msg) => + @grading_message = $(@grading_message_sel) + @grading_message.html("") + @grading_message.fadeIn() + @grading_message.html("

    " + msg + "

    ") + error: (msg) -> @error_msg = msg @state = state_error diff --git a/lms/templates/combinedopenended/openended/open_ended_rubric.html b/lms/templates/combinedopenended/openended/open_ended_rubric.html index ea374c5173..55194a9318 100644 --- a/lms/templates/combinedopenended/openended/open_ended_rubric.html +++ b/lms/templates/combinedopenended/openended/open_ended_rubric.html @@ -1,4 +1,5 @@ <%! from django.utils.translation import ugettext as _ %> +<% from random import randint %>
    @@ -9,6 +10,7 @@
    % for i in range(len(categories)): <% category = categories[i] %> + <% m = randint(0,1000) %> ${category['description']}
      % for j in range(len(category['options'])): @@ -18,8 +20,8 @@ %else:
    • % endif -
    • diff --git a/lms/templates/instructor/staff_grading.html b/lms/templates/instructor/staff_grading.html index 8595b0d722..bb60bddeb0 100644 --- a/lms/templates/instructor/staff_grading.html +++ b/lms/templates/instructor/staff_grading.html @@ -80,8 +80,8 @@
    - -
    +
    +
    \ No newline at end of file From 4f3ec68fb744565af929da3eae5f9dc2c5262bf7 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 14:12:37 -0400 Subject: [PATCH 068/244] 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 069/244] 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 8248c331386708100c94247387729fb0a45a3f74 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 14:48:11 -0400 Subject: [PATCH 070/244] 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 071/244] 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 59336ee03caab6d398cd72eebc1666d466510ed1 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 15:24:38 -0400 Subject: [PATCH 072/244] 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 f17987c13ae7b2745376649baf304579af19dc3e Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 16 Aug 2013 17:37:53 -0400 Subject: [PATCH 073/244] 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 06977c198ebf3cbd3cc8d24cbc243ace635c5d01 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 19 Aug 2013 08:47:53 -0400 Subject: [PATCH 074/244] Created new test configuration for MixedModuleStore --- .../xmodule/modulestore/tests/django_utils.py | 51 ++++++++++++++++--- 1 file changed, 44 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 4f998d57fb..8b8d61c85a 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -1,4 +1,3 @@ - import copy from uuid import uuid4 from django.test import TestCase @@ -8,6 +7,41 @@ import xmodule.modulestore.django from unittest.util import safe_repr +def mixed_store_config(data_dir, mappings): + """ + Return a `MixedModuleStore` configuration, which provides + access to both Mongo- and XML-backed courses. + + `data_dir` is the directory from which to load XML-backed courses. + `mappings` is a dictionary mapping course IDs to modulestores, for example: + + { + 'MITx/2.01x/2013_Spring': 'xml', + 'edx/999/2013_Spring': 'default' + } + + where 'xml' and 'default' are the two options provided by this configuration, + mapping (respectively) to XML-backed and Mongo-backed modulestores.. + """ + mongo_config = mongo_store_config(data_dir) + xml_config = xml_store_config(data_dir) + + store = { + 'default': { + 'ENGINE': 'xmodule.modulestore.mixed.MixedModuleStore', + 'OPTIONS': { + 'mappings': mappings, + 'stores': { + 'default': mongo_config['default'], + 'xml': xml_config['default'] + } + } + } + } + store['direct'] = store['default'] + return store + + def mongo_store_config(data_dir): """ Defines default module store using MongoModuleStore. @@ -27,6 +61,7 @@ def mongo_store_config(data_dir): } } } + store['direct'] = store['default'] return store @@ -45,23 +80,22 @@ def draft_mongo_store_config(data_dir): 'render_template': 'mitxmako.shortcuts.render_to_string' } - return { + store = { 'default': { 'ENGINE': 'xmodule.modulestore.mongo.draft.DraftModuleStore', 'OPTIONS': modulestore_options - }, - 'direct': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': modulestore_options } } + store['direct'] = store['default'] + return store + def xml_store_config(data_dir): """ Defines default module store using XMLModuleStore. """ - return { + store = { 'default': { 'ENGINE': 'xmodule.modulestore.xml.XMLModuleStore', 'OPTIONS': { @@ -71,6 +105,9 @@ def xml_store_config(data_dir): } } + store['direct'] = store['default'] + return store + class ModuleStoreTestCase(TestCase): """ Subclass for any test case that uses the mongodb From 05b34098abfce0c3c1e0d9d2bbacd7ca623a148e Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 19 Aug 2013 09:27:36 -0400 Subject: [PATCH 075/244] Refactored ModuleStoreTestCase to use modulestore interface for clearing _MODULESTORES --- .../lib/xmodule/xmodule/modulestore/django.py | 9 +++++++++ .../xmodule/modulestore/tests/django_utils.py | 18 +++++++++--------- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index 2f0cd126f9..cd0166e4b0 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -53,3 +53,12 @@ def modulestore(name='default'): settings.MODULESTORE[name]['OPTIONS']) return _MODULESTORES[name] + +def clear_existing_modulestores(): + """ + Clear the existing modulestore instances, causing + them to be re-created when accessed again. + + This is useful for flushing state between unit tests. + """ + _MODULESTORES.clear() diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 8b8d61c85a..a38cab79f6 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -3,7 +3,7 @@ from uuid import uuid4 from django.test import TestCase from django.conf import settings -import xmodule.modulestore.django +from xmodule.modulestore.django import modulestore, clear_existing_modulestores from unittest.util import safe_repr @@ -126,7 +126,7 @@ class ModuleStoreTestCase(TestCase): 'data' is a dictionary with an entry for each CourseField we want to update. """ - store = xmodule.modulestore.django.modulestore() + store = modulestore() store.update_metadata(course.location, data) updated_course = store.get_instance(course.id, course.location) return updated_course @@ -136,15 +136,15 @@ class ModuleStoreTestCase(TestCase): """ Delete everything in the module store except templates. """ - modulestore = xmodule.modulestore.django.modulestore() + store = modulestore() # This query means: every item in the collection # that is not a template query = {"_id.course": {"$ne": "templates"}} # Remove everything except templates - modulestore.collection.remove(query) - modulestore.collection.drop() + store.collection.remove(query) + store.collection.drop() @classmethod def setUpClass(cls): @@ -160,7 +160,7 @@ class ModuleStoreTestCase(TestCase): settings.MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex settings.MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex - xmodule.modulestore.django._MODULESTORES.clear() + clear_existing_modulestores() print settings.MODULESTORE @@ -173,10 +173,10 @@ class ModuleStoreTestCase(TestCase): """ # Clean up by dropping the collection - modulestore = xmodule.modulestore.django.modulestore() - modulestore.collection.drop() + store = modulestore() + store.collection.drop() - xmodule.modulestore.django._MODULESTORES.clear() + clear_existing_modulestores() # Restore the original modulestore settings settings.MODULESTORE = cls.orig_modulestore From bd65cfa813266a31b18c670c06c10f4928d85bd8 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 19 Aug 2013 09:35:30 -0400 Subject: [PATCH 076/244] Removed outdated template logic in ModuleStoreTestCase --- .../xmodule/modulestore/tests/django_utils.py | 38 ++++++++----------- 1 file changed, 16 insertions(+), 22 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index a38cab79f6..8e4655cf3f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -110,10 +110,11 @@ def xml_store_config(data_dir): class ModuleStoreTestCase(TestCase): - """ Subclass for any test case that uses the mongodb - module store. This populates a uniquely named modulestore - collection with templates before running the TestCase - and drops it they are finished. """ + """ + Subclass for any test case that uses a ModuleStore. + + Ensures that the ModuleStore is cleaned before/after each test. + """ @staticmethod def update_course(course, data): @@ -132,24 +133,19 @@ class ModuleStoreTestCase(TestCase): return updated_course @staticmethod - def flush_mongo_except_templates(): + def drop_mongo_collection(): """ - Delete everything in the module store except templates. + If using a Mongo-backed modulestore, drop the collection. """ store = modulestore() - # This query means: every item in the collection - # that is not a template - query = {"_id.course": {"$ne": "templates"}} - - # Remove everything except templates - store.collection.remove(query) - store.collection.drop() + if hasattr(store, 'collection'): + store.collection.drop() @classmethod def setUpClass(cls): """ - Flush the mongo store and set up templates. + Flush the ModuleStore. """ # Use a uuid to differentiate @@ -173,8 +169,7 @@ class ModuleStoreTestCase(TestCase): """ # Clean up by dropping the collection - store = modulestore() - store.collection.drop() + cls.drop_mongo_collection() clear_existing_modulestores() @@ -183,21 +178,20 @@ class ModuleStoreTestCase(TestCase): def _pre_setup(self): """ - Remove everything but the templates before each test. + Flush the ModuleStore before each test. """ - # Flush anything that is not a template - ModuleStoreTestCase.flush_mongo_except_templates() + # Flush the Mongo modulestore + ModuleStoreTestCase.drop_mongo_collection() # Call superclass implementation super(ModuleStoreTestCase, self)._pre_setup() def _post_teardown(self): """ - Flush everything we created except the templates. + Flush the ModuleStore after each test. """ - # Flush anything that is not a template - ModuleStoreTestCase.flush_mongo_except_templates() + ModuleStoreTestCase.drop_mongo_collection() # Call superclass implementation super(ModuleStoreTestCase, self)._post_teardown() From d782278d5caba66b4903ab5529ec1567d997abcc Mon Sep 17 00:00:00 2001 From: marco Date: Tue, 20 Aug 2013 01:33:06 -0400 Subject: [PATCH 077/244] minor cleanup of pull request comments within scss files, along with removal of some whitespace in text fixture file for rubrics --- common/lib/xmodule/xmodule/css/capa/display.scss | 2 +- .../xmodule/css/combinedopenended/display.scss | 11 ++++++----- common/lib/xmodule/xmodule/js/fixtures/rubric.html | 4 ---- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/capa/display.scss b/common/lib/xmodule/xmodule/css/capa/display.scss index 2fa3c315ef..fcc067c51d 100644 --- a/common/lib/xmodule/xmodule/css/capa/display.scss +++ b/common/lib/xmodule/xmodule/css/capa/display.scss @@ -914,7 +914,7 @@ section.problem { .tag { display: inline-block; - margin-left: $baseline; + margin-left: $baseline*2; border: 1px solid rgb(102,102,102); cursor: pointer; diff --git a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss index 551d1c181f..7ca99be5c4 100644 --- a/common/lib/xmodule/xmodule/css/combinedopenended/display.scss +++ b/common/lib/xmodule/xmodule/css/combinedopenended/display.scss @@ -129,7 +129,7 @@ section.combined-open-ended-status { } &:last-child { - border-right: 0px; + border-right: 0; border-radius: 0 $baseline/4 $baseline/4 0; } @@ -235,7 +235,8 @@ section.combined-open-ended-status { background-color: #eee; } .rubric-label-selected{ - border: 2px solid #666; + border-radius: $baseline/4; + background-color: #eee; } } @@ -365,7 +366,7 @@ div.result-container { font-family: monospace; font-size: 1em; padding-top: $baseline/2; - padding-bottom:30px; + padding-bottom: 30px; header { font-size: 1.4em; @@ -433,8 +434,8 @@ div.result-container { .rubric-result-container { padding: 2px; - margin: 0px; - display : inline; + margin: 0; + display: inline; .rubric-result { font-size: .9em; diff --git a/common/lib/xmodule/xmodule/js/fixtures/rubric.html b/common/lib/xmodule/xmodule/js/fixtures/rubric.html index bdb572d11b..76ad59b8ff 100644 --- a/common/lib/xmodule/xmodule/js/fixtures/rubric.html +++ b/common/lib/xmodule/xmodule/js/fixtures/rubric.html @@ -19,8 +19,6 @@
    - -
    @@ -30,8 +28,6 @@
    - -
    From 48c6daacb89ec7a3480b6070447f93043b590aa7 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 19 Aug 2013 09:45:18 -0400 Subject: [PATCH 078/244] Removed unnecessary settings wrangling from ModuleStoreTestCase. Modified navigation tests to use MixedModulestore Updated factories to find editable modulestore Updated test_submitting_problems Updated test_tabs.py Updated test_view_authentication Updated test_views Updated courseware/tests/tests.py Updated test_masquerade Updated test_module_render Pylint fixes Updated video and word cloud tests Updated course wiki tests Updated license and open_ended tests. One open_ended test still failing due to Mako initialization issues Updated staticbook Updated django_comment_client tests Updated instructor tests Updated instructor task tests Updated external_auth tests Updated course_groups --- .../djangoapps/course_groups/tests/tests.py | 13 +- .../external_auth/tests/test_shib.py | 8 +- .../lib/xmodule/xmodule/modulestore/django.py | 31 ++++ .../xmodule/modulestore/tests/django_utils.py | 65 +++++---- .../xmodule/modulestore/tests/factories.py | 10 +- lms/djangoapps/course_wiki/tests/tests.py | 15 +- lms/djangoapps/courseware/tests/__init__.py | 4 +- .../courseware/tests/modulestore_config.py | 16 ++- .../courseware/tests/test_masquerade.py | 22 ++- .../courseware/tests/test_module_render.py | 21 ++- .../courseware/tests/test_navigation.py | 4 +- .../tests/test_submitting_problems.py | 9 +- lms/djangoapps/courseware/tests/test_tabs.py | 4 +- .../tests/test_view_authentication.py | 4 +- lms/djangoapps/courseware/tests/test_views.py | 24 ++-- lms/djangoapps/courseware/tests/tests.py | 132 +++++++++--------- .../django_comment_client/base/tests.py | 4 +- .../django_comment_client/forum/tests.py | 4 +- .../instructor/tests/test_access.py | 10 +- lms/djangoapps/instructor/tests/test_api.py | 16 +-- .../instructor/tests/test_hint_manager.py | 4 +- .../tests/test_legacy_download_csv.py | 5 +- .../tests/test_legacy_enrollment.py | 4 +- .../tests/test_legacy_forum_admin.py | 4 +- .../instructor/tests/test_legacy_gradebook.py | 4 +- .../instructor/tests/test_legacy_xss.py | 4 +- .../instructor_task/tests/test_base.py | 8 +- lms/djangoapps/licenses/tests.py | 4 +- lms/djangoapps/open_ended_grading/tests.py | 19 ++- lms/djangoapps/staticbook/tests.py | 4 +- 30 files changed, 255 insertions(+), 221 deletions(-) diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index 2e519edb30..debdc68c26 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -8,19 +8,20 @@ from course_groups.models import CourseUserGroup from course_groups.cohorts import (get_cohort, get_course_cohorts, is_commentable_cohorted, get_cohort_by_name) -from xmodule.modulestore.django import modulestore, _MODULESTORES +from xmodule.modulestore.django import modulestore, clear_existing_modulestores -from xmodule.modulestore.tests.django_utils import xml_store_config +from xmodule.modulestore.tests.django_utils import mixed_store_config # NOTE: running this with the lms.envs.test config works without # manually overriding the modulestore. However, running with # cms.envs.test doesn't. TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) +TEST_MAPPING = { 'edX/toy/2012_Fall': 'xml' } +TEST_DATA_MIXED_MODULESTORE = mixed_store_config(TEST_DATA_DIR, TEST_MAPPING) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestCohorts(django.test.TestCase): @staticmethod @@ -82,9 +83,7 @@ class TestCohorts(django.test.TestCase): """ Make sure that course is reloaded every time--clear out the modulestore. """ - # don't like this, but don't know a better way to undo all changes made - # to course. We don't have a course.clone() method. - _MODULESTORES.clear() + clear_existing_modulestores() def test_get_cohort(self): """ diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py index 6bb9c38e6f..187acdb595 100644 --- a/common/djangoapps/external_auth/tests/test_shib.py +++ b/common/djangoapps/external_auth/tests/test_shib.py @@ -16,9 +16,9 @@ from django.utils.importlib import import_module from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.inheritance import own_metadata -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.django import editable_modulestore -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE from external_auth.models import ExternalAuthMap from external_auth.views import shib_login, course_specific_login, course_specific_register @@ -64,7 +64,7 @@ def gen_all_identities(): yield _build_identity_dict(mail, given_name, surname) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, SESSION_ENGINE='django.contrib.sessions.backends.cache') +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE, SESSION_ENGINE='django.contrib.sessions.backends.cache') class ShibSPTest(ModuleStoreTestCase): """ Tests for the Shibboleth SP, which communicates via request.META @@ -73,7 +73,7 @@ class ShibSPTest(ModuleStoreTestCase): request_factory = RequestFactory() def setUp(self): - self.store = modulestore() + self.store = editable_modulestore() @unittest.skipUnless(settings.MITX_FEATURES.get('AUTH_USE_SHIB'), True) def test_exception_shib_login(self): diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index cd0166e4b0..f1235668dc 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -54,6 +54,7 @@ def modulestore(name='default'): return _MODULESTORES[name] + def clear_existing_modulestores(): """ Clear the existing modulestore instances, causing @@ -62,3 +63,33 @@ def clear_existing_modulestores(): This is useful for flushing state between unit tests. """ _MODULESTORES.clear() + + +def editable_modulestore(name='default'): + """ + Retrieve a modulestore that we can modify. + This is useful for tests that need to insert test + data into the modulestore. + + Currently, only Mongo-backed modulestores can be modified. + Returns `None` if no editable modulestore is available. + """ + + # Try to retrieve the ModuleStore + # Depending on the settings, this may or may not + # be editable. + store = modulestore(name) + + # If this is a `MixedModuleStore`, then we will need + # to retrieve the actual Mongo instance. + # We assume that the default is Mongo. + if hasattr(store, 'modulestores'): + store = store.modulestores['default'] + + # At this point, we either have the ability to create + # items in the store, or we do not. + if hasattr(store, 'create_xmodule'): + return store + + else: + return None diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 8e4655cf3f..87156ec0dd 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -1,9 +1,11 @@ -import copy +""" +eoduleStore configuration for test cases. +""" + from uuid import uuid4 from django.test import TestCase - -from django.conf import settings -from xmodule.modulestore.django import modulestore, clear_existing_modulestores +from xmodule.modulestore.django import editable_modulestore, \ + editable_modulestore, clear_existing_modulestores from unittest.util import safe_repr @@ -112,8 +114,24 @@ def xml_store_config(data_dir): class ModuleStoreTestCase(TestCase): """ Subclass for any test case that uses a ModuleStore. - Ensures that the ModuleStore is cleaned before/after each test. + + Usage: + + 1. Create a subclass of `ModuleStoreTestCase` + 2. Use Django's @override_settings decorator to use + the desired modulestore configuration. + + For example: + + MIXED_CONFIG = mixed_store_config(data_dir, mappings) + + @override_settings(MODULESTORE=MIXED_CONFIG) + class FooTest(ModuleStoreTestCase): + # ... + + 3. Use factories (e.g. `CourseFactory`, `ItemFactory`) to populate + the modulestore with test data. """ @staticmethod @@ -127,7 +145,7 @@ class ModuleStoreTestCase(TestCase): 'data' is a dictionary with an entry for each CourseField we want to update. """ - store = modulestore() + store = editable_modulestore('direct') store.update_metadata(course.location, data) updated_course = store.get_instance(course.id, course.location) return updated_course @@ -137,7 +155,10 @@ class ModuleStoreTestCase(TestCase): """ If using a Mongo-backed modulestore, drop the collection. """ - store = modulestore() + + # This will return the mongo-backed modulestore + # even if we're using a mixed modulestore + store = editable_modulestore() if hasattr(store, 'collection'): store.collection.drop() @@ -145,36 +166,30 @@ class ModuleStoreTestCase(TestCase): @classmethod def setUpClass(cls): """ - Flush the ModuleStore. + Delete the existing modulestores, causing them to be reloaded. """ - - # Use a uuid to differentiate - # the mongo collections on jenkins. - cls.orig_modulestore = copy.deepcopy(settings.MODULESTORE) - if 'direct' not in settings.MODULESTORE: - settings.MODULESTORE['direct'] = settings.MODULESTORE['default'] - - settings.MODULESTORE['default']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex - settings.MODULESTORE['direct']['OPTIONS']['collection'] = 'modulestore_%s' % uuid4().hex + # Clear out any existing modulestores, + # which will cause them to be re-created + # the next time they are accessed. clear_existing_modulestores() - - print settings.MODULESTORE - TestCase.setUpClass() @classmethod def tearDownClass(cls): """ - Revert to the old modulestore settings. + Drop the existing modulestores, causing them to be reloaded. + Clean up any data stored in Mongo. """ - - # Clean up by dropping the collection + # Clean up by flushing the Mongo modulestore cls.drop_mongo_collection() + # Clear out the existing modulestores, + # which will cause them to be re-created + # the next time they are accessed. + # We do this at *both* setup and teardown just to be safe. clear_existing_modulestores() - # Restore the original modulestore settings - settings.MODULESTORE = cls.orig_modulestore + TestCase.tearDownClass() def _pre_setup(self): """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/factories.py b/common/lib/xmodule/xmodule/modulestore/tests/factories.py index f2e4017114..7913434086 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/factories.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/factories.py @@ -5,11 +5,12 @@ from uuid import uuid4 from pytz import UTC from xmodule.modulestore import Location -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.django import editable_modulestore from xmodule.course_module import CourseDescriptor from xblock.core import Scope from xmodule.x_module import XModuleDescriptor + class XModuleCourseFactory(Factory): """ Factory for XModule courses. @@ -25,10 +26,7 @@ class XModuleCourseFactory(Factory): display_name = kwargs.pop('display_name', None) location = Location('i4x', org, number, 'course', Location.clean(display_name)) - try: - store = modulestore('direct') - except KeyError: - store = modulestore() + store = editable_modulestore('direct') # Write the data to the mongo datastore new_course = store.create_xmodule(location) @@ -117,7 +115,7 @@ class XModuleItemFactory(Factory): if not isinstance(data, basestring): data.update(template.get('data')) - store = modulestore('direct') + store = editable_modulestore('direct') # This code was based off that in cms/djangoapps/contentstore/views.py parent = store.get_item(parent_location) diff --git a/lms/djangoapps/course_wiki/tests/tests.py b/lms/djangoapps/course_wiki/tests/tests.py index 6bbd8011d6..93954dab61 100644 --- a/lms/djangoapps/course_wiki/tests/tests.py +++ b/lms/djangoapps/course_wiki/tests/tests.py @@ -3,21 +3,18 @@ from django.test.utils import override_settings import xmodule.modulestore.django -from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_XML_MODULESTORE +from courseware.tests.tests import LoginEnrollmentTestCase +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.django import modulestore -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class WikiRedirectTestCase(LoginEnrollmentTestCase): + def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - courses = modulestore().get_courses() - def find_course(name): - """Assumes the course is present""" - return [c for c in courses if c.location.course == name][0] - - self.toy = find_course("toy") + # Load the toy course + self.toy = modulestore().get_course('edX/toy/2012_Fall') # Create two accounts self.student = 'view@test.com' diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 9d1b549b9f..4b93e804bf 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -12,7 +12,7 @@ from django.core.urlresolvers import reverse from django.test.client import Client from student.tests.factories import UserFactory, CourseEnrollmentFactory -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xmodule.tests import get_test_system from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -20,7 +20,7 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class BaseTestXmodule(ModuleStoreTestCase): """Base class for testing Xmodules with mongo store. diff --git a/lms/djangoapps/courseware/tests/modulestore_config.py b/lms/djangoapps/courseware/tests/modulestore_config.py index 80a7b0a7c1..e4ee86878f 100644 --- a/lms/djangoapps/courseware/tests/modulestore_config.py +++ b/lms/djangoapps/courseware/tests/modulestore_config.py @@ -1,4 +1,6 @@ -from xmodule.modulestore.tests.django_utils import xml_store_config, mongo_store_config, draft_mongo_store_config +from xmodule.modulestore.tests.django_utils import xml_store_config, \ + mongo_store_config, draft_mongo_store_config,\ + mixed_store_config from django.conf import settings @@ -6,3 +8,15 @@ TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT TEST_DATA_XML_MODULESTORE = xml_store_config(TEST_DATA_DIR) TEST_DATA_MONGO_MODULESTORE = mongo_store_config(TEST_DATA_DIR) TEST_DATA_DRAFT_MONGO_MODULESTORE = draft_mongo_store_config(TEST_DATA_DIR) + +# Map all XML course fixtures so they are accessible through +# the MixedModuleStore +MAPPINGS = { + 'edX/toy/2012_Fall': 'xml', + 'edX/toy/TT_2012_Fall': 'xml', + 'edX/test_end/2012_Fall': 'xml', + 'edX/test_about_blob_end_date/2012_Fall': 'xml', + 'edX/graded/2012_Fall': 'xml', + 'edX/open_ended/2012_Fall': 'xml', +} +TEST_DATA_MIXED_MODULESTORE = mixed_store_config(TEST_DATA_DIR, MAPPINGS) diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index 0fc4eae242..3122dc6477 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -15,23 +15,18 @@ from django.core.urlresolvers import reverse from django.contrib.auth.models import Group, User from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase -from modulestore_config import TEST_DATA_XML_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.django import modulestore -import xmodule.modulestore.django import json -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase): - ''' - Check for staff being able to masquerade as student - ''' + """ + Check for staff being able to masquerade as student. + """ def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - - #self.full = modulestore().get_course("edX/full/6.002_Spring_2012") - #self.toy = modulestore().get_course("edX/toy/2012_Fall") self.graded_course = modulestore().get_course("edX/graded/2012_Fall") # Create staff account @@ -50,7 +45,6 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase): self.logout() self.login(self.instructor, self.password) self.enroll(self.graded_course) - # self.factory = RequestFactory() def get_cw_section(self): url = reverse('courseware_section', @@ -70,9 +64,9 @@ class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase): self.assertTrue(sdebug in resp.content) def toggle_masquerade(self): - ''' - Toggle masquerade state - ''' + """ + Toggle masquerade state. + """ masq_url = reverse('masquerade-switch', kwargs={'marg': 'toggle'}) print "masq_url ", masq_url resp = self.client.get(masq_url) diff --git a/lms/djangoapps/courseware/tests/test_module_render.py b/lms/djangoapps/courseware/tests/test_module_render.py index 25056ba100..9c11dfc617 100644 --- a/lms/djangoapps/courseware/tests/test_module_render.py +++ b/lms/djangoapps/courseware/tests/test_module_render.py @@ -15,22 +15,17 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.tests.factories import ItemFactory, CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase import courseware.module_render as render -from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_MONGO_MODULESTORE +from courseware.tests.tests import LoginEnrollmentTestCase from courseware.model_data import ModelDataCache -from modulestore_config import TEST_DATA_XML_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from courseware.courses import get_course_with_access from .factories import UserFactory -class Stub: - def __init__(self): - pass - - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class ModuleRenderTestCase(LoginEnrollmentTestCase): +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class ModuleRenderTestCase(ModuleStoreTestCase, LoginEnrollmentTestCase): def setUp(self): self.location = ['i4x', 'edX', 'toy', 'chapter', 'Overview'] self.course_id = 'edX/toy/2012_Fall' @@ -96,7 +91,7 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase): settings.MAX_FILEUPLOADS_PER_INPUT})) mock_request_2 = MagicMock() mock_request_2.FILES.keys.return_value = ['file_id'] - inputfile = Stub() + inputfile = MagicMock() inputfile.size = 1 + settings.STUDENT_FILEUPLOAD_MAX_SIZE inputfile.name = 'name' filelist = [inputfile] @@ -109,7 +104,7 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase): mock_request_3.POST.copy.return_value = {'position': 1} mock_request_3.FILES = False mock_request_3.user = self.mock_user - inputfile_2 = Stub() + inputfile_2 = MagicMock() inputfile_2.size = 1 inputfile_2.name = 'name' self.assertIsInstance(render.modx_dispatch(mock_request_3, 'goto_position', @@ -200,7 +195,7 @@ class ModuleRenderTestCase(LoginEnrollmentTestCase): self.assertEquals(403, response.status_code) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestTOC(TestCase): """Check the Table of Contents for a course""" def setUp(self): @@ -266,7 +261,7 @@ class TestTOC(TestCase): self.assertIn(toc_section, actual) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestHtmlModifiers(ModuleStoreTestCase): """ Tests to verify that standard modifications to the output of XModule/XBlock diff --git a/lms/djangoapps/courseware/tests/test_navigation.py b/lms/djangoapps/courseware/tests/test_navigation.py index dd1f00711c..2b416b16de 100644 --- a/lms/djangoapps/courseware/tests/test_navigation.py +++ b/lms/djangoapps/courseware/tests/test_navigation.py @@ -6,10 +6,10 @@ from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from helpers import LoginEnrollmentTestCase, check_for_get_code -from modulestore_config import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestNavigation(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Check that navigation state is saved properly. diff --git a/lms/djangoapps/courseware/tests/test_submitting_problems.py b/lms/djangoapps/courseware/tests/test_submitting_problems.py index 9081a910c9..f8cfaefd75 100644 --- a/lms/djangoapps/courseware/tests/test_submitting_problems.py +++ b/lms/djangoapps/courseware/tests/test_submitting_problems.py @@ -13,17 +13,17 @@ from django.test.utils import override_settings from courseware import grades from courseware.model_data import ModelDataCache -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.django import modulestore, editable_modulestore #import factories and parent testcase modules from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from capa.tests.response_xml_factory import OptionResponseXMLFactory, CustomResponseXMLFactory, SchematicResponseXMLFactory from courseware.tests.helpers import LoginEnrollmentTestCase -from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestSubmittingProblems(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Check that a course gets graded properly. @@ -217,7 +217,8 @@ class TestCourseGrader(TestSubmittingProblems): """ course_data = {'grading_policy': grading_policy} - modulestore().update_item(self.course.location, course_data) + store = editable_modulestore('direct') + store.update_item(self.course.location, course_data) self.refresh_course() def get_grade_summary(self): diff --git a/lms/djangoapps/courseware/tests/test_tabs.py b/lms/djangoapps/courseware/tests/test_tabs.py index 4435b5c951..5de7a39f63 100644 --- a/lms/djangoapps/courseware/tests/test_tabs.py +++ b/lms/djangoapps/courseware/tests/test_tabs.py @@ -7,9 +7,9 @@ import courseware.tabs as tabs from django.test.utils import override_settings from django.core.urlresolvers import reverse -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE class ProgressTestCase(TestCase): @@ -261,7 +261,7 @@ class ValidateTabsTestCase(TestCase): self.assertRaises(tabs.InvalidTabsException, tabs.validate_tabs, self.courses[4]) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class DiscussionLinkTestCase(ModuleStoreTestCase): def setUp(self): diff --git a/lms/djangoapps/courseware/tests/test_view_authentication.py b/lms/djangoapps/courseware/tests/test_view_authentication.py index 055c860fcc..849e5fdc45 100644 --- a/lms/djangoapps/courseware/tests/test_view_authentication.py +++ b/lms/djangoapps/courseware/tests/test_view_authentication.py @@ -16,10 +16,10 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from helpers import LoginEnrollmentTestCase, check_for_get_code -from modulestore_config import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestViewAuth(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Check that view authentication works properly. diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index 6f665f7345..0c23b31f53 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -17,22 +17,20 @@ from xmodule.modulestore.django import modulestore import courseware.views as views from xmodule.modulestore import Location from pytz import UTC -from modulestore_config import TEST_DATA_XML_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -class Stub(): - pass - - -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestJumpTo(TestCase): - """Check the jumpto link for a course""" - def setUp(self): - self._MODULESTORES = {} + """ + Check the jumpto link for a course. + """ - # Toy courses should be loaded + def setUp(self): + + # Load toy course from XML self.course_name = 'edX/toy/2012_Fall' - self.toy_course = modulestore().get_course('edX/toy/2012_Fall') + self.toy_course = modulestore().get_course(self.course_name) def test_jumpto_invalid_location(self): location = Location('i4x', 'edX', 'toy', 'NoSuchPlace', None) @@ -71,7 +69,7 @@ class ViewsTestCase(TestCase): self.enrollment.created = self.date self.enrollment.save() self.location = ['tag', 'org', 'course', 'category', 'name'] - self._MODULESTORES = {} + # This is a CourseDescriptor object self.toy_course = modulestore().get_course('edX/toy/2012_Fall') self.request_factory = RequestFactory() @@ -85,7 +83,7 @@ class ViewsTestCase(TestCase): self.assertEquals(views.user_groups(mock_user), []) def test_get_current_child(self): - self.assertIsNone(views.get_current_child(Stub())) + self.assertIsNone(views.get_current_child(MagicMock())) mock_xmodule = MagicMock() mock_xmodule.position = -1 mock_xmodule.get_display_items.return_value = ['one', 'two'] diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index cd245d2610..995c7a352c 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,25 +1,21 @@ -''' -Test for lms courseware app -''' -import random - +""" +Test for LMS courseware app. +""" from django.test import TestCase from django.core.urlresolvers import reverse from django.test.utils import override_settings -import xmodule.modulestore.django from xmodule.error_module import ErrorDescriptor from xmodule.modulestore.django import modulestore from xmodule.modulestore import Location from xmodule.modulestore.xml_importer import import_from_xml -from xmodule.modulestore.xml import XMLModuleStore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from helpers import LoginEnrollmentTestCase -from modulestore_config import TEST_DATA_DIR, \ - TEST_DATA_XML_MODULESTORE, \ +from courseware.tests.helpers import LoginEnrollmentTestCase +from courseware.tests.modulestore_config import TEST_DATA_DIR, \ TEST_DATA_MONGO_MODULESTORE, \ - TEST_DATA_DRAFT_MONGO_MODULESTORE -import xmodule + TEST_DATA_DRAFT_MONGO_MODULESTORE, \ + TEST_DATA_MIXED_MODULESTORE class ActivateLoginTest(LoginEnrollmentTestCase): @@ -47,57 +43,60 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): Base class that adds a function to load all pages in a modulestore. """ - def check_random_page_loads(self, module_store): + def check_all_pages_load(self, course_id): """ - Choose a page in the course randomly, and assert that it loads. + Assert that all pages in the course load correctly. + `course_id` is the ID of the course to check. """ - # enroll in the course before trying to access pages - courses = module_store.get_courses() - self.assertEqual(len(courses), 1) - course = courses[0] + + store = modulestore() + + # Enroll in the course before trying to access pages + course = store.get_course(course_id) self.enroll(course, True) - course_id = course.id # Search for items in the course # None is treated as a wildcard course_loc = course.location - location_query = Location(course_loc.tag, course_loc.org, - course_loc.course, None, None, None) + location_query = Location( + course_loc.tag, course_loc.org, + course_loc.course, None, None, None + ) - items = module_store.get_items(location_query) + items = store.get_items( + location_query, + course_id=course_id, + depth=2 + ) if len(items) < 1: self.fail('Could not retrieve any items from course') - else: - descriptor = random.choice(items) - # We have ancillary course information now as modules - # and we can't simply use 'jump_to' to view them - if descriptor.location.category == 'about': - self._assert_loads('about_course', - {'course_id': course_id}, - descriptor) + # Try to load each item in the course + for descriptor in items: - elif descriptor.location.category == 'static_tab': - kwargs = {'course_id': course_id, - 'tab_slug': descriptor.location.name} - self._assert_loads('static_tab', kwargs, descriptor) + if descriptor.location.category == 'about': + self._assert_loads('about_course', + {'course_id': course_id}, + descriptor) - elif descriptor.location.category == 'course_info': - self._assert_loads('info', {'course_id': course_id}, - descriptor) + elif descriptor.location.category == 'static_tab': + kwargs = {'course_id': course_id, + 'tab_slug': descriptor.location.name} + self._assert_loads('static_tab', kwargs, descriptor) - elif descriptor.location.category == 'custom_tag_template': - pass + elif descriptor.location.category == 'course_info': + self._assert_loads('info', {'course_id': course_id}, + descriptor) - else: + else: - kwargs = {'course_id': course_id, - 'location': descriptor.location.url()} + kwargs = {'course_id': course_id, + 'location': descriptor.location.url()} - self._assert_loads('jump_to', kwargs, descriptor, - expect_redirect=True, - check_content=True) + self._assert_loads('jump_to', kwargs, descriptor, + expect_redirect=True, + check_content=True) def _assert_loads(self, django_url, kwargs, descriptor, expect_redirect=False, @@ -124,54 +123,51 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): self.assertNotIsInstance(descriptor, ErrorDescriptor) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestCoursesLoadTestCase_XmlModulestore(PageLoaderTestCase): +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestXmlCoursesLoad(ModuleStoreTestCase, PageLoaderTestCase): """ Check that all pages in test courses load properly from XML. """ def setUp(self): - super(TestCoursesLoadTestCase_XmlModulestore, self).setUp() + super(TestXmlCoursesLoad, self).setUp() self.setup_user() - xmodule.modulestore.django._MODULESTORES.clear() def test_toy_course_loads(self): - module_class = 'xmodule.hidden_module.HiddenDescriptor' - module_store = XMLModuleStore(TEST_DATA_DIR, - default_class=module_class, - course_dirs=['toy'], - load_error_modules=True) - self.check_random_page_loads(module_store) + # Load one of the XML based courses + # Our test mapping rules allow the MixedModuleStore + # to load this course from XML, not Mongo. + self.check_all_pages_load('edX/toy/2012_Fall') +# Importing XML courses isn't possible with MixedModuleStore, +# so we use a Mongo modulestore directly (as we would in Studio) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) -class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): +class TestMongoCoursesLoad(ModuleStoreTestCase, PageLoaderTestCase): """ Check that all pages in test courses load properly from Mongo. """ def setUp(self): - super(TestCoursesLoadTestCase_MongoModulestore, self).setUp() + super(TestMongoCoursesLoad, self).setUp() self.setup_user() - xmodule.modulestore.django._MODULESTORES.clear() - modulestore().collection.drop() + + # Import the toy course into a Mongo-backed modulestore + self.store = modulestore() + import_from_xml(self.store, TEST_DATA_DIR, ['toy']) def test_toy_course_loads(self): - module_store = modulestore() - import_from_xml(module_store, TEST_DATA_DIR, ['toy']) - self.check_random_page_loads(module_store) + self.check_all_pages_load('edX/toy/2012_Fall') def test_toy_textbooks_loads(self): - module_store = modulestore() - import_from_xml(module_store, TEST_DATA_DIR, ['toy']) - - course = module_store.get_item(Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None])) - + location = Location(['i4x', 'edX', 'toy', 'course', '2012_Fall', None]) + course = self.store.get_item(location) self.assertGreater(len(course.textbooks), 0) + @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) -class TestDraftModuleStore(TestCase): +class TestDraftModuleStore(ModuleStoreTestCase, TestCase): def test_get_items_with_course_items(self): store = modulestore() diff --git a/lms/djangoapps/django_comment_client/base/tests.py b/lms/djangoapps/django_comment_client/base/tests.py index 434d4d616b..e6ce3b2d25 100644 --- a/lms/djangoapps/django_comment_client/base/tests.py +++ b/lms/djangoapps/django_comment_client/base/tests.py @@ -10,14 +10,14 @@ from django.core.urlresolvers import reverse from django.core.management import call_command from util.testing import UrlResetMixin -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from nose.tools import assert_true, assert_equal from mock import patch log = logging.getLogger(__name__) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @patch('comment_client.utils.requests.request') class ViewsTestCase(UrlResetMixin, ModuleStoreTestCase): diff --git a/lms/djangoapps/django_comment_client/forum/tests.py b/lms/djangoapps/django_comment_client/forum/tests.py index bd18ab80d6..2d889722a4 100644 --- a/lms/djangoapps/django_comment_client/forum/tests.py +++ b/lms/djangoapps/django_comment_client/forum/tests.py @@ -6,7 +6,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from django.core.urlresolvers import reverse from util.testing import UrlResetMixin -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from nose.tools import assert_true from mock import patch, Mock @@ -15,7 +15,7 @@ import logging log = logging.getLogger(__name__) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class ViewsExceptionTestCase(UrlResetMixin, ModuleStoreTestCase): @patch.dict("django.conf.settings.MITX_FEATURES", {"ENABLE_DISCUSSION_SERVICE": True}) diff --git a/lms/djangoapps/instructor/tests/test_access.py b/lms/djangoapps/instructor/tests/test_access.py index 688ed89dad..ee2e91f766 100644 --- a/lms/djangoapps/instructor/tests/test_access.py +++ b/lms/djangoapps/instructor/tests/test_access.py @@ -9,7 +9,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from django.test.utils import override_settings -from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from courseware.access import get_access_group_name from django_comment_common.models import (Role, @@ -20,7 +20,7 @@ from instructor.access import (allow_access, update_forum_role_membership) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAccessList(ModuleStoreTestCase): """ Test access listings. """ def setUp(self): @@ -42,7 +42,7 @@ class TestInstructorAccessList(ModuleStoreTestCase): self.assertEqual(set(beta_testers), set(self.beta_testers)) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAccessAllow(ModuleStoreTestCase): """ Test access allow. """ def setUp(self): @@ -85,7 +85,7 @@ class TestInstructorAccessAllow(ModuleStoreTestCase): group = Group.objects.get(name=get_access_group_name(self.course, 'staff')) self.assertIn(user, group.user_set.all()) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAccessRevoke(ModuleStoreTestCase): """ Test access revoke. """ def setUp(self): @@ -129,7 +129,7 @@ class TestInstructorAccessRevoke(ModuleStoreTestCase): self.assertNotIn(user, group.user_set.all()) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAccessForum(ModuleStoreTestCase): """ Test forum access control. diff --git a/lms/djangoapps/instructor/tests/test_api.py b/lms/djangoapps/instructor/tests/test_api.py index 155a8a2c9f..7d55b001d0 100644 --- a/lms/djangoapps/instructor/tests/test_api.py +++ b/lms/djangoapps/instructor/tests/test_api.py @@ -14,7 +14,7 @@ from django.core.urlresolvers import reverse from django.http import HttpRequest, HttpResponse from django.contrib.auth.models import User -from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from courseware.tests.helpers import LoginEnrollmentTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -90,7 +90,7 @@ class TestCommonExceptions400(unittest.TestCase): self.assertIn("Task is already running", result["error"]) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Ensure that users cannot access endpoints they shouldn't be able to. @@ -147,7 +147,7 @@ class TestInstructorAPIDenyLevels(ModuleStoreTestCase, LoginEnrollmentTestCase): self.assertEqual(response.status_code, 403) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAPIEnrollment(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Test enrollment modification endpoint. @@ -270,7 +270,7 @@ class TestInstructorAPIEnrollment(ModuleStoreTestCase, LoginEnrollmentTestCase): self.assertEqual(res_json, expected) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAPILevelsAccess(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Test endpoints whereby instructors can change permissions @@ -414,7 +414,7 @@ class TestInstructorAPILevelsAccess(ModuleStoreTestCase, LoginEnrollmentTestCase self.assertEqual(res_json, expected) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Test endpoints that show data without side effects. @@ -521,7 +521,7 @@ class TestInstructorAPILevelsDataDump(ModuleStoreTestCase, LoginEnrollmentTestCa self.assertEqual(response.status_code, 400) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Test endpoints whereby instructors can change student grades. @@ -655,7 +655,7 @@ class TestInstructorAPIRegradeTask(ModuleStoreTestCase, LoginEnrollmentTestCase) self.assertTrue(act.called) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Test instructor task list endpoint. @@ -745,7 +745,7 @@ class TestInstructorAPITaskLists(ModuleStoreTestCase, LoginEnrollmentTestCase): self.assertEqual(json.loads(response.content), expected_res) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) @override_settings(ANALYTICS_SERVER_URL="http://robotanalyticsserver.netbot:900/") @override_settings(ANALYTICS_API_KEY="robot_api_key") class TestInstructorAPIAnalyticsProxy(ModuleStoreTestCase, LoginEnrollmentTestCase): diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py index 8f12572875..4513025aa5 100644 --- a/lms/djangoapps/instructor/tests/test_hint_manager.py +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -5,14 +5,14 @@ from django.test.utils import override_settings from courseware.models import XModuleContentField from courseware.tests.factories import ContentFactory -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE import instructor.hint_manager as view from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class HintManagerTest(ModuleStoreTestCase): def setUp(self): diff --git a/lms/djangoapps/instructor/tests/test_legacy_download_csv.py b/lms/djangoapps/instructor/tests/test_legacy_download_csv.py index b05746f015..b77626c8a1 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_download_csv.py +++ b/lms/djangoapps/instructor/tests/test_legacy_download_csv.py @@ -17,12 +17,12 @@ from django.core.urlresolvers import reverse from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase -from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.django import modulestore import xmodule.modulestore.django -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): ''' Check for download of csv @@ -31,7 +31,6 @@ class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): def setUp(self): xmodule.modulestore.django._MODULESTORES = {} - self.full = modulestore().get_course("edX/full/6.002_Spring_2012") self.toy = modulestore().get_course("edX/toy/2012_Fall") # Create two accounts diff --git a/lms/djangoapps/instructor/tests/test_legacy_enrollment.py b/lms/djangoapps/instructor/tests/test_legacy_enrollment.py index 1f5ea8ad56..4c1c252891 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_enrollment.py +++ b/lms/djangoapps/instructor/tests/test_legacy_enrollment.py @@ -7,7 +7,7 @@ from django.test.utils import override_settings from django.contrib.auth.models import User from django.core.urlresolvers import reverse from courseware.tests.helpers import LoginEnrollmentTestCase -from courseware.tests.modulestore_config import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -18,7 +18,7 @@ from django.core import mail USER_COUNT = 4 -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorEnrollsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Check Enrollment/Unenrollment with/without auto-enrollment on activation and with/without email notification diff --git a/lms/djangoapps/instructor/tests/test_legacy_forum_admin.py b/lms/djangoapps/instructor/tests/test_legacy_forum_admin.py index 90dadd569e..3b691aa708 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_forum_admin.py +++ b/lms/djangoapps/instructor/tests/test_legacy_forum_admin.py @@ -15,7 +15,7 @@ from django_comment_client.utils import has_forum_access from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase -from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from xmodule.modulestore.django import modulestore import xmodule.modulestore.django @@ -32,7 +32,7 @@ def action_name(operation, rolename): return '{0} forum {1}'.format(operation, FORUM_ADMIN_ACTION_SUFFIX[rolename]) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorDashboardForumAdmin(LoginEnrollmentTestCase): ''' Check for change in forum admin role memberships diff --git a/lms/djangoapps/instructor/tests/test_legacy_gradebook.py b/lms/djangoapps/instructor/tests/test_legacy_gradebook.py index aaf03deb8c..fd285d2e3f 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_gradebook.py +++ b/lms/djangoapps/instructor/tests/test_legacy_gradebook.py @@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from student.tests.factories import UserFactory, CourseEnrollmentFactory, AdminFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE from capa.tests.response_xml_factory import StringResponseXMLFactory from courseware.tests.factories import StudentModuleFactory from xmodule.modulestore import Location @@ -17,7 +17,7 @@ from xmodule.modulestore.django import modulestore USER_COUNT = 11 -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestGradebook(ModuleStoreTestCase): grading_policy = None diff --git a/lms/djangoapps/instructor/tests/test_legacy_xss.py b/lms/djangoapps/instructor/tests/test_legacy_xss.py index 7df6511b3c..784838fc4c 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_xss.py +++ b/lms/djangoapps/instructor/tests/test_legacy_xss.py @@ -7,14 +7,14 @@ from django.test.client import RequestFactory from django.test.utils import override_settings from markupsafe import escape -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE from student.tests.factories import UserFactory, CourseEnrollmentFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from instructor.views import legacy -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestXss(ModuleStoreTestCase): def setUp(self): self._request_factory = RequestFactory() diff --git a/lms/djangoapps/instructor_task/tests/test_base.py b/lms/djangoapps/instructor_task/tests/test_base.py index b67453e997..2c1fe02bd8 100644 --- a/lms/djangoapps/instructor_task/tests/test_base.py +++ b/lms/djangoapps/instructor_task/tests/test_base.py @@ -13,13 +13,13 @@ from django.contrib.auth.models import User from django.test.utils import override_settings from capa.tests.response_xml_factory import OptionResponseXMLFactory -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.django import editable_modulestore from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from student.tests.factories import CourseEnrollmentFactory, UserFactory from courseware.model_data import StudentModule -from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_MONGO_MODULESTORE +from courseware.tests.tests import LoginEnrollmentTestCase, TEST_DATA_MIXED_MODULESTORE from instructor_task.api_helper import encode_problem_and_student_input from instructor_task.models import PROGRESS, QUEUING @@ -95,7 +95,7 @@ class InstructorTaskTestCase(TestCase): return self._create_entry(task_state=task_state, task_output=progress, student=student) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase): """ Base test class for InstructorTask-related tests that require @@ -106,7 +106,7 @@ class InstructorTaskModuleTestCase(LoginEnrollmentTestCase, ModuleStoreTestCase) def initialize_course(self): """Create a course in the store, with a chapter and section.""" - self.module_store = modulestore() + self.module_store = editable_modulestore() # Create the course self.course = CourseFactory.create(org=TEST_COURSE_ORG, diff --git a/lms/djangoapps/licenses/tests.py b/lms/djangoapps/licenses/tests.py index 151a0faa9d..a853955c83 100644 --- a/lms/djangoapps/licenses/tests.py +++ b/lms/djangoapps/licenses/tests.py @@ -14,7 +14,7 @@ from django.core.management import call_command from django.core.urlresolvers import reverse from nose.tools import assert_true -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from licenses.models import CourseSoftware, UserLicense from student.tests.factories import UserFactory @@ -143,7 +143,7 @@ class LicenseTestCase(TestCase): self.assertEqual(302, response.status_code) -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class CommandTest(ModuleStoreTestCase): '''Test management command for importing serial numbers''' def setUp(self): diff --git a/lms/djangoapps/open_ended_grading/tests.py b/lms/djangoapps/open_ended_grading/tests.py index 262124d667..7ae5994dc1 100644 --- a/lms/djangoapps/open_ended_grading/tests.py +++ b/lms/djangoapps/open_ended_grading/tests.py @@ -27,14 +27,15 @@ log = logging.getLogger(__name__) from django.test.utils import override_settings from xmodule.tests import test_util_open_ended +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from courseware.tests import factories -from courseware.tests.modulestore_config import TEST_DATA_XML_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from courseware.tests.helpers import LoginEnrollmentTestCase, check_for_get_code, check_for_post_code -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestStaffGradingService(LoginEnrollmentTestCase): +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestStaffGradingService(ModuleStoreTestCase, LoginEnrollmentTestCase): ''' Check that staff grading service proxy works. Basically just checking the access control and error handling logic -- all the actual work is on the @@ -42,8 +43,6 @@ class TestStaffGradingService(LoginEnrollmentTestCase): ''' def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - self.student = 'view@test.com' self.instructor = 'view2@test.com' self.password = 'foo' @@ -138,8 +137,8 @@ class TestStaffGradingService(LoginEnrollmentTestCase): self.assertIsNotNone(content['problem_list']) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestPeerGradingService(LoginEnrollmentTestCase): +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestPeerGradingService(ModuleStoreTestCase, LoginEnrollmentTestCase): ''' Check that staff grading service proxy works. Basically just checking the access control and error handling logic -- all the actual work is on the @@ -147,8 +146,6 @@ class TestPeerGradingService(LoginEnrollmentTestCase): ''' def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - self.student = 'view@test.com' self.instructor = 'view2@test.com' self.password = 'foo' @@ -293,8 +290,8 @@ class TestPeerGradingService(LoginEnrollmentTestCase): self.assertFalse('actual_score' in response) -@override_settings(MODULESTORE=TEST_DATA_XML_MODULESTORE) -class TestPanel(LoginEnrollmentTestCase): +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) +class TestPanel(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Run tests on the open ended panel """ diff --git a/lms/djangoapps/staticbook/tests.py b/lms/djangoapps/staticbook/tests.py index deb13ffc9e..c18b3663e7 100644 --- a/lms/djangoapps/staticbook/tests.py +++ b/lms/djangoapps/staticbook/tests.py @@ -10,7 +10,7 @@ import requests from django.test.utils import override_settings from django.core.urlresolvers import reverse, NoReverseMatch -from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from student.tests.factories import UserFactory, CourseEnrollmentFactory from xmodule.modulestore.tests.factories import CourseFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -36,7 +36,7 @@ HTML_BOOK = { ], } -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +@override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class StaticBookTest(ModuleStoreTestCase): """ Helpers for the static book tests. From 7bf734221c2d28f5fc7053816471565d31da7c06 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 21 Aug 2013 10:49:48 -0400 Subject: [PATCH 079/244] Fix var name issue --- cms/djangoapps/contentstore/views/assets.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 74cb94a354..35502659f3 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -167,7 +167,7 @@ def upload_asset(request, org, course, coursename): sc_partial = partial(StaticContent, content_loc, filename, mime_type) if chunked: content = sc_partial(upload_file.chunks()) - temp_filepath = upload_file.temporary_file_path() + tempfile_path = upload_file.temporary_file_path() else: content = sc_partial(upload_file.read()) tempfile_path = None From 151782acf052fc3a3c5c722d5668855514687a36 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 21 Aug 2013 16:23:48 -0400 Subject: [PATCH 080/244] LMS acceptance tests use mixed modulestore --- common/djangoapps/terrain/browser.py | 5 ++-- common/djangoapps/terrain/course_helpers.py | 6 ++-- .../xmodule/modulestore/tests/django_utils.py | 29 ------------------- lms/envs/acceptance.py | 18 ++++++++---- 4 files changed, 17 insertions(+), 41 deletions(-) diff --git a/common/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py index c2bf2bbbf3..24dd9f3729 100644 --- a/common/djangoapps/terrain/browser.py +++ b/common/djangoapps/terrain/browser.py @@ -108,9 +108,10 @@ def reset_databases(scenario): mongo = MongoClient() mongo.drop_database(settings.CONTENTSTORE['OPTIONS']['db']) _CONTENTSTORE.clear() - modulestore = xmodule.modulestore.django.modulestore() + + modulestore = xmodule.modulestore.django.editable_modulestore() modulestore.collection.drop() - xmodule.modulestore.django._MODULESTORES.clear() + xmodule.modulestore.django.clear_existing_modulestores() # Uncomment below to trigger a screenshot on error diff --git a/common/djangoapps/terrain/course_helpers.py b/common/djangoapps/terrain/course_helpers.py index eca3290080..fc01d25d66 100644 --- a/common/djangoapps/terrain/course_helpers.py +++ b/common/djangoapps/terrain/course_helpers.py @@ -10,7 +10,7 @@ from django.contrib.auth import authenticate, login from django.contrib.auth.middleware import AuthenticationMiddleware from django.contrib.sessions.middleware import SessionMiddleware from student.models import CourseEnrollment -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.django import editable_modulestore from xmodule.contentstore.django import contentstore from urllib import quote_plus @@ -60,11 +60,9 @@ def register_by_course_id(course_id, is_staff=False): @world.absorb def clear_courses(): # Flush and initialize the module store - # It needs the templates because it creates new records - # by cloning from the template. # Note that if your test module gets in some weird state # (though it shouldn't), do this manually # from the bash shell to drop it: # $ mongo test_xmodule --eval "db.dropDatabase()" - modulestore().collection.drop() + editable_modulestore().collection.drop() contentstore().fs_files.drop() diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 87156ec0dd..dabdd00e43 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -210,32 +210,3 @@ class ModuleStoreTestCase(TestCase): # Call superclass implementation super(ModuleStoreTestCase, self)._post_teardown() - - - def assert2XX(self, status_code, msg=None): - """ - Assert that the given value is a success status (between 200 and 299) - """ - msg = self._formatMessage(msg, "%s is not a success status" % safe_repr(status_code)) - self.assertTrue(status_code >= 200 and status_code < 300, msg=msg) - - def assert3XX(self, status_code, msg=None): - """ - Assert that the given value is a redirection status (between 300 and 399) - """ - msg = self._formatMessage(msg, "%s is not a redirection status" % safe_repr(status_code)) - self.assertTrue(status_code >= 300 and status_code < 400, msg=msg) - - def assert4XX(self, status_code, msg=None): - """ - Assert that the given value is a client error status (between 400 and 499) - """ - msg = self._formatMessage(msg, "%s is not a client error status" % safe_repr(status_code)) - self.assertTrue(status_code >= 400 and status_code < 500, msg=msg) - - def assert5XX(self, status_code, msg=None): - """ - Assert that the given value is a server error status (between 500 and 599) - """ - msg = self._formatMessage(msg, "%s is not a server error status" % safe_repr(status_code)) - self.assertTrue(status_code >= 500 and status_code < 600, msg=msg) diff --git a/lms/envs/acceptance.py b/lms/envs/acceptance.py index 1e188d3b45..a5c455288a 100644 --- a/lms/envs/acceptance.py +++ b/lms/envs/acceptance.py @@ -35,15 +35,21 @@ modulestore_options = { MODULESTORE = { 'default': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': modulestore_options - }, - 'direct': { - 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', - 'OPTIONS': modulestore_options + 'ENGINE': 'xmodule.modulestore.mixed.MixedModuleStore', + 'OPTIONS': { + 'mappings': {}, + 'stores': { + 'default': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': modulestore_options + } + } + } } } +MODULESTORE['direct'] = MODULESTORE['default'] + CONTENTSTORE = { 'ENGINE': 'xmodule.contentstore.mongo.MongoContentStore', 'OPTIONS': { From 109d3c3d100f67fe2b49feecddd6573dcf37d027 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 21 Aug 2013 16:52:37 -0400 Subject: [PATCH 081/244] Refactored to remove test#XXX methods from ModuleStoreTestCase --- .../contentstore/tests/test_assets.py | 4 +-- .../contentstore/tests/test_contentstore.py | 30 ++++++++-------- .../tests/test_course_settings.py | 6 ++-- .../contentstore/tests/test_item.py | 2 +- .../contentstore/tests/test_textbooks.py | 22 ++++++------ .../contentstore/tests/test_users.py | 34 +++++++++---------- .../xmodule/modulestore/tests/django_utils.py | 2 +- 7 files changed, 50 insertions(+), 50 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py index b627237729..2f158cfda6 100644 --- a/cms/djangoapps/contentstore/tests/test_assets.py +++ b/cms/djangoapps/contentstore/tests/test_assets.py @@ -60,11 +60,11 @@ class UploadTestCase(CourseTestCase): f = BytesIO("sample content") f.name = "sample.txt" resp = self.client.post(self.url, {"name": "my-name", "file": f}) - self.assert2XX(resp.status_code) + self.assertEquals(resp.status_code, 200) def test_no_file(self): resp = self.client.post(self.url, {"name": "file.txt"}) - self.assert4XX(resp.status_code) + self.assertEquals(resp.status_code, 400) def test_get(self): resp = self.client.get(self.url) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 96b0b84e36..2e94b42476 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1367,7 +1367,7 @@ class ContentStoreTest(ModuleStoreTestCase): 'course': loc.course, 'name': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) self.assertContains(resp, 'Chapter 2') # go to various pages @@ -1377,92 +1377,92 @@ class ContentStoreTest(ModuleStoreTestCase): kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # export page resp = self.client.get(reverse('export_course', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # manage users resp = self.client.get(reverse('manage_users', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # course info resp = self.client.get(reverse('course_info', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # settings_details resp = self.client.get(reverse('settings_details', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # settings_details resp = self.client.get(reverse('settings_grading', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # static_pages resp = self.client.get(reverse('static_pages', kwargs={'org': loc.org, 'course': loc.course, 'coursename': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # static_pages resp = self.client.get(reverse('asset_index', kwargs={'org': loc.org, 'course': loc.course, 'name': loc.name})) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # 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.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # 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.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # 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.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # 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.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # 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.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # 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.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) def test_import_into_new_course_id(self): module_store = modulestore('direct') diff --git a/cms/djangoapps/contentstore/tests/test_course_settings.py b/cms/djangoapps/contentstore/tests/test_course_settings.py index 2007ba2f69..f413820aac 100644 --- a/cms/djangoapps/contentstore/tests/test_course_settings.py +++ b/cms/djangoapps/contentstore/tests/test_course_settings.py @@ -430,12 +430,12 @@ class CourseGraderUpdatesTest(CourseTestCase): def test_get(self): resp = self.client.get(self.url) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) obj = json.loads(resp.content) def test_delete(self): resp = self.client.delete(self.url) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) def test_post(self): grader = { @@ -446,5 +446,5 @@ class CourseGraderUpdatesTest(CourseTestCase): "weight": 17.3, } resp = self.client.post(self.url, grader) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) obj = json.loads(resp.content) diff --git a/cms/djangoapps/contentstore/tests/test_item.py b/cms/djangoapps/contentstore/tests/test_item.py index 260444a8f7..e5ff992cb8 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.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) class TestCreateItem(CourseTestCase): diff --git a/cms/djangoapps/contentstore/tests/test_textbooks.py b/cms/djangoapps/contentstore/tests/test_textbooks.py index a21a1b1023..950d0f780e 100644 --- a/cms/djangoapps/contentstore/tests/test_textbooks.py +++ b/cms/djangoapps/contentstore/tests/test_textbooks.py @@ -23,7 +23,7 @@ class TextbookIndexTestCase(CourseTestCase): def test_view_index(self): "Basic check that the textbook index page responds correctly" resp = self.client.get(self.url) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # we don't have resp.context right now, # due to bugs in our testing harness :( if resp.context: @@ -36,7 +36,7 @@ class TextbookIndexTestCase(CourseTestCase): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH='XMLHttpRequest' ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) obj = json.loads(resp.content) self.assertEqual(self.course.pdf_textbooks, obj) @@ -73,7 +73,7 @@ class TextbookIndexTestCase(CourseTestCase): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH='XMLHttpRequest' ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) obj = json.loads(resp.content) self.assertEqual(content, obj) @@ -90,7 +90,7 @@ class TextbookIndexTestCase(CourseTestCase): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH='XMLHttpRequest' ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) # reload course store = get_modulestore(self.course.location) @@ -111,7 +111,7 @@ class TextbookIndexTestCase(CourseTestCase): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH='XMLHttpRequest' ) - self.assert4XX(resp.status_code) + self.assertEqual(resp.status_code, 400) obj = json.loads(resp.content) self.assertIn("error", obj) @@ -184,7 +184,7 @@ class TextbookCreateTestCase(CourseTestCase): HTTP_ACCEPT="application/json", HTTP_X_REQUESTED_WITH="XMLHttpRequest", ) - self.assert4XX(resp.status_code) + self.assertEqual(resp.status_code, 400) self.assertNotIn("Location", resp) @@ -238,14 +238,14 @@ class TextbookByIdTestCase(CourseTestCase): def test_get_1(self): "Get the first textbook" resp = self.client.get(self.url1) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) compare = json.loads(resp.content) self.assertEqual(compare, self.textbook1) def test_get_2(self): "Get the second textbook" resp = self.client.get(self.url2) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) compare = json.loads(resp.content) self.assertEqual(compare, self.textbook2) @@ -257,7 +257,7 @@ class TextbookByIdTestCase(CourseTestCase): def test_delete(self): "Delete a textbook by ID" resp = self.client.delete(self.url1) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) course = self.store.get_item(self.course.location) self.assertEqual(course.pdf_textbooks, [self.textbook2]) @@ -288,7 +288,7 @@ class TextbookByIdTestCase(CourseTestCase): ) self.assertEqual(resp.status_code, 201) resp2 = self.client.get(url) - self.assert2XX(resp2.status_code) + self.assertEqual(resp2.status_code, 200) compare = json.loads(resp2.content) self.assertEqual(compare, textbook) course = self.store.get_item(self.course.location) @@ -311,7 +311,7 @@ class TextbookByIdTestCase(CourseTestCase): ) self.assertEqual(resp.status_code, 201) resp2 = self.client.get(self.url2) - self.assert2XX(resp2.status_code) + self.assertEqual(resp2.status_code, 200) compare = json.loads(resp2.content) self.assertEqual(compare, replacement) course = self.store.get_item(self.course.location) diff --git a/cms/djangoapps/contentstore/tests/test_users.py b/cms/djangoapps/contentstore/tests/test_users.py index cbb8aa8b01..80b2364c43 100644 --- a/cms/djangoapps/contentstore/tests/test_users.py +++ b/cms/djangoapps/contentstore/tests/test_users.py @@ -72,13 +72,13 @@ class UsersTestCase(CourseTestCase): def test_detail_inactive(self): resp = self.client.get(self.inactive_detail_url) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 200) result = json.loads(resp.content) self.assertFalse(result["active"]) def test_detail_invalid(self): resp = self.client.get(self.invalid_detail_url) - self.assert4XX(resp.status_code) + self.assertEqual(resp.status_code, 404) result = json.loads(resp.content) self.assertIn("error", result) @@ -87,7 +87,7 @@ class UsersTestCase(CourseTestCase): self.detail_url, data={"role": None}, ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # reload user from DB ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] @@ -103,7 +103,7 @@ class UsersTestCase(CourseTestCase): content_type="application/json", HTTP_ACCEPT="application/json", ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # reload user from DB ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] @@ -122,7 +122,7 @@ class UsersTestCase(CourseTestCase): content_type="application/json", HTTP_ACCEPT="application/json", ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # reload user from DB ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] @@ -142,7 +142,7 @@ class UsersTestCase(CourseTestCase): content_type="application/json", HTTP_ACCEPT="application/json", ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # reload user from DB ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] @@ -157,7 +157,7 @@ class UsersTestCase(CourseTestCase): content_type="application/json", HTTP_ACCEPT="application/json", ) - self.assert4XX(resp.status_code) + self.assertEqual(resp.status_code, 400) result = json.loads(resp.content) self.assertIn("error", result) self.assert_not_enrolled() @@ -169,7 +169,7 @@ class UsersTestCase(CourseTestCase): content_type="application/json", HTTP_ACCEPT="application/json", ) - self.assert4XX(resp.status_code) + self.assertEqual(resp.status_code, 400) result = json.loads(resp.content) self.assertIn("error", result) self.assert_not_enrolled() @@ -180,7 +180,7 @@ class UsersTestCase(CourseTestCase): data={"role": "staff"}, HTTP_ACCEPT="application/json", ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # reload user from DB ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] @@ -197,7 +197,7 @@ class UsersTestCase(CourseTestCase): self.detail_url, HTTP_ACCEPT="application/json", ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # reload user from DB ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] @@ -214,7 +214,7 @@ class UsersTestCase(CourseTestCase): self.detail_url, HTTP_ACCEPT="application/json", ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # reload user from DB ext_user = User.objects.get(email=self.ext_user.email) groups = [g.name for g in ext_user.groups.all()] @@ -273,7 +273,7 @@ class UsersTestCase(CourseTestCase): data={"role": "instructor"}, HTTP_ACCEPT="application/json", ) - self.assert4XX(resp.status_code) + self.assertEqual(resp.status_code, 400) result = json.loads(resp.content) self.assertIn("error", result) @@ -288,7 +288,7 @@ class UsersTestCase(CourseTestCase): data={"role": "instructor"}, HTTP_ACCEPT="application/json", ) - self.assert4XX(resp.status_code) + self.assertEqual(resp.status_code, 400) result = json.loads(resp.content) self.assertIn("error", result) @@ -306,7 +306,7 @@ class UsersTestCase(CourseTestCase): }) resp = self.client.delete(self_url) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) # reload user from DB user = User.objects.get(email=self.user.email) groups = [g.name for g in user.groups.all()] @@ -321,7 +321,7 @@ class UsersTestCase(CourseTestCase): self.ext_user.save() resp = self.client.delete(self.detail_url) - self.assert4XX(resp.status_code) + self.assertEqual(resp.status_code, 400) result = json.loads(resp.content) self.assertIn("error", result) # reload user from DB @@ -347,7 +347,7 @@ class UsersTestCase(CourseTestCase): self.detail_url, HTTP_ACCEPT="application/json", ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) self.assert_enrolled() def test_staff_to_instructor_still_enrolled(self): @@ -366,7 +366,7 @@ class UsersTestCase(CourseTestCase): content_type="application/json", HTTP_ACCEPT="application/json", ) - self.assert2XX(resp.status_code) + self.assertEqual(resp.status_code, 204) self.assert_enrolled() def assert_not_enrolled(self): diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index dabdd00e43..00e34ce41a 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -1,5 +1,5 @@ """ -eoduleStore configuration for test cases. +Modulestore configuration for test cases. """ from uuid import uuid4 From 8165a033b143c0c14a1d4adac54b37637c6b346d Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 21 Aug 2013 19:42:59 -0400 Subject: [PATCH 082/244] Pep8/pylint fixes Fixed test_masquerade failure due to not clearing the modulestore between tests. --- common/djangoapps/course_groups/tests/tests.py | 2 +- common/djangoapps/external_auth/tests/test_shib.py | 2 +- common/lib/xmodule/xmodule/js/libpeerconnection.log | 0 common/lib/xmodule/xmodule/modulestore/django.py | 3 +-- .../xmodule/xmodule/modulestore/tests/django_utils.py | 3 +-- lms/djangoapps/courseware/tests/modulestore_config.py | 8 ++++++-- lms/djangoapps/courseware/tests/test_masquerade.py | 9 +++++++-- lms/djangoapps/courseware/tests/tests.py | 6 +++--- lms/djangoapps/instructor/tests/test_access.py | 1 + lms/djangoapps/instructor/tests/test_legacy_xss.py | 1 + lms/djangoapps/staticbook/tests.py | 1 + 11 files changed, 23 insertions(+), 13 deletions(-) create mode 100644 common/lib/xmodule/xmodule/js/libpeerconnection.log diff --git a/common/djangoapps/course_groups/tests/tests.py b/common/djangoapps/course_groups/tests/tests.py index debdc68c26..a17df56a71 100644 --- a/common/djangoapps/course_groups/tests/tests.py +++ b/common/djangoapps/course_groups/tests/tests.py @@ -17,7 +17,7 @@ from xmodule.modulestore.tests.django_utils import mixed_store_config # cms.envs.test doesn't. TEST_DATA_DIR = settings.COMMON_TEST_DATA_ROOT -TEST_MAPPING = { 'edX/toy/2012_Fall': 'xml' } +TEST_MAPPING = {'edX/toy/2012_Fall': 'xml'} TEST_DATA_MIXED_MODULESTORE = mixed_store_config(TEST_DATA_DIR, TEST_MAPPING) diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py index 187acdb595..d48948c6ae 100644 --- a/common/djangoapps/external_auth/tests/test_shib.py +++ b/common/djangoapps/external_auth/tests/test_shib.py @@ -18,7 +18,7 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.django import editable_modulestore -from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from external_auth.models import ExternalAuthMap from external_auth.views import shib_login, course_specific_login, course_specific_register diff --git a/common/lib/xmodule/xmodule/js/libpeerconnection.log b/common/lib/xmodule/xmodule/js/libpeerconnection.log new file mode 100644 index 0000000000..e69de29bb2 diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index adafbc1253..b239e5f1d4 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -103,10 +103,9 @@ def editable_modulestore(name='default'): store = store.modulestores['default'] # At this point, we either have the ability to create - # items in the store, or we do not. + # items in the store, or we do not. if hasattr(store, 'create_xmodule'): return store else: return None - diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 00e34ce41a..e7b0b98824 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -5,8 +5,7 @@ Modulestore configuration for test cases. from uuid import uuid4 from django.test import TestCase from xmodule.modulestore.django import editable_modulestore, \ - editable_modulestore, clear_existing_modulestores -from unittest.util import safe_repr + clear_existing_modulestores def mixed_store_config(data_dir, mappings): diff --git a/lms/djangoapps/courseware/tests/modulestore_config.py b/lms/djangoapps/courseware/tests/modulestore_config.py index e4ee86878f..74fd3da57f 100644 --- a/lms/djangoapps/courseware/tests/modulestore_config.py +++ b/lms/djangoapps/courseware/tests/modulestore_config.py @@ -1,6 +1,10 @@ +""" +Define test configuration for modulestores. +""" + from xmodule.modulestore.tests.django_utils import xml_store_config, \ - mongo_store_config, draft_mongo_store_config,\ - mixed_store_config + mongo_store_config, draft_mongo_store_config,\ + mixed_store_config from django.conf import settings diff --git a/lms/djangoapps/courseware/tests/test_masquerade.py b/lms/djangoapps/courseware/tests/test_masquerade.py index 3122dc6477..0ec320b605 100644 --- a/lms/djangoapps/courseware/tests/test_masquerade.py +++ b/lms/djangoapps/courseware/tests/test_masquerade.py @@ -16,17 +16,22 @@ from django.contrib.auth.models import Group, User from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.django import modulestore, clear_existing_modulestores import json @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) -class TestStaffMasqueradeAsStudent(LoginEnrollmentTestCase): +class TestStaffMasqueradeAsStudent(ModuleStoreTestCase, LoginEnrollmentTestCase): """ Check for staff being able to masquerade as student. """ def setUp(self): + + # Clear out the modulestores, causing them to reload + clear_existing_modulestores() + self.graded_course = modulestore().get_course("edX/graded/2012_Fall") # Create staff account diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 995c7a352c..4486a6a032 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -64,9 +64,9 @@ class PageLoaderTestCase(LoginEnrollmentTestCase): ) items = store.get_items( - location_query, - course_id=course_id, - depth=2 + location_query, + course_id=course_id, + depth=2 ) if len(items) < 1: diff --git a/lms/djangoapps/instructor/tests/test_access.py b/lms/djangoapps/instructor/tests/test_access.py index ee2e91f766..1874e88f22 100644 --- a/lms/djangoapps/instructor/tests/test_access.py +++ b/lms/djangoapps/instructor/tests/test_access.py @@ -85,6 +85,7 @@ class TestInstructorAccessAllow(ModuleStoreTestCase): group = Group.objects.get(name=get_access_group_name(self.course, 'staff')) self.assertIn(user, group.user_set.all()) + @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestInstructorAccessRevoke(ModuleStoreTestCase): """ Test access revoke. """ diff --git a/lms/djangoapps/instructor/tests/test_legacy_xss.py b/lms/djangoapps/instructor/tests/test_legacy_xss.py index 784838fc4c..d748876032 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_xss.py +++ b/lms/djangoapps/instructor/tests/test_legacy_xss.py @@ -14,6 +14,7 @@ from xmodule.modulestore.tests.factories import CourseFactory from instructor.views import legacy + @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class TestXss(ModuleStoreTestCase): def setUp(self): diff --git a/lms/djangoapps/staticbook/tests.py b/lms/djangoapps/staticbook/tests.py index c18b3663e7..135150a2d1 100644 --- a/lms/djangoapps/staticbook/tests.py +++ b/lms/djangoapps/staticbook/tests.py @@ -36,6 +36,7 @@ HTML_BOOK = { ], } + @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class StaticBookTest(ModuleStoreTestCase): """ From 078ad4b25e2dd51ada41422a974ba130991655bd Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 21 Aug 2013 20:31:23 -0400 Subject: [PATCH 083/244] Added comment justifying ModuleStoreTestCase design. --- .../xmodule/modulestore/tests/django_utils.py | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index e7b0b98824..1f856d7eba 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -131,6 +131,22 @@ class ModuleStoreTestCase(TestCase): 3. Use factories (e.g. `CourseFactory`, `ItemFactory`) to populate the modulestore with test data. + + NOTE: + * For Mongo-backed courses (created with `CourseFactory`), + the state of the course will be reset before/after each + test method executes. + + * For XML-backed courses, the course state will NOT + reset between test methods (although it will reset + between test classes) + + The reason is: XML courses are not editable, so to reset + a course you have to reload it from disk, which is slow. + + If you do need to reset an XML course, use + `clear_existing_modulestores()` directly in + your `setUp()` method. """ @staticmethod From 1759191370c2a3871ed91e0bc67e59558c7d6699 Mon Sep 17 00:00:00 2001 From: lapentab Date: Thu, 22 Aug 2013 08:43:28 -0400 Subject: [PATCH 084/244] Remove network calls in tests --- cms/djangoapps/contentstore/tests/test_contentstore.py | 10 ++++++++-- lms/djangoapps/courseware/tests/tests.py | 5 ++++- 2 files changed, 12 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 7491e5ab4a..f13edbd213 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -312,7 +312,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'html', 'toyhtml', None])) self.assertIn('/static/', handouts.data) - def test_import_textbook_as_content_element(self): + @mock.patch('xmodule.course_module.requests.get') + def test_import_textbook_as_content_element(self, mock_get): + mock_get.return_value.text = u'\n\n \n' + module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['toy']) @@ -845,7 +848,10 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): filesystem = OSFS(root_dir / ('test_export/' + dirname)) self.assertTrue(filesystem.exists(item.location.name + filename_suffix)) - def test_export_course(self): + @mock.patch('xmodule.course_module.requests.get') + def test_export_course(self, mock_get): + mock_get.return_value.text = u'\n\n \n' + module_store = modulestore('direct') draft_store = modulestore('draft') content_store = contentstore() diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 68b06a1ba8..5cf84d7088 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -2,6 +2,7 @@ Test for lms courseware app ''' import random +import mock from django.test import TestCase from django.core.urlresolvers import reverse @@ -162,7 +163,9 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): import_from_xml(module_store, TEST_DATA_DIR, ['toy']) self.check_random_page_loads(module_store) - def test_toy_textbooks_loads(self): + @mock.patch('xmodule.course_module.requests.get') + def test_toy_textbooks_loads(self, mock_get): + mock_get.return_value.text = u'\n\n \n' module_store = modulestore() import_from_xml(module_store, TEST_DATA_DIR, ['toy']) From d894a065dfaf649f30d65e7c4d95a5efa86b7e52 Mon Sep 17 00:00:00 2001 From: lapentab Date: Thu, 22 Aug 2013 09:28:37 -0400 Subject: [PATCH 085/244] Stylistic changes --- .../contentstore/tests/test_contentstore.py | 15 +++++++++++++-- lms/djangoapps/courseware/tests/tests.py | 10 +++++++++- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index f13edbd213..2afc26707a 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -3,6 +3,9 @@ import json import shutil import mock + +from textwrap import dedent + from django.test.client import Client from django.test.utils import override_settings from django.conf import settings @@ -314,7 +317,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): @mock.patch('xmodule.course_module.requests.get') def test_import_textbook_as_content_element(self, mock_get): - mock_get.return_value.text = u'\n\n \n' + mock_get.return_value.text = dedent(""" + + + + """).strip() module_store = modulestore('direct') import_from_xml(module_store, 'common/test/data/', ['toy']) @@ -850,7 +857,11 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): @mock.patch('xmodule.course_module.requests.get') def test_export_course(self, mock_get): - mock_get.return_value.text = u'\n\n \n' + mock_get.return_value.text = dedent(""" + + + + """).strip() module_store = modulestore('direct') draft_store = modulestore('draft') diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 5cf84d7088..21a7e392a0 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -4,6 +4,8 @@ Test for lms courseware app import random import mock +from textwrap import dedent + from django.test import TestCase from django.core.urlresolvers import reverse from django.test.utils import override_settings @@ -165,7 +167,12 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): @mock.patch('xmodule.course_module.requests.get') def test_toy_textbooks_loads(self, mock_get): - mock_get.return_value.text = u'\n\n \n' + mock_get.return_value.text = dedent(""" + + + + """).strip() + module_store = modulestore() import_from_xml(module_store, TEST_DATA_DIR, ['toy']) @@ -173,6 +180,7 @@ class TestCoursesLoadTestCase_MongoModulestore(PageLoaderTestCase): self.assertGreater(len(course.textbooks), 0) + @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) class TestDraftModuleStore(TestCase): def test_get_items_with_course_items(self): From a6cc30d1fea1d4709da6c2e93b658aee3737397f Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 22 Aug 2013 13:51:27 -0400 Subject: [PATCH 086/244] Fix tests, address review feedback --- .../js/src/combinedopenended/display.coffee | 1 - .../combined_open_ended_modulev1.py | 49 +++++++++++++------ .../combined_open_ended_rubric.py | 26 +++++++--- .../grading_service_module.py | 1 - .../xmodule/xmodule/peer_grading_module.py | 14 +++--- .../xmodule/tests/test_combined_open_ended.py | 24 ++++----- .../xmodule/tests/test_peer_grading.py | 2 +- 7 files changed, 70 insertions(+), 47 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee index 1732bfbe71..bd399e8c88 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/display.coffee @@ -171,7 +171,6 @@ class @CombinedOpenEnded @results_container = @$(@result_container_sel) @combined_rubric_container = @$(@combined_rubric_sel) - # Where to put the rubric once we load it @oe = @$(@open_ended_child_sel) 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 23fa9c28c8..8c90983d3c 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,7 +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 +from peer_grading_service import PeerGradingService, MockPeerGradingService, GradingServiceError log = logging.getLogger("mitx.courseware") @@ -40,10 +40,10 @@ HUMAN_TASK_TYPE = { } HUMAN_STATES = { - 'intitial' : "Not started.", - 'assessing' : "Being scored.", - 'intermediate_done' : "Scoring finished.", - 'done' : "Complete." + '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 @@ -438,6 +438,7 @@ class CombinedOpenEndedV1Module(): grader_type = grader_types[0] else: grader_type = "IN" + grader_types = ["IN"] if grader_type in HUMAN_GRADER_TYPE: human_grader_name = HUMAN_GRADER_TYPE[grader_type] @@ -514,33 +515,46 @@ class CombinedOpenEndedV1Module(): return return_html def check_if_student_has_done_needed_grading(self): + """ + Checks with the ORA server to see if the student has completed the needed peer grading to be shown their grade. + For example, if a student submits one response, and three peers grade their response, the student + cannot see their grades and feedback unless they reciprocate. + Output: + success - boolean indicator of success + allowed_to_submit - boolean indicator of whether student has done their needed grading or not + error_message - If not success, explains why + """ student_id = self.system.anonymous_student_id success = False allowed_to_submit = True - 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.url(), student_id) - log.info(response) 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: + except GradingServiceError: # 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 + except KeyError: + log.error("Invalid response from grading server for location {0} and student {1}".format(self.location, student_id)) + error_message = "Received invalid response from the graders. Please notify course staff." + return success, allowed_to_submit, error_message if count_graded >= count_required or count_available==0: - return success, allowed_to_submit, "" + error_message = "" + return success, allowed_to_submit, error_message else: allowed_to_submit = False # This is a student_facing_error + 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.

    ") error_message = error_string.format(count_required - count_graded, count_graded, count_required, student_sub_count) return success, allowed_to_submit, error_message @@ -562,9 +576,12 @@ class CombinedOpenEndedV1Module(): rubric_number+=1 response = self.get_last_response(rubric_number) score_length = len(response['grader_types']) - for z in xrange(0,score_length): - feedback = response['feedback_dicts'][z].get('feedback', '') - if response['grader_types'][z] in HUMAN_GRADER_TYPE.keys(): + for z in xrange(score_length): + if response['grader_types'][z] in HUMAN_GRADER_TYPE: + try: + feedback = response['feedback_dicts'][z].get('feedback', '') + except TypeError: + return {'success' : False} rubric_scores = [[response['rubric_scores'][z]]] grader_types = [[response['grader_types'][z]]] feedback_items = [[response['feedback_items'][z]]] @@ -664,7 +681,7 @@ class CombinedOpenEndedV1Module(): self.student_attempts +=1 self.state = self.INITIAL self.ready_to_reset = False - for i in xrange(0, len(self.task_xml)): + for i in xrange(len(self.task_xml)): self.current_task_number = i self.setup_next_task(reset=True) self.current_task.reset(self.system) 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 a072d5ad5e..a72fd07438 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 @@ -206,27 +206,39 @@ class CombinedOpenEndedRubric(object): def render_combined_rubric(self, rubric_xml, scores, score_types, feedback_types): success, score_tuples = CombinedOpenEndedRubric.reformat_scores_for_rendering(scores, score_types, feedback_types) + #Get all the categories in the rubric rubric_categories = self.extract_categories(rubric_xml) + #Get a list of max scores, each entry belonging to a rubric category max_scores = map((lambda cat: cat['options'][-1]['points']), rubric_categories) actual_scores = [] + #Get the highest possible score across all categories max_score = max(max_scores) - for i in xrange(0, len(rubric_categories)): - category = rubric_categories[i] - for j in xrange(0, len(category['options'])): + #Loop through each category + for i,category in enumerate(rubric_categories): + #Loop through each option in the category + for j in xrange(len(category['options'])): + #Intialize empty grader types list rubric_categories[i]['options'][j]['grader_types'] = [] - for tuple in score_tuples: - if tuple[1] == i and tuple[2] == j: - for grader_type in tuple[3]: + #Score tuples are a flat data structure with (category, option, grader_type_list) for selected graders + for tup in score_tuples: + if tup[1] == i and tup[2] == j: + for grader_type in tup[3]: + #Set the rubric grader type to the tuple grader types rubric_categories[i]['options'][j]['grader_types'].append(grader_type) + #Grab the score and add it to the actual scores. J will be the score for the selected + #grader type if len(actual_scores)<=i: + #Initialize a new list in the list of lists actual_scores.append([j]) else: + #If a list in the list of lists for this position exists, append to it actual_scores[i] += [j] actual_scores = [sum(i)/len(i) for i in actual_scores] correct = [] + #Define if the student is "correct" (1) "incorrect" (0) or "partially correct" (.5) for (i,a) in enumerate(actual_scores): - if int(a)/max_scores[i]==1: + if int(a) == max_scores[i]: correct.append(1) elif int(a)==0: correct.append(0) diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py index 6857876703..fcbe9e5ad1 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py @@ -62,7 +62,6 @@ class GradingService(object): """ Make a get request to the grading controller """ - log.debug(params) op = lambda: self.session.get(url, allow_redirects=allow_redirects, params=params) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index d60f448d3b..bbfc444cdc 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -227,7 +227,7 @@ class PeerGradingModule(PeerGradingFields, XModule): count_graded = self.student_data_for_location['count_graded'] count_required = self.student_data_for_location['count_required'] except: - success, response = self.query_data_for_location() + success, response = self.query_data_for_location(self.location) if not success: log.exception( "No instance data found and could not get data from controller for loc {0} student {1}".format( @@ -311,7 +311,7 @@ class PeerGradingModule(PeerGradingFields, XModule): """ 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"]: + if data.get("submission_flagged", False) in ["false", False, "False", "FALSE"]: required.append("rubric_scores[]") success, message = self._check_required(data, set(required)) if not success: @@ -325,6 +325,8 @@ class PeerGradingModule(PeerGradingFields, XModule): try: response = self.peer_gs.save_grade(**data_dict) success, location_data = self.query_data_for_location(data_dict['location']) + #Don't check for success above because the response = statement will raise the same Exception as the one + #that will cause success to be false. 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 @@ -507,7 +509,7 @@ class PeerGradingModule(PeerGradingFields, XModule): error_text = "Could not get list of problems to peer grade. Please notify course staff." log.error(error_text) success = False - except: + except Exception: log.exception("Could not contact peer grading service.") success = False @@ -518,7 +520,7 @@ class PeerGradingModule(PeerGradingFields, XModule): ''' try: return modulestore().get_instance(self.system.course_id, location) - except: + except Exception: # the linked problem doesn't exist log.error("Problem {0} does not exist in this course".format(location)) raise @@ -528,14 +530,14 @@ class PeerGradingModule(PeerGradingFields, XModule): problem_location = problem['location'] try: descriptor = _find_corresponding_module_for_location(problem_location) - except: + except Exception: continue if descriptor: problem['due'] = descriptor.lms.due grace_period = descriptor.lms.graceperiod try: problem_timeinfo = TimeInfo(problem['due'], grace_period) - except: + except Exception: log.error("Malformed due date or grace period string for location {0}".format(problem_location)) raise if self._closed(problem_timeinfo): 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 83f6dc6833..38d976370a 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -73,6 +73,7 @@ class OpenEndedChildTest(unittest.TestCase): def setUp(self): self.test_system = get_test_system() + self.test_system.open_ended_grading_interface = None self.openendedchild = OpenEndedChild(self.test_system, self.location, self.definition, self.descriptor, self.static_data, self.metadata) @@ -203,7 +204,7 @@ class OpenEndedModuleTest(unittest.TestCase): def setUp(self): self.test_system = get_test_system() - + self.test_system.open_ended_grading_interface = None self.test_system.location = self.location self.mock_xqueue = MagicMock() self.mock_xqueue.send_to_queue.return_value = (None, "Message") @@ -378,6 +379,7 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): full_definition = definition_template.format(prompt=prompt, rubric=rubric, task1=task_xml1, task2=task_xml2) descriptor = Mock(data=full_definition) test_system = get_test_system() + test_system.open_ended_grading_interface = None combinedoe_container = CombinedOpenEndedModule( test_system, descriptor, @@ -504,6 +506,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): def setUp(self): self.test_system = get_test_system() + self.test_system.open_ended_grading_interface = None self.test_system.xqueue['interface'] = Mock( send_to_queue=Mock(side_effect=[1, "queued"]) ) @@ -537,9 +540,9 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): module = self.get_module_from_location(self.problem_location, COURSE) #Simulate a student saving an answer - module.handle_ajax("save_answer", {"student_answer": self.answer}) - status = module.handle_ajax("get_status", {}) - self.assertTrue(isinstance(status, basestring)) + html = module.handle_ajax("get_html", {}) + module.handle_ajax("save_answer", {"student_answer": self.answer, "can_upload_files" : False, "student_file" : None}) + html = module.handle_ajax("get_html", {}) #Mock a student submitting an assessment assessment_dict = MockQueryDict() @@ -547,8 +550,7 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): module.handle_ajax("save_assessment", assessment_dict) task_one_json = json.loads(module.task_states[0]) self.assertEqual(json.loads(task_one_json['child_history'][0]['post_assessment']), assessment) - status = module.handle_ajax("get_status", {}) - self.assertTrue(isinstance(status, basestring)) + rubric = module.handle_ajax("get_combined_rubric", {}) #Move to the next step in the problem module.handle_ajax("next_problem", {}) @@ -585,7 +587,6 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): module.handle_ajax("save_assessment", assessment_dict) task_one_json = json.loads(module.task_states[0]) self.assertEqual(json.loads(task_one_json['child_history'][0]['post_assessment']), assessment) - module.handle_ajax("get_status", {}) #Move to the next step in the problem try: @@ -628,12 +629,8 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): #Get html and other data client will request module.get_html() - legend = module.handle_ajax("get_legend", {}) - self.assertTrue(isinstance(legend, basestring)) - module.handle_ajax("get_status", {}) module.handle_ajax("skip_post_assessment", {}) - self.assertTrue(isinstance(legend, basestring)) #Get all results module.handle_ajax("get_combined_rubric", {}) @@ -654,6 +651,7 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore): def setUp(self): self.test_system = get_test_system() + self.test_system.open_ended_grading_interface = None self.test_system.xqueue['interface'] = Mock( send_to_queue=Mock(side_effect=[1, "queued"]) ) @@ -670,8 +668,6 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore): #Simulate a student saving an answer module.handle_ajax("save_answer", {"student_answer": self.answer}) - status = module.handle_ajax("get_status", {}) - self.assertTrue(isinstance(status, basestring)) #Mock a student submitting an assessment assessment_dict = MockQueryDict() @@ -679,8 +675,6 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore): module.handle_ajax("save_assessment", assessment_dict) task_one_json = json.loads(module.task_states[0]) self.assertEqual(json.loads(task_one_json['child_history'][0]['post_assessment']), assessment) - status = module.handle_ajax("get_status", {}) - self.assertTrue(isinstance(status, basestring)) #Move to the next step in the problem module.handle_ajax("next_problem", {}) diff --git a/common/lib/xmodule/xmodule/tests/test_peer_grading.py b/common/lib/xmodule/xmodule/tests/test_peer_grading.py index fcdb0bb1ac..240fef4e87 100644 --- a/common/lib/xmodule/xmodule/tests/test_peer_grading.py +++ b/common/lib/xmodule/xmodule/tests/test_peer_grading.py @@ -61,7 +61,7 @@ class PeerGradingModuleTest(unittest.TestCase, DummyModulestore): Try getting data from the external grading service @return: """ - success, data = self.peer_grading.query_data_for_location() + success, data = self.peer_grading.query_data_for_location(self.problem_location.url()) self.assertEqual(success, True) def test_get_score(self): From 736b3e0ecdf3ef66b70bcadafbc4a687ce29bb99 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 22 Aug 2013 14:06:13 -0400 Subject: [PATCH 087/244] Common djangoapps no longer access courseware; other cleanup --- common/djangoapps/external_auth/tests/test_shib.py | 6 +++--- lms/djangoapps/instructor/tests/test_hint_manager.py | 2 +- .../instructor/tests/test_legacy_download_csv.py | 8 ++++---- .../instructor/tests/test_legacy_forum_admin.py | 8 ++++---- 4 files changed, 12 insertions(+), 12 deletions(-) diff --git a/common/djangoapps/external_auth/tests/test_shib.py b/common/djangoapps/external_auth/tests/test_shib.py index d48948c6ae..0355730256 100644 --- a/common/djangoapps/external_auth/tests/test_shib.py +++ b/common/djangoapps/external_auth/tests/test_shib.py @@ -14,12 +14,10 @@ from django.contrib.auth.models import AnonymousUser, User from django.utils.importlib import import_module from xmodule.modulestore.tests.factories import CourseFactory -from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase, mixed_store_config from xmodule.modulestore.inheritance import own_metadata from xmodule.modulestore.django import editable_modulestore -from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE - from external_auth.models import ExternalAuthMap from external_auth.views import shib_login, course_specific_login, course_specific_register @@ -27,6 +25,8 @@ from student.views import create_account, change_enrollment from student.models import UserProfile, Registration, CourseEnrollment from student.tests.factories import UserFactory +TEST_DATA_MIXED_MODULESTORE = mixed_store_config(settings.COMMON_TEST_DATA_ROOT, {}) + # Shib is supposed to provide 'REMOTE_USER', 'givenName', 'sn', 'mail', 'Shib-Identity-Provider' # attributes via request.META. We can count on 'Shib-Identity-Provider', and 'REMOTE_USER' being present # b/c of how mod_shib works but should test the behavior with the rest of the attributes present/missing diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py index 3b124f1c99..456f8e0ed8 100644 --- a/lms/djangoapps/instructor/tests/test_hint_manager.py +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -5,7 +5,7 @@ from django.test.utils import override_settings from courseware.models import XModuleContentField from courseware.tests.factories import ContentFactory -from courseware.tests.tests import TEST_DATA_MIXED_MODULESTORE +from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE import instructor.hint_manager as view from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase diff --git a/lms/djangoapps/instructor/tests/test_legacy_download_csv.py b/lms/djangoapps/instructor/tests/test_legacy_download_csv.py index b77626c8a1..c65547e408 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_download_csv.py +++ b/lms/djangoapps/instructor/tests/test_legacy_download_csv.py @@ -18,19 +18,19 @@ from django.core.urlresolvers import reverse from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -from xmodule.modulestore.django import modulestore +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.django import modulestore, clear_existing_modulestores import xmodule.modulestore.django @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) -class TestInstructorDashboardGradeDownloadCSV(LoginEnrollmentTestCase): +class TestInstructorDashboardGradeDownloadCSV(ModuleStoreTestCase, LoginEnrollmentTestCase): ''' Check for download of csv ''' def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} - + clear_existing_modulestores() self.toy = modulestore().get_course("edX/toy/2012_Fall") # Create two accounts diff --git a/lms/djangoapps/instructor/tests/test_legacy_forum_admin.py b/lms/djangoapps/instructor/tests/test_legacy_forum_admin.py index 3b691aa708..29046b151f 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_forum_admin.py +++ b/lms/djangoapps/instructor/tests/test_legacy_forum_admin.py @@ -16,8 +16,8 @@ from django_comment_client.utils import has_forum_access from courseware.access import _course_staff_group_name from courseware.tests.helpers import LoginEnrollmentTestCase from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -from xmodule.modulestore.django import modulestore -import xmodule.modulestore.django +from xmodule.modulestore.django import modulestore, clear_existing_modulestores +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase FORUM_ROLES = [FORUM_ROLE_ADMINISTRATOR, FORUM_ROLE_MODERATOR, FORUM_ROLE_COMMUNITY_TA] @@ -33,13 +33,13 @@ def action_name(operation, rolename): @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) -class TestInstructorDashboardForumAdmin(LoginEnrollmentTestCase): +class TestInstructorDashboardForumAdmin(ModuleStoreTestCase, LoginEnrollmentTestCase): ''' Check for change in forum admin role memberships ''' def setUp(self): - xmodule.modulestore.django._MODULESTORES = {} + clear_existing_modulestores() courses = modulestore().get_courses() self.course_id = "edX/toy/2012_Fall" From a06801432eaf58b8196634546206595a1b5caae9 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 22 Aug 2013 14:12:12 -0400 Subject: [PATCH 088/244] Fix staff grading test --- .../coffee/src/staff_grading/staff_grading.coffee | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lms/static/coffee/src/staff_grading/staff_grading.coffee b/lms/static/coffee/src/staff_grading/staff_grading.coffee index 31c084ffd0..ef76bb31f7 100644 --- a/lms/static/coffee/src/staff_grading/staff_grading.coffee +++ b/lms/static/coffee/src/staff_grading/staff_grading.coffee @@ -476,9 +476,13 @@ class @StaffGrading @question_header.text(new_text) scroll_to_top: () => - $('html, body').animate({ - scrollTop: $(".staff-grading").offset().top - }, 200) + #This try/catch is needed because jasmine fails with it + try + $('html, body').animate({ + scrollTop: $(".staff-grading").offset().top + }, 200) + catch error + console.log("Scrolling error.") From 21e13e44af24e096c1afb0909fdb4ff46f71c656 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 22 Aug 2013 14:29:39 -0400 Subject: [PATCH 089/244] Pep8 and pylint fixes --- .../xmodule/combined_open_ended_module.py | 12 +++++----- .../combined_open_ended_modulev1.py | 22 ++++++++++++++----- .../combined_open_ended_rubric.py | 6 ++--- .../grading_service_module.py | 3 +++ .../openendedchild.py | 4 ++-- 5 files changed, 30 insertions(+), 17 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index 62f537b1e9..f7960b13b1 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -213,7 +213,7 @@ class CombinedOpenEndedFields(object): help="The number of times the student can try to answer this problem.", default=1, scope=Scope.settings, - values={"min" : 1 } + values={"min": 1 } ) accept_file_upload = Boolean( display_name="Allow File Uploads", @@ -242,7 +242,7 @@ class CombinedOpenEndedFields(object): display_name="Problem Weight", help="Defines the number of points each problem is worth. If the value is not set, each problem is worth one point.", scope=Scope.settings, - values={"min" : 0 , "step": ".1"}, + values={"min": 0, "step": ".1"}, default=1 ) min_to_calibrate = Integer( @@ -250,28 +250,28 @@ 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, "max" : 20, "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={"min" : 1, "max" : 20, "step" : "1"} + values={"min": 1, "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=3, scope=Scope.settings, - values={"min" : 1, "step" : "1", "max" : 5} + 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=3, scope=Scope.settings, - values={"min" : 1, "step" : "1", "max" : 5} + values={"min": 1, "step": "1", "max": 5} ) markdown = String( help="Markdown source of this module", 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 8c90983d3c..c215df2d66 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 @@ -351,7 +351,12 @@ class CombinedOpenEndedV1Module(): return self.current_task.get_html(self.system) def get_html_ajax(self, data): - return {'html' : self.get_html()} + """ + Get HTML in AJAX callback + data - Needed to preserve AJAX structure + Output: Dictionary with html attribute + """ + return {'html': self.get_html()} def get_current_attributes(self, task_number): """ @@ -643,7 +648,12 @@ class CombinedOpenEndedV1Module(): def get_current_state(self, data): return self.get_context() - def get_last_response_ajax(self,data): + def get_last_response_ajax(self, data): + """ + Get the last response via ajax callback + data - Needed to preserve ajax callback structure + Output: Last response dictionary + """ return self.get_last_response(self.current_task_number) def next_problem(self, _data): @@ -666,10 +676,10 @@ class CombinedOpenEndedV1Module(): 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 + 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 return { 'success': False, # This is a student_facing_error 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 a72fd07438..1b8d84754a 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 @@ -214,7 +214,7 @@ class CombinedOpenEndedRubric(object): #Get the highest possible score across all categories max_score = max(max_scores) #Loop through each category - for i,category in enumerate(rubric_categories): + for i, category in enumerate(rubric_categories): #Loop through each option in the category for j in xrange(len(category['options'])): #Intialize empty grader types list @@ -234,10 +234,10 @@ class CombinedOpenEndedRubric(object): #If a list in the list of lists for this position exists, append to it actual_scores[i] += [j] - actual_scores = [sum(i)/len(i) for i in actual_scores] + actual_scores = [sum(i) / len(i) for i in actual_scores] correct = [] #Define if the student is "correct" (1) "incorrect" (0) or "partially correct" (.5) - for (i,a) in enumerate(actual_scores): + for (i, a) in enumerate(actual_scores): if int(a) == max_scores[i]: correct.append(1) elif int(a)==0: diff --git a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py index fcbe9e5ad1..4c6a79a5f1 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/grading_service_module.py @@ -11,6 +11,9 @@ log = logging.getLogger(__name__) class GradingServiceError(Exception): + """ + Exception for grading service. Shown when Open Response Assessment servers cannot be reached. + """ pass 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 d99e466886..2f8d4fa866 100644 --- a/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py +++ b/common/lib/xmodule/xmodule/open_ended_grading_classes/openendedchild.py @@ -333,7 +333,7 @@ class OpenEndedChild(object): try: image_data.seek(0) image_ok = open_ended_image_submission.run_image_tests(image_data) - except: + except Exception: log.exception("Could not create image and check it.") if image_ok: @@ -346,7 +346,7 @@ class OpenEndedChild(object): success, s3_public_url = open_ended_image_submission.upload_to_s3( image_data, image_key, self.s3_interface ) - except: + except Exception: log.exception("Could not upload image to S3.") return success, image_ok, s3_public_url From 46ce931d51cf3a3a9e8b6836c4c8c67509ee707b Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 22 Aug 2013 14:50:45 -0400 Subject: [PATCH 090/244] ErrorDescriptors do not have xml_attributes field --- common/lib/xmodule/xmodule/modulestore/xml_importer.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/common/lib/xmodule/xmodule/modulestore/xml_importer.py b/common/lib/xmodule/xmodule/modulestore/xml_importer.py index c793365060..beb255fb14 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml_importer.py +++ b/common/lib/xmodule/xmodule/modulestore/xml_importer.py @@ -250,10 +250,10 @@ def import_module(module, store, course_data_path, static_content_store, # inherited metadata everywhere. # remove any export/import only xml_attributes which are used to wire together draft imports - if 'parent_sequential_url' in module.xml_attributes: + if hasattr(module, 'xml_attributes') and 'parent_sequential_url' in module.xml_attributes: del module.xml_attributes['parent_sequential_url'] - if 'index_in_children_list' in module.xml_attributes: + if hasattr(module, 'xml_attributes') and 'index_in_children_list' in module.xml_attributes: del module.xml_attributes['index_in_children_list'] module.save() From 988a7a1fba6fdc1a9c8b39900842cd2d691584e7 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 15:48:40 -0700 Subject: [PATCH 091/244] initial commit of shopping cart and cybersource integration --- common/djangoapps/student/views.py | 2 +- lms/urls.py | 5 +++++ 2 files changed, 6 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4d59b5cc66..92f9d7f814 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -386,7 +386,7 @@ def change_enrollment(request): CourseEnrollment.unenroll(user, course_id) org, course_num, run = course_id.split("/") - statsd.increment("common.student.unenrollment", + log.increment("common.student.unenrollment", tags=["org:{0}".format(org), "course:{0}".format(course_num), "run:{0}".format(run)]) diff --git a/lms/urls.py b/lms/urls.py index b32c0263d0..9034683556 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -377,6 +377,11 @@ if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): ) +# Shopping cart +urlpatterns += ( + url(r'^shoppingcarttest/(?P[^/]+/[^/]+/[^/]+)/$','shoppingcart.views.test'), +) + if settings.MITX_FEATURES.get('AUTH_USE_OPENID_PROVIDER'): urlpatterns += ( From 4d81383e0a1e19728b85a0f48ca0d5ff9ad15688 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 22:53:36 -0700 Subject: [PATCH 092/244] added shopping cart list template, embedded form --- lms/djangoapps/shoppingcart/__init__.py | 0 .../shoppingcart/inventory_types.py | 68 ++++++++++++++ lms/djangoapps/shoppingcart/models.py | 3 + lms/djangoapps/shoppingcart/tests.py | 16 ++++ lms/djangoapps/shoppingcart/urls.py | 9 ++ lms/djangoapps/shoppingcart/views.py | 91 +++++++++++++++++++ lms/templates/shoppingcart/list.html | 46 ++++++++++ lms/urls.py | 2 +- 8 files changed, 234 insertions(+), 1 deletion(-) create mode 100644 lms/djangoapps/shoppingcart/__init__.py create mode 100644 lms/djangoapps/shoppingcart/inventory_types.py create mode 100644 lms/djangoapps/shoppingcart/models.py create mode 100644 lms/djangoapps/shoppingcart/tests.py create mode 100644 lms/djangoapps/shoppingcart/urls.py create mode 100644 lms/djangoapps/shoppingcart/views.py create mode 100644 lms/templates/shoppingcart/list.html diff --git a/lms/djangoapps/shoppingcart/__init__.py b/lms/djangoapps/shoppingcart/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/inventory_types.py b/lms/djangoapps/shoppingcart/inventory_types.py new file mode 100644 index 0000000000..0230760cb5 --- /dev/null +++ b/lms/djangoapps/shoppingcart/inventory_types.py @@ -0,0 +1,68 @@ +import logging +from django.contrib.auth.models import User +from student.views import course_from_id +from student.models import CourseEnrollmentAllowed, CourseEnrollment +from statsd import statsd + +log = logging.getLogger("shoppingcart") + +class InventoryItem(object): + """ + This is the abstract interface for inventory items. + Inventory items are things that fill up the shopping cart. + + Each implementation of InventoryItem should have purchased_callback as + a method and data attributes as defined in __init__ below + """ + def __init__(self): + # Set up default data attribute values + self.qty = 1 + self.unit_cost = 0 # in dollars + self.line_cost = 0 # qty * unit_cost + self.line_desc = "Misc Item" + + def purchased_callback(self, user_id): + """ + This is called on each inventory item in the shopping cart when the + purchase goes through. The parameter provided is the id of the user who + made the purchase. + """ + raise NotImplementedError + + +class PaidCourseRegistration(InventoryItem): + """ + This is an inventory item for paying for a course registration + """ + def __init__(self, course_id, unit_cost): + course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + self.qty = 1 + self.unit_cost = unit_cost + self.line_cost = unit_cost + self.course_id = course_id + self.line_desc = "Registration for Course {0}".format(course_id) + + def purchased_callback(self, user_id): + """ + When purchased, this should enroll the user in the course. We are assuming that + course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found in + CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment + would in fact be quite silly since there's a clear back door. + """ + user = User.objects.get(id=user_id) + course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for + # whatever reason. + # Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency + # with rest of codebase. + CourseEnrollmentAllowed.objects.get_or_create(email=user.email, course_id=self.course_id, auto_enroll=True) + CourseEnrollment.objects.get_or_create(user=user, course_id=self.course_id) + + log.info("Enrolled {0} in paid course {1}, paid ${2}".format(user.email, self.course_id, self.line_cost)) + org, course_num, run = self.course_id.split("/") + statsd.increment("shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py new file mode 100644 index 0000000000..71a8362390 --- /dev/null +++ b/lms/djangoapps/shoppingcart/models.py @@ -0,0 +1,3 @@ +from django.db import models + +# Create your models here. diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py new file mode 100644 index 0000000000..501deb776c --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests.py @@ -0,0 +1,16 @@ +""" +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 + + +class SimpleTest(TestCase): + def test_basic_addition(self): + """ + Tests that 1 + 1 always equals 2. + """ + self.assertEqual(1 + 1, 2) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py new file mode 100644 index 0000000000..47bd3c4c3d --- /dev/null +++ b/lms/djangoapps/shoppingcart/urls.py @@ -0,0 +1,9 @@ +from django.conf.urls import patterns, include, url + +urlpatterns = patterns('shoppingcart.views', # nopep8 + url(r'^$','show_cart'), + url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), + url(r'^add/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), + url(r'^clear/$','clear_cart'), + url(r'^remove_item/$', 'remove_item'), +) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py new file mode 100644 index 0000000000..e7d09e18b7 --- /dev/null +++ b/lms/djangoapps/shoppingcart/views.py @@ -0,0 +1,91 @@ +import logging +import random +import time +import hmac +import binascii +from hashlib import sha1 + +from collections import OrderedDict +from django.http import HttpResponse +from django.contrib.auth.decorators import login_required +from mitxmako.shortcuts import render_to_response +from .inventory_types import * + +log = logging.getLogger("shoppingcart") + + +def test(request, course_id): + item1 = PaidCourseRegistration(course_id, 200) + item1.purchased_callback(request.user.id) + return HttpResponse('OK') + +@login_required +def add_course_to_cart(request, course_id): + cart = request.session.get('shopping_cart', []) + course_ids_in_cart = [i.course_id for i in cart if isinstance(i, PaidCourseRegistration)] + if course_id not in course_ids_in_cart: + # TODO: Catch 500 here for course that does not exist, period + item = PaidCourseRegistration(course_id, 200) + cart.append(item) + request.session['shopping_cart'] = cart + return HttpResponse('Added') + else: + return HttpResponse("Item exists, not adding") + +@login_required +def show_cart(request): + cart = request.session.get('shopping_cart', []) + total_cost = "{0:0.2f}".format(sum([i.line_cost for i in cart])) + params = OrderedDict() + params['amount'] = total_cost + params['currency'] = 'usd' + params['orderPage_transactionType'] = 'sale' + params['orderNumber'] = "{0:d}".format(random.randint(1, 10000)) + signed_param_dict = cybersource_sign(params) + return render_to_response("shoppingcart/list.html", + {'shoppingcart_items': cart, + 'total_cost': total_cost, + 'params': signed_param_dict, + }) + +@login_required +def clear_cart(request): + request.session['shopping_cart'] = [] + return HttpResponse('Cleared') + +@login_required +def remove_item(request): + # doing this with indexes to replicate the function that generated the list on the HTML page + item_idx = request.REQUEST.get('idx', 'blank') + try: + cart = request.session.get('shopping_cart', []) + cart.pop(int(item_idx)) + request.session['shopping_cart'] = cart + except IndexError, ValueError: + log.exception('Cannot remove element at index {0} from cart'.format(item_idx)) + return HttpResponse('OK') + + +def cybersource_sign(params): + """ + params needs to be an ordered dict, b/c cybersource documentation states that order is important. + Reverse engineered from PHP version provided by cybersource + """ + shared_secret = "ELIDED" + merchant_id = "ELIDED" + serial_number = "ELIDED" + orderPage_version = "7" + params['merchantID'] = merchant_id + params['orderPage_timestamp'] = int(time.time()*1000) + params['orderPage_version'] = orderPage_version + params['orderPage_serialNumber'] = serial_number + fields = ",".join(params.keys()) + values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) + fields_hash_obj = hmac.new(shared_secret, fields, sha1) + fields_sig = binascii.b2a_base64(fields_hash_obj.digest())[:-1] # last character is a '\n', which we don't want + values += ",signedFieldsPublicSignature=" + fields_sig + values_hash_obj = hmac.new(shared_secret, values, sha1) + params['orderPage_signaturePublic'] = binascii.b2a_base64(values_hash_obj.digest())[:-1] + params['orderPage_signedFields'] = fields + + return params \ No newline at end of file diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html new file mode 100644 index 0000000000..f3fd26c96b --- /dev/null +++ b/lms/templates/shoppingcart/list.html @@ -0,0 +1,46 @@ +<%! from django.utils.translation import ugettext as _ %> + +<%! from django.core.urlresolvers import reverse %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Your Shopping Cart")} + +
    + + + + + + % for idx,item in enumerate(shoppingcart_items): + + + % endfor + + + + +
    QtyDescriptionUnit PricePrice
    ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost}[x]
    Total Cost
    ${total_cost}
    + + + % for pk, pv in params.iteritems(): + + % endfor + + +
    + + + + diff --git a/lms/urls.py b/lms/urls.py index 9034683556..53665f9ef6 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -379,7 +379,7 @@ if settings.MITX_FEATURES.get('RESTRICT_ENROLL_BY_REG_METHOD'): # Shopping cart urlpatterns += ( - url(r'^shoppingcarttest/(?P[^/]+/[^/]+/[^/]+)/$','shoppingcart.views.test'), + url(r'^shoppingcart/', include('shoppingcart.urls')), ) From ea7cf3a24eeeebc455b92154d155eeb139e437f6 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 7 Aug 2013 23:29:33 -0700 Subject: [PATCH 093/244] add parameterization of cybersource creds --- lms/djangoapps/shoppingcart/views.py | 9 +++--- lms/envs/aws.py | 2 ++ lms/envs/common.py | 8 ++++++ lms/templates/shoppingcart/list.html | 43 ++++++++++++++++------------ 4 files changed, 39 insertions(+), 23 deletions(-) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index e7d09e18b7..a2680fd845 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -5,6 +5,7 @@ import hmac import binascii from hashlib import sha1 +from django.conf import settings from collections import OrderedDict from django.http import HttpResponse from django.contrib.auth.decorators import login_required @@ -71,10 +72,10 @@ def cybersource_sign(params): params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ - shared_secret = "ELIDED" - merchant_id = "ELIDED" - serial_number = "ELIDED" - orderPage_version = "7" + shared_secret = settings.CYBERSOURCE.get('SHARED_SECRET','') + merchant_id = settings.CYBERSOURCE.get('MERCHANT_ID','') + serial_number = settings.CYBERSOURCE.get('SERIAL_NUMBER','') + orderPage_version = settings.CYBERSOURCE.get('ORDERPAGE_VERSION','7') params['merchantID'] = merchant_id params['orderPage_timestamp'] = int(time.time()*1000) params['orderPage_version'] = orderPage_version diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 8d2ffba96e..cc0e956b0c 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -191,6 +191,8 @@ if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) +CYBERSOURCE = AUTH_TOKENS.get('CYBERSOURCE', CYBERSOURCE) + SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] diff --git a/lms/envs/common.py b/lms/envs/common.py index 250552a40c..d397371cc2 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -431,6 +431,14 @@ ZENDESK_URL = None ZENDESK_USER = None ZENDESK_API_KEY = None +##### CyberSource Payment parameters ##### +CYBERSOURCE = { + 'SHARED_SECRET': '', + 'MERCHANT_ID' : '', + 'SERIAL_NUMBER' : '', + 'ORDERPAGE_VERSION': '7', +} + ################################# open ended grading config ##################### #By setting up the default settings with an incorrect user name and password, diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index f3fd26c96b..a1f785c8b4 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -7,27 +7,32 @@ <%block name="title">${_("Your Shopping Cart")}
    - - - - - - % for idx,item in enumerate(shoppingcart_items): - - - % endfor - - + % if shoppingcart_items: +
    QtyDescriptionUnit PricePrice
    ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost}[x]
    Total Cost
    ${total_cost}
    + + + + + % for idx,item in enumerate(shoppingcart_items): + + + % endfor + + - -
    QtyDescriptionUnit PricePrice
    ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost}[x]
    Total Cost
    ${total_cost}
    + + + +
    + % for pk, pv in params.iteritems(): + + % endfor + +
    + % else: +

    You have selected no items for purchase.

    + % endif -
    - % for pk, pv in params.iteritems(): - - % endfor - -
    From 3f9c52cd1cb097112f6a3fc1c2b484a59ea1560c Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 18:07:18 -0700 Subject: [PATCH 094/244] Move shopping cart from session into model/db --- common/djangoapps/student/views.py | 2 +- .../shoppingcart/inventory_types.py | 68 -------- .../shoppingcart/migrations/0001_initial.py | 116 +++++++++++++ .../shoppingcart/migrations/__init__.py | 0 lms/djangoapps/shoppingcart/models.py | 160 +++++++++++++++++- lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 60 ++++--- lms/envs/common.py | 3 + lms/templates/shoppingcart/list.html | 6 +- 9 files changed, 320 insertions(+), 98 deletions(-) delete mode 100644 lms/djangoapps/shoppingcart/inventory_types.py create mode 100644 lms/djangoapps/shoppingcart/migrations/0001_initial.py create mode 100644 lms/djangoapps/shoppingcart/migrations/__init__.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 92f9d7f814..4d59b5cc66 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -386,7 +386,7 @@ def change_enrollment(request): CourseEnrollment.unenroll(user, course_id) org, course_num, run = course_id.split("/") - log.increment("common.student.unenrollment", + statsd.increment("common.student.unenrollment", tags=["org:{0}".format(org), "course:{0}".format(course_num), "run:{0}".format(run)]) diff --git a/lms/djangoapps/shoppingcart/inventory_types.py b/lms/djangoapps/shoppingcart/inventory_types.py deleted file mode 100644 index 0230760cb5..0000000000 --- a/lms/djangoapps/shoppingcart/inventory_types.py +++ /dev/null @@ -1,68 +0,0 @@ -import logging -from django.contrib.auth.models import User -from student.views import course_from_id -from student.models import CourseEnrollmentAllowed, CourseEnrollment -from statsd import statsd - -log = logging.getLogger("shoppingcart") - -class InventoryItem(object): - """ - This is the abstract interface for inventory items. - Inventory items are things that fill up the shopping cart. - - Each implementation of InventoryItem should have purchased_callback as - a method and data attributes as defined in __init__ below - """ - def __init__(self): - # Set up default data attribute values - self.qty = 1 - self.unit_cost = 0 # in dollars - self.line_cost = 0 # qty * unit_cost - self.line_desc = "Misc Item" - - def purchased_callback(self, user_id): - """ - This is called on each inventory item in the shopping cart when the - purchase goes through. The parameter provided is the id of the user who - made the purchase. - """ - raise NotImplementedError - - -class PaidCourseRegistration(InventoryItem): - """ - This is an inventory item for paying for a course registration - """ - def __init__(self, course_id, unit_cost): - course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to - # throw errors if it doesn't - self.qty = 1 - self.unit_cost = unit_cost - self.line_cost = unit_cost - self.course_id = course_id - self.line_desc = "Registration for Course {0}".format(course_id) - - def purchased_callback(self, user_id): - """ - When purchased, this should enroll the user in the course. We are assuming that - course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found in - CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment - would in fact be quite silly since there's a clear back door. - """ - user = User.objects.get(id=user_id) - course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to - # throw errors if it doesn't - # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for - # whatever reason. - # Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency - # with rest of codebase. - CourseEnrollmentAllowed.objects.get_or_create(email=user.email, course_id=self.course_id, auto_enroll=True) - CourseEnrollment.objects.get_or_create(user=user, course_id=self.course_id) - - log.info("Enrolled {0} in paid course {1}, paid ${2}".format(user.email, self.course_id, self.line_cost)) - org, course_num, run = self.course_id.split("/") - statsd.increment("shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", - tags=["org:{0}".format(org), - "course:{0}".format(course_num), - "run:{0}".format(run)]) diff --git a/lms/djangoapps/shoppingcart/migrations/0001_initial.py b/lms/djangoapps/shoppingcart/migrations/0001_initial.py new file mode 100644 index 0000000000..779eccc94d --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0001_initial.py @@ -0,0 +1,116 @@ +# -*- 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 'Order' + db.create_table('shoppingcart_order', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), + ('nonce', self.gf('django.db.models.fields.CharField')(max_length=128)), + ('purchase_time', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), + )) + db.send_create_signal('shoppingcart', ['Order']) + + # Adding model 'OrderItem' + db.create_table('shoppingcart_orderitem', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('order', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['shoppingcart.Order'])), + ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), + ('qty', self.gf('django.db.models.fields.IntegerField')(default=1)), + ('unit_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)), + ('line_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)), + ('line_desc', self.gf('django.db.models.fields.CharField')(default='Misc. Item', max_length=1024)), + )) + db.send_create_signal('shoppingcart', ['OrderItem']) + + # Adding model 'PaidCourseRegistration' + db.create_table('shoppingcart_paidcourseregistration', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + )) + db.send_create_signal('shoppingcart', ['PaidCourseRegistration']) + + + def backwards(self, orm): + # Deleting model 'Order' + db.delete_table('shoppingcart_order') + + # Deleting model 'OrderItem' + db.delete_table('shoppingcart_orderitem') + + # Deleting model 'PaidCourseRegistration' + db.delete_table('shoppingcart_paidcourseregistration') + + + 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'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'nonce': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/__init__.py b/lms/djangoapps/shoppingcart/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 71a8362390..4b8ac259dd 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1,3 +1,161 @@ +import pytz +import logging +from datetime import datetime from django.db import models +from django.core.exceptions import ObjectDoesNotExist +from django.contrib.auth.models import User +from student.views import course_from_id +from student.models import CourseEnrollmentAllowed, CourseEnrollment +from statsd import statsd +log = logging.getLogger("shoppingcart") -# Create your models here. +ORDER_STATUSES = ( + ('cart', 'cart'), + ('purchased', 'purchased'), + ('refunded', 'refunded'), # Not used for now +) + +class Order(models.Model): + """ + This is the model for an order. Before purchase, an Order and its related OrderItems are used + as the shopping cart. + THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart' PER USER. + """ + user = models.ForeignKey(User, db_index=True) + status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) + # Because we allow an external service to tell us when something is purchased, and our order numbers + # are their pk and therefore predicatble, let's protect against + # forged/replayed replies with a nonce. + nonce = models.CharField(max_length=128) + purchase_time = models.DateTimeField(null=True, blank=True) + + @classmethod + def get_cart_for_user(cls, user): + """ + Use this to enforce the property that at most 1 order per user has status = 'cart' + """ + order, created = cls.objects.get_or_create(user=user, status='cart') + return order + + @property + def total_cost(self): + return sum([i.line_cost for i in self.orderitem_set.all()]) + + def purchase(self): + """ + Call to mark this order as purchased. Iterates through its OrderItems and calls + their purchased_callback + """ + self.status = 'purchased' + self.purchase_time = datetime.now(pytz.utc) + self.save() + for item in self.orderitem_set.all(): + item.status = 'purchased' + item.purchased_callback() + item.save() + + +class OrderItem(models.Model): + """ + This is the basic interface for order items. + Order items are line items that fill up the shopping carts and orders. + + Each implementation of OrderItem should provide its own purchased_callback as + a method. + """ + order = models.ForeignKey(Order, db_index=True) + # this is denormalized, but convenient for SQL queries for reports, etc. user should always be = order.user + user = models.ForeignKey(User, db_index=True) + # this is denormalized, but convenient for SQL queries for reports, etc. status should always be = order.status + status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) + qty = models.IntegerField(default=1) + unit_cost = models.FloatField(default=0.0) + line_cost = models.FloatField(default=0.0) # qty * unit_cost + line_desc = models.CharField(default="Misc. Item", max_length=1024) + + def add_to_order(self, *args, **kwargs): + """ + A suggested convenience function for subclasses. + """ + raise NotImplementedError + + def purchased_callback(self): + """ + This is called on each inventory item in the shopping cart when the + purchase goes through. + + NOTE: We want to provide facilities for doing something like + for item in OrderItem.objects.filter(order_id=order_id): + item.purchased_callback() + + Unfortunately the QuerySet used determines the class to be OrderItem, and not its most specific + subclasses. That means this parent class implementation of purchased_callback needs to act as + a dispatcher to call the callback the proper subclasses, and as such it needs to know about all subclasses. + So please add + """ + for classname, lc_classname in ORDER_ITEM_SUBTYPES: + try: + sub_instance = getattr(self,lc_classname) + sub_instance.purchased_callback() + except (ObjectDoesNotExist, AttributeError): + log.exception('Cannot call purchase_callback on non-existent subclass attribute {0} of OrderItem'\ + .format(lc_classname)) + pass + +# Each entry is a tuple of ('ModelName', 'lower_case_model_name') +# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for +# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem +ORDER_ITEM_SUBTYPES = [ + ('PaidCourseRegistration', 'paidcourseregistration') +] + + + +class PaidCourseRegistration(OrderItem): + """ + This is an inventory item for paying for a course registration + """ + course_id = models.CharField(max_length=128, db_index=True) + + @classmethod + def add_to_order(cls, order, course_id, cost): + """ + A standardized way to create these objects, with sensible defaults filled in. + Will update the cost if called on an order that already carries the course. + + Returns the order item + """ + # TODO: Possibly add checking for whether student is already enrolled in course + course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) + item.status = order.status + item.qty = 1 + item.unit_cost = cost + item.line_cost = cost + item.line_desc = "Registration for Course {0}".format(course_id) + item.save() + return item + + def purchased_callback(self): + """ + When purchased, this should enroll the user in the course. We are assuming that + course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found in + CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment + would in fact be quite silly since there's a clear back door. + """ + course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to + # throw errors if it doesn't + # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for + # whatever reason. + # Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency + # with rest of codebase. + CourseEnrollmentAllowed.objects.get_or_create(email=self.user.email, course_id=self.course_id, auto_enroll=True) + CourseEnrollment.objects.get_or_create(user=self.user, course_id=self.course_id) + + log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) + org, course_num, run = self.course_id.split("/") + statsd.increment("shoppingcart.PaidCourseRegistration.purchased_callback.enrollment", + tags=["org:{0}".format(org), + "course:{0}".format(course_num), + "run:{0}".format(run)]) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 47bd3c4c3d..80653f93cb 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -3,7 +3,8 @@ from django.conf.urls import patterns, include, url urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^$','show_cart'), url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), - url(r'^add/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), + url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), + url(r'^purchased/$', 'purchased'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index a2680fd845..4c2a4dd091 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -7,10 +7,10 @@ from hashlib import sha1 from django.conf import settings from collections import OrderedDict -from django.http import HttpResponse +from django.http import HttpResponse, HttpResponseRedirect from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response -from .inventory_types import * +from .models import * log = logging.getLogger("shoppingcart") @@ -20,50 +20,62 @@ def test(request, course_id): item1.purchased_callback(request.user.id) return HttpResponse('OK') +@login_required +def purchased(request): + #verify() -- signatures, total cost match up, etc. Need error handling code ( + # If verify fails probaly need to display a contact email/number) + cart = Order.get_cart_for_user(request.user) + cart.purchase() + return HttpResponseRedirect('/') + @login_required def add_course_to_cart(request, course_id): - cart = request.session.get('shopping_cart', []) - course_ids_in_cart = [i.course_id for i in cart if isinstance(i, PaidCourseRegistration)] - if course_id not in course_ids_in_cart: - # TODO: Catch 500 here for course that does not exist, period - item = PaidCourseRegistration(course_id, 200) - cart.append(item) - request.session['shopping_cart'] = cart - return HttpResponse('Added') - else: - return HttpResponse("Item exists, not adding") + cart = Order.get_cart_for_user(request.user) + # TODO: Catch 500 here for course that does not exist, period + PaidCourseRegistration.add_to_order(cart, course_id, 200) + return HttpResponse("Added") @login_required def show_cart(request): - cart = request.session.get('shopping_cart', []) - total_cost = "{0:0.2f}".format(sum([i.line_cost for i in cart])) + cart = Order.get_cart_for_user(request.user) + total_cost = cart.total_cost + cart_items = cart.orderitem_set.all() params = OrderedDict() params['amount'] = total_cost params['currency'] = 'usd' params['orderPage_transactionType'] = 'sale' - params['orderNumber'] = "{0:d}".format(random.randint(1, 10000)) + params['orderNumber'] = "{0:d}".format(cart.id) + params['billTo_email'] = request.user.email + idx=1 + for item in cart_items: + prefix = "item_{0:d}_".format(idx) + params[prefix+'productSKU'] = "{0:d}".format(item.id) + params[prefix+'quantity'] = item.qty + params[prefix+'productName'] = item.line_desc + params[prefix+'unitPrice'] = item.unit_cost + params[prefix+'taxAmount'] = "0.00" signed_param_dict = cybersource_sign(params) return render_to_response("shoppingcart/list.html", - {'shoppingcart_items': cart, + {'shoppingcart_items': cart_items, 'total_cost': total_cost, 'params': signed_param_dict, }) @login_required def clear_cart(request): - request.session['shopping_cart'] = [] + cart = Order.get_cart_for_user(request.user) + cart.orderitem_set.all().delete() return HttpResponse('Cleared') @login_required def remove_item(request): - # doing this with indexes to replicate the function that generated the list on the HTML page - item_idx = request.REQUEST.get('idx', 'blank') + item_id = request.REQUEST.get('id', '-1') try: - cart = request.session.get('shopping_cart', []) - cart.pop(int(item_idx)) - request.session['shopping_cart'] = cart - except IndexError, ValueError: - log.exception('Cannot remove element at index {0} from cart'.format(item_idx)) + item = OrderItem.objects.get(id=item_id, status='cart') + if item.user == request.user: + item.delete() + except OrderItem.DoesNotExist: + log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) return HttpResponse('OK') diff --git a/lms/envs/common.py b/lms/envs/common.py index d397371cc2..420068f7bd 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -778,6 +778,9 @@ INSTALLED_APPS = ( 'rest_framework', 'user_api', + # shopping cart + 'shoppingcart', + # Notification preferences setting 'notification_prefs', diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index a1f785c8b4..a37aa0fb5f 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -13,9 +13,9 @@ QtyDescriptionUnit PricePrice - % for idx,item in enumerate(shoppingcart_items): + % for item in shoppingcart_items: ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost} - [x] + [x] % endfor Total Cost ${total_cost} @@ -41,7 +41,7 @@ $('a.remove_line_item').click(function(event) { event.preventDefault(); var post_url = "${reverse('shoppingcart.views.remove_item')}"; - $.post(post_url, {idx:$(this).data('item-idx')}) + $.post(post_url, {id:$(this).data('item-id')}) .always(function(data){ location.reload(true); }); From ff5ca76aa67e645f7c5e4dece4aa85d7fdbddc73 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 21:55:08 -0700 Subject: [PATCH 095/244] add Validation function for cybersource receipt POST --- lms/djangoapps/shoppingcart/urls.py | 1 + lms/djangoapps/shoppingcart/views.py | 55 ++++++++++++++++++++++------ lms/templates/shoppingcart/list.html | 7 ++-- 3 files changed, 48 insertions(+), 15 deletions(-) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 80653f93cb..99d5217813 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -7,4 +7,5 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^purchased/$', 'purchased'), + url(r'^receipt/$', 'receipt'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 4c2a4dd091..f0558d3003 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -4,16 +4,22 @@ import time import hmac import binascii from hashlib import sha1 +from collections import OrderedDict from django.conf import settings -from collections import OrderedDict from django.http import HttpResponse, HttpResponseRedirect +from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response from .models import * log = logging.getLogger("shoppingcart") +shared_secret = settings.CYBERSOURCE.get('SHARED_SECRET','') +merchant_id = settings.CYBERSOURCE.get('MERCHANT_ID','') +serial_number = settings.CYBERSOURCE.get('SERIAL_NUMBER','') +orderPage_version = settings.CYBERSOURCE.get('ORDERPAGE_VERSION','7') + def test(request, course_id): item1 = PaidCourseRegistration(course_id, 200) @@ -39,9 +45,11 @@ def add_course_to_cart(request, course_id): def show_cart(request): cart = Order.get_cart_for_user(request.user) total_cost = cart.total_cost + amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() params = OrderedDict() - params['amount'] = total_cost + params['comment'] = 'Stanford OpenEdX Purchase' + params['amount'] = amount params['currency'] = 'usd' params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) @@ -57,7 +65,7 @@ def show_cart(request): signed_param_dict = cybersource_sign(params) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, - 'total_cost': total_cost, + 'amount': amount, 'params': signed_param_dict, }) @@ -78,27 +86,50 @@ def remove_item(request): log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) return HttpResponse('OK') +@csrf_exempt +def receipt(request): + """ + Receives the POST-back from Cybersource and performs the validation and displays a receipt + and does some other stuff + """ + if cybersource_verify(request.POST): + return HttpResponse("Validated") + else: + return HttpResponse("Not Validated") + +def cybersource_hash(value): + """ + Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page + """ + hash_obj = hmac.new(shared_secret, value, sha1) + return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want + def cybersource_sign(params): """ params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ - shared_secret = settings.CYBERSOURCE.get('SHARED_SECRET','') - merchant_id = settings.CYBERSOURCE.get('MERCHANT_ID','') - serial_number = settings.CYBERSOURCE.get('SERIAL_NUMBER','') - orderPage_version = settings.CYBERSOURCE.get('ORDERPAGE_VERSION','7') params['merchantID'] = merchant_id params['orderPage_timestamp'] = int(time.time()*1000) params['orderPage_version'] = orderPage_version params['orderPage_serialNumber'] = serial_number fields = ",".join(params.keys()) values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) - fields_hash_obj = hmac.new(shared_secret, fields, sha1) - fields_sig = binascii.b2a_base64(fields_hash_obj.digest())[:-1] # last character is a '\n', which we don't want + fields_sig = cybersource_hash(fields) values += ",signedFieldsPublicSignature=" + fields_sig - values_hash_obj = hmac.new(shared_secret, values, sha1) - params['orderPage_signaturePublic'] = binascii.b2a_base64(values_hash_obj.digest())[:-1] + params['orderPage_signaturePublic'] = cybersource_hash(values) params['orderPage_signedFields'] = fields - return params \ No newline at end of file + return params + +def cybersource_verify(params): + signed_fields = params.get('signedFields', '').split(',') + data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) + signed_fields_sig = cybersource_hash(params.get('signedFields', '')) + data += ",signedFieldsPublicSignature=" + signed_fields_sig + returned_sig = params.get('signedDataPublicSignature','') + if not returned_sig: + return False + return cybersource_hash(data) == returned_sig + diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index a37aa0fb5f..0ff97aa6ae 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -14,11 +14,12 @@ % for item in shoppingcart_items: - ${item.qty}${item.line_desc}${item.unit_cost}${item.line_cost} + ${item.qty}${item.line_desc} + ${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)} [x] % endfor - Total Cost - ${total_cost} + Total Amount + ${amount} From 44be024168768793b24b0a4d7b073f0d442d841a Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 8 Aug 2013 23:01:29 -0700 Subject: [PATCH 096/244] add Order model fields for receipt generation --- ...d_field_order_bill_to_first__add_field_.py | 183 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 34 +++- lms/djangoapps/shoppingcart/views.py | 2 +- lms/templates/shoppingcart/list.html | 3 +- 4 files changed, 213 insertions(+), 9 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py diff --git a/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py new file mode 100644 index 0000000000..940116f7b8 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py @@ -0,0 +1,183 @@ +# -*- 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): + # Deleting field 'Order.nonce' + db.delete_column('shoppingcart_order', 'nonce') + + # Adding field 'Order.bill_to_first' + db.add_column('shoppingcart_order', 'bill_to_first', + self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_last' + db.add_column('shoppingcart_order', 'bill_to_last', + self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_street1' + db.add_column('shoppingcart_order', 'bill_to_street1', + self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_street2' + db.add_column('shoppingcart_order', 'bill_to_street2', + self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_city' + db.add_column('shoppingcart_order', 'bill_to_city', + self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_postalcode' + db.add_column('shoppingcart_order', 'bill_to_postalcode', + self.gf('django.db.models.fields.CharField')(max_length=16, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_country' + db.add_column('shoppingcart_order', 'bill_to_country', + self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_ccnum' + db.add_column('shoppingcart_order', 'bill_to_ccnum', + self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.bill_to_cardtype' + db.add_column('shoppingcart_order', 'bill_to_cardtype', + self.gf('django.db.models.fields.CharField')(max_length=32, null=True, blank=True), + keep_default=False) + + # Adding field 'Order.processor_reply_dump' + db.add_column('shoppingcart_order', 'processor_reply_dump', + self.gf('django.db.models.fields.TextField')(null=True, blank=True), + keep_default=False) + + # Adding field 'OrderItem.currency' + db.add_column('shoppingcart_orderitem', 'currency', + self.gf('django.db.models.fields.CharField')(default='usd', max_length=8), + keep_default=False) + + + def backwards(self, orm): + # Adding field 'Order.nonce' + db.add_column('shoppingcart_order', 'nonce', + self.gf('django.db.models.fields.CharField')(default='defaultNonce', max_length=128), + keep_default=False) + + # Deleting field 'Order.bill_to_first' + db.delete_column('shoppingcart_order', 'bill_to_first') + + # Deleting field 'Order.bill_to_last' + db.delete_column('shoppingcart_order', 'bill_to_last') + + # Deleting field 'Order.bill_to_street1' + db.delete_column('shoppingcart_order', 'bill_to_street1') + + # Deleting field 'Order.bill_to_street2' + db.delete_column('shoppingcart_order', 'bill_to_street2') + + # Deleting field 'Order.bill_to_city' + db.delete_column('shoppingcart_order', 'bill_to_city') + + # Deleting field 'Order.bill_to_postalcode' + db.delete_column('shoppingcart_order', 'bill_to_postalcode') + + # Deleting field 'Order.bill_to_country' + db.delete_column('shoppingcart_order', 'bill_to_country') + + # Deleting field 'Order.bill_to_ccnum' + db.delete_column('shoppingcart_order', 'bill_to_ccnum') + + # Deleting field 'Order.bill_to_cardtype' + db.delete_column('shoppingcart_order', 'bill_to_cardtype') + + # Deleting field 'Order.processor_reply_dump' + db.delete_column('shoppingcart_order', 'processor_reply_dump') + + # Deleting field 'OrderItem.currency' + db.delete_column('shoppingcart_orderitem', 'currency') + + + 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'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 4b8ac259dd..f9da7082e1 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -19,20 +19,29 @@ class Order(models.Model): """ This is the model for an order. Before purchase, an Order and its related OrderItems are used as the shopping cart. - THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart' PER USER. + FOR ANY USER, THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart'. """ user = models.ForeignKey(User, db_index=True) status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) - # Because we allow an external service to tell us when something is purchased, and our order numbers - # are their pk and therefore predicatble, let's protect against - # forged/replayed replies with a nonce. - nonce = models.CharField(max_length=128) purchase_time = models.DateTimeField(null=True, blank=True) + # Now we store data needed to generate a reasonable receipt + # These fields only make sense after the purchase + bill_to_first = models.CharField(max_length=64, null=True, blank=True) + bill_to_last = models.CharField(max_length=64, null=True, blank=True) + bill_to_street1 = models.CharField(max_length=128, null=True, blank=True) + bill_to_street2 = models.CharField(max_length=128, null=True, blank=True) + bill_to_city = models.CharField(max_length=64, null=True, blank=True) + bill_to_postalcode = models.CharField(max_length=16, null=True, blank=True) + bill_to_country = models.CharField(max_length=64, null=True, blank=True) + bill_to_ccnum = models.CharField(max_length=8, null=True, blank=True) # last 4 digits + bill_to_cardtype = models.CharField(max_length=32, null=True, blank=True) + # a JSON dump of the CC processor response, for completeness + processor_reply_dump = models.TextField(null=True, blank=True) @classmethod def get_cart_for_user(cls, user): """ - Use this to enforce the property that at most 1 order per user has status = 'cart' + Always use this to preserve the property that at most 1 order per user has status = 'cart' """ order, created = cls.objects.get_or_create(user=user, status='cart') return order @@ -41,6 +50,15 @@ class Order(models.Model): def total_cost(self): return sum([i.line_cost for i in self.orderitem_set.all()]) + @property + def currency(self): + """Assumes that all cart items are in the same currency""" + items = self.orderitem_set.all() + if not items: + return 'usd' + else: + return items[0].currency + def purchase(self): """ Call to mark this order as purchased. Iterates through its OrderItems and calls @@ -72,6 +90,7 @@ class OrderItem(models.Model): unit_cost = models.FloatField(default=0.0) line_cost = models.FloatField(default=0.0) # qty * unit_cost line_desc = models.CharField(default="Misc. Item", max_length=1024) + currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes def add_to_order(self, *args, **kwargs): """ @@ -118,7 +137,7 @@ class PaidCourseRegistration(OrderItem): course_id = models.CharField(max_length=128, db_index=True) @classmethod - def add_to_order(cls, order, course_id, cost): + def add_to_order(cls, order, course_id, cost, currency='usd'): """ A standardized way to create these objects, with sensible defaults filled in. Will update the cost if called on an order that already carries the course. @@ -134,6 +153,7 @@ class PaidCourseRegistration(OrderItem): item.unit_cost = cost item.line_cost = cost item.line_desc = "Registration for Course {0}".format(course_id) + item.currency = currency item.save() return item diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index f0558d3003..d81de1a68c 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -50,7 +50,7 @@ def show_cart(request): params = OrderedDict() params['comment'] = 'Stanford OpenEdX Purchase' params['amount'] = amount - params['currency'] = 'usd' + params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) params['billTo_email'] = request.user.email diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 0ff97aa6ae..7c3e7052ae 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -10,12 +10,13 @@ % if shoppingcart_items: - + % for item in shoppingcart_items: + % endfor From 41b9f9f071d023871fa7da1b1c0297f3fb1d63fe Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 9 Aug 2013 11:01:32 -0700 Subject: [PATCH 097/244] factor out cybersource processor from cart --- lms/djangoapps/shoppingcart/models.py | 3 +- .../shoppingcart/processors/CyberSource.py | 81 +++++++++++++++++++ .../shoppingcart/processors/__init__.py | 34 ++++++++ lms/djangoapps/shoppingcart/views.py | 73 ++--------------- lms/envs/aws.py | 2 +- lms/envs/common.py | 16 ++-- .../shoppingcart/cybersource_form.html | 6 ++ lms/templates/shoppingcart/list.html | 7 +- 8 files changed, 140 insertions(+), 82 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/processors/CyberSource.py create mode 100644 lms/djangoapps/shoppingcart/processors/__init__.py create mode 100644 lms/templates/shoppingcart/cybersource_form.html diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index f9da7082e1..0bf4e3934e 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -4,6 +4,7 @@ from datetime import datetime from django.db import models from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User +from courseware.courses import course_image_url, get_course_about_section from student.views import course_from_id from student.models import CourseEnrollmentAllowed, CourseEnrollment from statsd import statsd @@ -152,7 +153,7 @@ class PaidCourseRegistration(OrderItem): item.qty = 1 item.unit_cost = cost item.line_cost = cost - item.line_desc = "Registration for Course {0}".format(course_id) + item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) item.currency = currency item.save() return item diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py new file mode 100644 index 0000000000..98026e6a84 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -0,0 +1,81 @@ +### Implementation of support for the Cybersource Credit card processor +### The name of this file should be used as the key of the dict in the CC_PROCESSOR setting + +import time +import hmac +import binascii +from collections import OrderedDict +from hashlib import sha1 +from django.conf import settings +from mitxmako.shortcuts import render_to_string + +shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') +merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') +serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') +orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') +purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') + +def hash(value): + """ + Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page + """ + hash_obj = hmac.new(shared_secret, value, sha1) + return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want + + +def sign(params): + """ + params needs to be an ordered dict, b/c cybersource documentation states that order is important. + Reverse engineered from PHP version provided by cybersource + """ + params['merchantID'] = merchant_id + params['orderPage_timestamp'] = int(time.time()*1000) + params['orderPage_version'] = orderPage_version + params['orderPage_serialNumber'] = serial_number + fields = ",".join(params.keys()) + values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) + fields_sig = hash(fields) + values += ",signedFieldsPublicSignature=" + fields_sig + params['orderPage_signaturePublic'] = hash(values) + params['orderPage_signedFields'] = fields + + return params + +def verify(params): + """ + Verify the signatures accompanying the POST back from Cybersource Hosted Order Page + """ + signed_fields = params.get('signedFields', '').split(',') + data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) + signed_fields_sig = hash(params.get('signedFields', '')) + data += ",signedFieldsPublicSignature=" + signed_fields_sig + returned_sig = params.get('signedDataPublicSignature','') + if not returned_sig: + return False + return hash(data) == returned_sig + +def render_purchase_form_html(cart, user): + total_cost = cart.total_cost + amount = "{0:0.2f}".format(total_cost) + cart_items = cart.orderitem_set.all() + params = OrderedDict() + params['comment'] = 'Stanford OpenEdX Purchase' + params['amount'] = amount + params['currency'] = cart.currency + params['orderPage_transactionType'] = 'sale' + params['orderNumber'] = "{0:d}".format(cart.id) + params['billTo_email'] = user.email + idx=1 + for item in cart_items: + prefix = "item_{0:d}_".format(idx) + params[prefix+'productSKU'] = "{0:d}".format(item.id) + params[prefix+'quantity'] = item.qty + params[prefix+'productName'] = item.line_desc + params[prefix+'unitPrice'] = item.unit_cost + params[prefix+'taxAmount'] = "0.00" + signed_param_dict = sign(params) + + return render_to_string('shoppingcart/cybersource_form.html', { + 'action': purchase_endpoint, + 'params': signed_param_dict, + }) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py new file mode 100644 index 0000000000..de567976be --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -0,0 +1,34 @@ +from django.conf import settings + +### Now code that determines, using settings, which actual processor implementation we're using. +processor_name = settings.CC_PROCESSOR.keys()[0] +module = __import__('shoppingcart.processors.' + processor_name, + fromlist=['sign', 'verify', 'render_purchase_form_html']) + +def sign(*args, **kwargs): + """ + Given a dict (or OrderedDict) of parameters to send to the + credit card processor, signs them in the manner expected by + the processor + + Returns a dict containing the signature + """ + return module.sign(*args, **kwargs) + +def verify(*args, **kwargs): + """ + Given a dict (or OrderedDict) of parameters to returned by the + credit card processor, verifies them in the manner specified by + the processor + + Returns a boolean + """ + return module.sign(*args, **kwargs) + +def render_purchase_form_html(*args, **kwargs): + """ + Given a shopping cart, + Renders the HTML form for display on user's browser, which POSTS to Hosted Processors + Returns the HTML as a string + """ + return module.render_purchase_form_html(*args, **kwargs) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index d81de1a68c..00e6db0e7d 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,26 +1,14 @@ import logging -import random -import time -import hmac -import binascii -from hashlib import sha1 -from collections import OrderedDict -from django.conf import settings from django.http import HttpResponse, HttpResponseRedirect from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response from .models import * +from .processors import verify, render_purchase_form_html log = logging.getLogger("shoppingcart") -shared_secret = settings.CYBERSOURCE.get('SHARED_SECRET','') -merchant_id = settings.CYBERSOURCE.get('MERCHANT_ID','') -serial_number = settings.CYBERSOURCE.get('SERIAL_NUMBER','') -orderPage_version = settings.CYBERSOURCE.get('ORDERPAGE_VERSION','7') - - def test(request, course_id): item1 = PaidCourseRegistration(course_id, 200) item1.purchased_callback(request.user.id) @@ -47,26 +35,11 @@ def show_cart(request): total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() - params = OrderedDict() - params['comment'] = 'Stanford OpenEdX Purchase' - params['amount'] = amount - params['currency'] = cart.currency - params['orderPage_transactionType'] = 'sale' - params['orderNumber'] = "{0:d}".format(cart.id) - params['billTo_email'] = request.user.email - idx=1 - for item in cart_items: - prefix = "item_{0:d}_".format(idx) - params[prefix+'productSKU'] = "{0:d}".format(item.id) - params[prefix+'quantity'] = item.qty - params[prefix+'productName'] = item.line_desc - params[prefix+'unitPrice'] = item.unit_cost - params[prefix+'taxAmount'] = "0.00" - signed_param_dict = cybersource_sign(params) + form_html = render_purchase_form_html(cart, request.user) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, 'amount': amount, - 'params': signed_param_dict, + 'form_html': form_html, }) @login_required @@ -89,47 +62,11 @@ def remove_item(request): @csrf_exempt def receipt(request): """ - Receives the POST-back from Cybersource and performs the validation and displays a receipt + Receives the POST-back from processor and performs the validation and displays a receipt and does some other stuff """ - if cybersource_verify(request.POST): + if verify(request.POST.dict()): return HttpResponse("Validated") else: return HttpResponse("Not Validated") -def cybersource_hash(value): - """ - Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page - """ - hash_obj = hmac.new(shared_secret, value, sha1) - return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want - - -def cybersource_sign(params): - """ - params needs to be an ordered dict, b/c cybersource documentation states that order is important. - Reverse engineered from PHP version provided by cybersource - """ - params['merchantID'] = merchant_id - params['orderPage_timestamp'] = int(time.time()*1000) - params['orderPage_version'] = orderPage_version - params['orderPage_serialNumber'] = serial_number - fields = ",".join(params.keys()) - values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) - fields_sig = cybersource_hash(fields) - values += ",signedFieldsPublicSignature=" + fields_sig - params['orderPage_signaturePublic'] = cybersource_hash(values) - params['orderPage_signedFields'] = fields - - return params - -def cybersource_verify(params): - signed_fields = params.get('signedFields', '').split(',') - data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) - signed_fields_sig = cybersource_hash(params.get('signedFields', '')) - data += ",signedFieldsPublicSignature=" + signed_fields_sig - returned_sig = params.get('signedDataPublicSignature','') - if not returned_sig: - return False - return cybersource_hash(data) == returned_sig - diff --git a/lms/envs/aws.py b/lms/envs/aws.py index cc0e956b0c..f7c6db39f9 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -191,7 +191,7 @@ if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) -CYBERSOURCE = AUTH_TOKENS.get('CYBERSOURCE', CYBERSOURCE) +CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR) SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] diff --git a/lms/envs/common.py b/lms/envs/common.py index 420068f7bd..d066259fe5 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -431,12 +431,16 @@ ZENDESK_URL = None ZENDESK_USER = None ZENDESK_API_KEY = None -##### CyberSource Payment parameters ##### -CYBERSOURCE = { - 'SHARED_SECRET': '', - 'MERCHANT_ID' : '', - 'SERIAL_NUMBER' : '', - 'ORDERPAGE_VERSION': '7', +##### shoppingcart Payment ##### +##### Using cybersource by default ##### +CC_PROCESSOR = { + 'CyberSource' : { + 'SHARED_SECRET': '', + 'MERCHANT_ID' : '', + 'SERIAL_NUMBER' : '', + 'ORDERPAGE_VERSION': '7', + 'PURCHASE_ENDPOINT': '', + } } ################################# open ended grading config ##################### diff --git a/lms/templates/shoppingcart/cybersource_form.html b/lms/templates/shoppingcart/cybersource_form.html new file mode 100644 index 0000000000..b29ea79aa1 --- /dev/null +++ b/lms/templates/shoppingcart/cybersource_form.html @@ -0,0 +1,6 @@ +
    + % for pk, pv in params.iteritems(): + + % endfor + + diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 7c3e7052ae..35623d8b5b 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -25,12 +25,7 @@
    QtyDescriptionUnit PricePrice
    QtyDescriptionUnit PricePriceCurrency
    ${item.qty}${item.line_desc} ${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()} [x]
    Total Amount
    -
    - % for pk, pv in params.iteritems(): - - % endfor - -
    + ${form_html} % else:

    You have selected no items for purchase.

    % endif From e4e22f0f85c1edc9ae09b04602bc5de5481b2dde Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Fri, 9 Aug 2013 18:36:35 -0700 Subject: [PATCH 098/244] Lots more verification of CyberSource reply + receipt generation --- ...003_auto__add_field_order_bill_to_state.py | 96 +++++++++++++++ lms/djangoapps/shoppingcart/models.py | 17 ++- .../shoppingcart/processors/CyberSource.py | 112 +++++++++++++++++- .../shoppingcart/processors/__init__.py | 22 +++- .../shoppingcart/processors/exceptions.py | 11 ++ lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 57 +++++++-- lms/envs/aws.py | 2 +- lms/envs/common.py | 1 + lms/templates/shoppingcart/list.html | 7 +- lms/templates/shoppingcart/receipt.html | 56 +++++++++ 11 files changed, 365 insertions(+), 19 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py create mode 100644 lms/djangoapps/shoppingcart/processors/exceptions.py create mode 100644 lms/templates/shoppingcart/receipt.html diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py new file mode 100644 index 0000000000..85923794b6 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py @@ -0,0 +1,96 @@ +# -*- 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 field 'Order.bill_to_state' + db.add_column('shoppingcart_order', 'bill_to_state', + self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'Order.bill_to_state' + db.delete_column('shoppingcart_order', 'bill_to_state') + + + 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'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 0bf4e3934e..052ecfb888 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -32,6 +32,7 @@ class Order(models.Model): bill_to_street1 = models.CharField(max_length=128, null=True, blank=True) bill_to_street2 = models.CharField(max_length=128, null=True, blank=True) bill_to_city = models.CharField(max_length=64, null=True, blank=True) + bill_to_state = models.CharField(max_length=8, null=True, blank=True) bill_to_postalcode = models.CharField(max_length=16, null=True, blank=True) bill_to_country = models.CharField(max_length=64, null=True, blank=True) bill_to_ccnum = models.CharField(max_length=8, null=True, blank=True) # last 4 digits @@ -49,7 +50,7 @@ class Order(models.Model): @property def total_cost(self): - return sum([i.line_cost for i in self.orderitem_set.all()]) + return sum([i.line_cost for i in self.orderitem_set.filter(status=self.status)]) @property def currency(self): @@ -60,13 +61,25 @@ class Order(models.Model): else: return items[0].currency - def purchase(self): + def purchase(self, first='', last='', street1='', street2='', city='', state='', postalcode='', + country='', ccnum='', cardtype='', processor_reply_dump=''): """ Call to mark this order as purchased. Iterates through its OrderItems and calls their purchased_callback """ self.status = 'purchased' self.purchase_time = datetime.now(pytz.utc) + self.bill_to_first = first + self.bill_to_last = last + self.bill_to_street1 = street1 + self.bill_to_street2 = street2 + self.bill_to_city = city + self.bill_to_state = state + self.bill_to_postalcode = postalcode + self.bill_to_country = country + self.bill_to_ccnum = ccnum + self.bill_to_cardtype = cardtype + self.processor_reply_dump = processor_reply_dump self.save() for item in self.orderitem_set.all(): item.status = 'purchased' diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 98026e6a84..17e1511ac6 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -1,13 +1,19 @@ ### Implementation of support for the Cybersource Credit card processor ### The name of this file should be used as the key of the dict in the CC_PROCESSOR setting +### Implementes interface as specified by __init__.py import time import hmac import binascii -from collections import OrderedDict +import re +import json +from collections import OrderedDict, defaultdict from hashlib import sha1 from django.conf import settings +from django.utils.translation import ugettext as _ from mitxmako.shortcuts import render_to_string +from shoppingcart.models import Order +from .exceptions import CCProcessorDataException, CCProcessorWrongAmountException shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') @@ -41,6 +47,7 @@ def sign(params): return params + def verify(params): """ Verify the signatures accompanying the POST back from Cybersource Hosted Order Page @@ -54,7 +61,11 @@ def verify(params): return False return hash(data) == returned_sig + def render_purchase_form_html(cart, user): + """ + Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource + """ total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() @@ -64,7 +75,6 @@ def render_purchase_form_html(cart, user): params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) - params['billTo_email'] = user.email idx=1 for item in cart_items: prefix = "item_{0:d}_".format(idx) @@ -78,4 +88,100 @@ def render_purchase_form_html(cart, user): return render_to_string('shoppingcart/cybersource_form.html', { 'action': purchase_endpoint, 'params': signed_param_dict, - }) \ No newline at end of file + }) + + +def payment_accepted(params): + """ + Check that cybersource has accepted the payment + """ + #make sure required keys are present and convert their values to the right type + valid_params = {} + for key, type in [('orderNumber', int), + ('ccAuthReply_amount', float), + ('orderCurrency', str), + ('decision', str)]: + if key not in params: + raise CCProcessorDataException( + _("The payment processor did not return a required parameter: {0}".format(key)) + ) + try: + valid_params[key] = type(params[key]) + except ValueError: + raise CCProcessorDataException( + _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) + ) + + try: + order = Order.objects.get(id=valid_params['orderNumber']) + except Order.DoesNotExist: + raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system.")) + + if valid_params['decision'] == 'ACCEPT': + if valid_params['ccAuthReply_amount'] == order.total_cost and valid_params['orderCurrency'] == order.currency: + return {'accepted': True, + 'amt_charged': valid_params['ccAuthReply_amount'], + 'currency': valid_params['orderCurrency'], + 'order': order} + else: + raise CCProcessorWrongAmountException( + _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}."\ + .format(valid_params['ccAuthReply_amount'], valid_params['orderCurrency'], + order.total_cost, order.currency)) + ) + else: + return {'accepted': False, + 'amt_charged': 0, + 'currency': 'usd', + 'order': None} + + +def record_purchase(params, order): + """ + Record the purchase and run purchased_callbacks + """ + ccnum_str = params.get('card_accountNumber', '') + m = re.search("\d", ccnum_str) + if m: + ccnum = ccnum_str[m.start():] + else: + ccnum = "####" + + order.purchase( + first=params.get('billTo_firstName', ''), + last=params.get('billTo_lastName', ''), + street1=params.get('billTo_street1', ''), + street2=params.get('billTo_street2', ''), + city=params.get('billTo_city', ''), + state=params.get('billTo_state', ''), + country=params.get('billTo_country', ''), + postalcode=params.get('billTo_postalCode',''), + ccnum=ccnum, + cardtype=CARDTYPE_MAP[params.get('card_cardType', 'UNKNOWN')], + processor_reply_dump=json.dumps(params) + ) + + +CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") +CARDTYPE_MAP.update( + { + '001': 'Visa', + '002': 'MasterCard', + '003': 'American Express', + '004': 'Discover', + '005': 'Diners Club', + '006': 'Carte Blanche', + '007': 'JCB', + '014': 'EnRoute', + '021': 'JAL', + '024': 'Maestro', + '031': 'Delta', + '033': 'Visa Electron', + '034': 'Dankort', + '035': 'Laser', + '036': 'Carte Bleue', + '037': 'Carta Si', + '042': 'Maestro', + '043': 'GE Money UK card' + } +) diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index de567976be..520c353535 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -3,7 +3,12 @@ from django.conf import settings ### Now code that determines, using settings, which actual processor implementation we're using. processor_name = settings.CC_PROCESSOR.keys()[0] module = __import__('shoppingcart.processors.' + processor_name, - fromlist=['sign', 'verify', 'render_purchase_form_html']) + fromlist=['sign', + 'verify', + 'render_purchase_form_html' + 'payment_accepted', + 'record_purchase', + ]) def sign(*args, **kwargs): """ @@ -32,3 +37,18 @@ def render_purchase_form_html(*args, **kwargs): Returns the HTML as a string """ return module.render_purchase_form_html(*args, **kwargs) + +def payment_accepted(*args, **kwargs): + """ + Given params returned by the CC processor, check that processor has accepted the payment + Returns a dict of {accepted:bool, amt_charged:float, currency:str, order:Order} + """ + return module.payment_accepted(*args, **kwargs) + +def record_purchase(*args, **kwargs): + """ + Given params returned by the CC processor, record that the purchase has occurred in + the database and also run callbacks + """ + return module.record_purchase(*args, **kwargs) + diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py new file mode 100644 index 0000000000..bc132a3d54 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -0,0 +1,11 @@ +class PaymentException(Exception): + pass + +class CCProcessorException(PaymentException): + pass + +class CCProcessorDataException(CCProcessorException): + pass + +class CCProcessorWrongAmountException(PaymentException): + pass \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 99d5217813..58e51f0b40 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -7,5 +7,6 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^purchased/$', 'purchased'), - url(r'^receipt/$', 'receipt'), + url(r'^postpay_accept_callback/$', 'postpay_accept_callback'), + url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 00e6db0e7d..f5540aafbb 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,11 +1,13 @@ import logging -from django.http import HttpResponse, HttpResponseRedirect +from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from mitxmako.shortcuts import render_to_response from .models import * -from .processors import verify, render_purchase_form_html +from .processors import verify, payment_accepted, render_purchase_form_html, record_purchase +from .processors.exceptions import CCProcessorDataException, CCProcessorWrongAmountException log = logging.getLogger("shoppingcart") @@ -60,13 +62,52 @@ def remove_item(request): return HttpResponse('OK') @csrf_exempt -def receipt(request): +def postpay_accept_callback(request): """ Receives the POST-back from processor and performs the validation and displays a receipt and does some other stuff - """ - if verify(request.POST.dict()): - return HttpResponse("Validated") - else: - return HttpResponse("Not Validated") + HANDLES THE ACCEPT AND REVIEW CASES + """ + # TODO: Templates and logic for all error cases and the REVIEW CASE + params = request.POST.dict() + if verify(params): + try: + result = payment_accepted(params) + if result['accepted']: + # ACCEPTED CASE first + record_purchase(params, result['order']) + #render_receipt + return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) + else: + return HttpResponse("CC Processor has not accepted the payment.") + except CCProcessorWrongAmountException: + return HttpResponse("Charged the wrong amount, contact our user support") + except CCProcessorDataException: + return HttpResponse("Exception: the processor returned invalid data") + else: + return HttpResponse("There has been a communication problem blah blah. Not Validated") + +def show_receipt(request, ordernum): + """ + Displays a receipt for a particular order. + 404 if order is not yet purchased or request.user != order.user + """ + try: + order = Order.objects.get(id=ordernum) + except Order.DoesNotExist: + raise Http404('Order not found!') + + if order.user != request.user or order.status != 'purchased': + raise Http404('Order not found!') + + order_items = order.orderitem_set.all() + any_refunds = "refunded" in [i.status for i in order_items] + return render_to_response('shoppingcart/receipt.html', {'order': order, + 'order_items': order_items, + 'any_refunds': any_refunds}) + +def show_orders(request): + """ + Displays all orders of a user + """ diff --git a/lms/envs/aws.py b/lms/envs/aws.py index f7c6db39f9..f6eb45ec51 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -127,6 +127,7 @@ SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL) CONTACT_EMAIL = ENV_TOKENS.get('CONTACT_EMAIL', CONTACT_EMAIL) BUGS_EMAIL = ENV_TOKENS.get('BUGS_EMAIL', BUGS_EMAIL) +PAYMENT_SUPPORT_EMAIL = ENV_TOKENS.get('PAYMENT_SUPPORT_EMAIL', PAYMENT_SUPPORT_EMAIL) #Theme overrides THEME_NAME = ENV_TOKENS.get('THEME_NAME', None) @@ -190,7 +191,6 @@ SEGMENT_IO_LMS_KEY = AUTH_TOKENS.get('SEGMENT_IO_LMS_KEY') if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = ENV_TOKENS.get('SEGMENT_IO_LMS', False) - CC_PROCESSOR = AUTH_TOKENS.get('CC_PROCESSOR', CC_PROCESSOR) SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] diff --git a/lms/envs/common.py b/lms/envs/common.py index d066259fe5..7e4d23f065 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -432,6 +432,7 @@ ZENDESK_USER = None ZENDESK_API_KEY = None ##### shoppingcart Payment ##### +PAYMENT_SUPPORT_EMAIL = 'payment@edx.org' ##### Using cybersource by default ##### CC_PROCESSOR = { 'CyberSource' : { diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 35623d8b5b..677077ba2d 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -7,10 +7,11 @@ <%block name="title">${_("Your Shopping Cart")}
    +

    ${_("Your selected items:")}

    % if shoppingcart_items: - + ${_("")} % for item in shoppingcart_items: @@ -19,7 +20,7 @@ % endfor - + @@ -27,7 +28,7 @@ ${form_html} % else: -

    You have selected no items for purchase.

    +

    ${_("You have selected no items for purchase.")}

    % endif diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html new file mode 100644 index 0000000000..01de3b3f83 --- /dev/null +++ b/lms/templates/shoppingcart/receipt.html @@ -0,0 +1,56 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> +<%! from django.conf import settings %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Receipt for Order")} ${order.id} + + + +
    +

    ${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}

    +

    ${_("Order #")}${order.id}

    +

    ${_("Date:")} ${order.purchase_time.date().isoformat()}

    +

    ${_("Items ordered:")}

    + +
    QtyDescriptionUnit PricePriceCurrency
    QuantityDescriptionUnit PricePriceCurrency
    ${item.currency.upper()} [x]
    Total Amount
    ${_("Total Amount")}
    ${amount}
    + + ${_("")} + + + % for item in order_items: + + % if item.status == "purchased": + + + + + % elif item.status == "refunded": + + + + + % endif + % endfor + + + +
    QtyDescriptionUnit PricePriceCurrency
    ${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}
    ${item.qty}${item.line_desc}${"{0:0.2f}".format(item.unit_cost)}${"{0:0.2f}".format(item.line_cost)}${item.currency.upper()}
    ${_("Total Amount")}
    ${"{0:0.2f}".format(order.total_cost)}
    + % if any_refunds: +

    + ${_("Note: items with strikethough like ")}this${_(" have been refunded.")} +

    + % endif + +

    ${_("Billed To:")}

    +

    + ${order.bill_to_cardtype} ${_("#:")} ${order.bill_to_ccnum}
    + ${order.bill_to_first} ${order.bill_to_last}
    + ${order.bill_to_street1}
    + ${order.bill_to_street2}
    + ${order.bill_to_city}, ${order.bill_to_state} ${order.bill_to_postalcode}
    + ${order.bill_to_country.upper()}
    +

    + +
    From 5ae2289df09d2d0ee2bf999cb7b93d1d8b23cf14 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 13 Aug 2013 11:41:05 -0700 Subject: [PATCH 099/244] about page changes, refactor processor reply handling --- common/static/js/capa/spec/jsinput_spec.js | 70 ------------------ lms/djangoapps/shoppingcart/models.py | 53 ++++++++++---- .../shoppingcart/processors/CyberSource.py | 52 +++++++++++++- .../shoppingcart/processors/__init__.py | 32 ++++++--- .../shoppingcart/processors/exceptions.py | 2 +- lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 71 +++++++++---------- lms/templates/courseware/course_about.html | 2 +- lms/templates/shoppingcart/list.html | 6 +- lms/templates/shoppingcart/receipt.html | 6 +- 10 files changed, 159 insertions(+), 138 deletions(-) delete mode 100644 common/static/js/capa/spec/jsinput_spec.js diff --git a/common/static/js/capa/spec/jsinput_spec.js b/common/static/js/capa/spec/jsinput_spec.js deleted file mode 100644 index a4a4f6e57d..0000000000 --- a/common/static/js/capa/spec/jsinput_spec.js +++ /dev/null @@ -1,70 +0,0 @@ -xdescribe("A jsinput has:", function () { - - beforeEach(function () { - $('#fixture').remove(); - $.ajax({ - async: false, - url: 'mainfixture.html', - success: function(data) { - $('body').append($(data)); - } - }); - }); - - - - describe("The jsinput constructor", function(){ - - var iframe1 = $(document).find('iframe')[0]; - - var testJsElem = jsinputConstructor({ - id: 1, - elem: iframe1, - passive: false - }); - - it("Returns an object", function(){ - expect(typeof(testJsElem)).toEqual('object'); - }); - - it("Adds the object to the jsinput array", function() { - expect(jsinput.exists(1)).toBe(true); - }); - - describe("The returned object", function() { - - it("Has a public 'update' method", function(){ - expect(testJsElem.update).toBeDefined(); - }); - - it("Returns an 'update' that is idempotent", function(){ - var orig = testJsElem.update(); - for (var i = 0; i++; i < 5) { - expect(testJsElem.update()).toEqual(orig); - } - }); - - it("Changes the parent's inputfield", function() { - testJsElem.update(); - - }); - }); - }); - - - describe("The walkDOM functions", function() { - - walkDOM(); - - it("Creates (at least) one object per iframe", function() { - jsinput.arr.length >= 2; - }); - - it("Does not create multiple objects with the same id", function() { - while (jsinput.arr.length > 0) { - var elem = jsinput.arr.pop(); - expect(jsinput.exists(elem.id)).toBe(false); - } - }); - }); -}) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 052ecfb888..acc0545ab7 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -123,11 +123,13 @@ class OrderItem(models.Model): Unfortunately the QuerySet used determines the class to be OrderItem, and not its most specific subclasses. That means this parent class implementation of purchased_callback needs to act as - a dispatcher to call the callback the proper subclasses, and as such it needs to know about all subclasses. - So please add + a dispatcher to call the callback the proper subclasses, and as such it needs to know about all + possible subclasses. + So keep ORDER_ITEM_SUBTYPES up-to-date """ - for classname, lc_classname in ORDER_ITEM_SUBTYPES: + for cls, lc_classname in ORDER_ITEM_SUBTYPES.iteritems(): try: + #Uses https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance to test subclass sub_instance = getattr(self,lc_classname) sub_instance.purchased_callback() except (ObjectDoesNotExist, AttributeError): @@ -135,13 +137,18 @@ class OrderItem(models.Model): .format(lc_classname)) pass -# Each entry is a tuple of ('ModelName', 'lower_case_model_name') -# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for -# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem -ORDER_ITEM_SUBTYPES = [ - ('PaidCourseRegistration', 'paidcourseregistration') -] - + def is_of_subtype(self, cls): + """ + Checks if self is also a type of cls, in addition to being an OrderItem + Uses https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance to test for subclass + """ + if cls not in ORDER_ITEM_SUBTYPES: + return False + try: + getattr(self, ORDER_ITEM_SUBTYPES[cls]) + return True + except (ObjectDoesNotExist, AttributeError): + return False class PaidCourseRegistration(OrderItem): @@ -151,7 +158,16 @@ class PaidCourseRegistration(OrderItem): course_id = models.CharField(max_length=128, db_index=True) @classmethod - def add_to_order(cls, order, course_id, cost, currency='usd'): + def part_of_order(cls, order, course_id): + """ + Is the course defined by course_id in the order? + """ + return course_id in [item.paidcourseregistration.course_id + for item in order.orderitem_set.all() + if item.is_of_subtype(PaidCourseRegistration)] + + @classmethod + def add_to_order(cls, order, course_id, cost=None, currency=None): """ A standardized way to create these objects, with sensible defaults filled in. Will update the cost if called on an order that already carries the course. @@ -164,6 +180,10 @@ class PaidCourseRegistration(OrderItem): item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status item.qty = 1 + if cost is None: + cost = course.enrollment_cost['cost'] + if currency is None: + currency = course.enrollment_cost['currency'] item.unit_cost = cost item.line_cost = cost item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) @@ -182,9 +202,6 @@ class PaidCourseRegistration(OrderItem): # throw errors if it doesn't # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for # whatever reason. - # Don't really need to create CourseEnrollmentAllowed object, but doing it for bookkeeping and consistency - # with rest of codebase. - CourseEnrollmentAllowed.objects.get_or_create(email=self.user.email, course_id=self.course_id, auto_enroll=True) CourseEnrollment.objects.get_or_create(user=self.user, course_id=self.course_id) log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) @@ -193,3 +210,11 @@ class PaidCourseRegistration(OrderItem): tags=["org:{0}".format(org), "course:{0}".format(course_num), "run:{0}".format(run)]) + + +# Each entry is a dictionary of ModelName: 'lower_case_model_name' +# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for +# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem +ORDER_ITEM_SUBTYPES = { + PaidCourseRegistration: 'paidcourseregistration', +} \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 17e1511ac6..75ad754237 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -13,7 +13,7 @@ from django.conf import settings from django.utils.translation import ugettext as _ from mitxmako.shortcuts import render_to_string from shoppingcart.models import Order -from .exceptions import CCProcessorDataException, CCProcessorWrongAmountException +from .exceptions import CCProcessorException, CCProcessorDataException, CCProcessorWrongAmountException shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') @@ -21,6 +21,42 @@ serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') +def process_postpay_callback(request): + """ + The top level call to this module, basically + This function is handed the callback request after the customer has entered the CC info and clicked "buy" + on the external Hosted Order Page. + It is expected to verify the callback and determine if the payment was successful. + It returns {'success':bool, 'order':Order, 'error_html':str} + If successful this function must have the side effect of marking the order purchased and calling the + purchased_callbacks of the cart items. + If unsuccessful this function should not have those side effects but should try to figure out why and + return a helpful-enough error message in error_html. + """ + params = request.POST.dict() + if verify_signatures(params): + try: + result = payment_accepted(params) + if result['accepted']: + # SUCCESS CASE first, rest are some sort of oddity + record_purchase(params, result['order']) + return {'success': True, + 'order': result['order'], + 'error_html': ''} + else: + return {'success': False, + 'order': result['order'], + 'error_html': get_processor_error_html(params)} + except CCProcessorException as e: + return {'success': False, + 'order': None, #due to exception we may not have the order + 'error_html': get_exception_html(params, e)} + else: + return {'success': False, + 'order': None, + 'error_html': get_signature_error_html(params)} + + def hash(value): """ Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page @@ -48,7 +84,7 @@ def sign(params): return params -def verify(params): +def verify_signatures(params): """ Verify the signatures accompanying the POST back from Cybersource Hosted Order Page """ @@ -161,6 +197,18 @@ def record_purchase(params, order): processor_reply_dump=json.dumps(params) ) +def get_processor_error_html(params): + """Have to parse through the error codes for all the other cases""" + return "

    ERROR!

    " + +def get_exception_html(params, exp): + """Return error HTML associated with exception""" + return "

    EXCEPTION!

    " + +def get_signature_error_html(params): + """Return error HTML associated with signature failure""" + return "

    EXCEPTION!

    " + CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") CARDTYPE_MAP.update( diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index 520c353535..45a6e3114d 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -8,8 +8,32 @@ module = __import__('shoppingcart.processors.' + processor_name, 'render_purchase_form_html' 'payment_accepted', 'record_purchase', + 'process_postpay_callback', ]) +def render_purchase_form_html(*args, **kwargs): + """ + The top level call to this module to begin the purchase. + Given a shopping cart, + Renders the HTML form for display on user's browser, which POSTS to Hosted Processors + Returns the HTML as a string + """ + return module.render_purchase_form_html(*args, **kwargs) + +def process_postpay_callback(*args, **kwargs): + """ + The top level call to this module after the purchase. + This function is handed the callback request after the customer has entered the CC info and clicked "buy" + on the external payment page. + It is expected to verify the callback and determine if the payment was successful. + It returns {'success':bool, 'order':Order, 'error_html':str} + If successful this function must have the side effect of marking the order purchased and calling the + purchased_callbacks of the cart items. + If unsuccessful this function should not have those side effects but should try to figure out why and + return a helpful-enough error message in error_html. + """ + return module.process_postpay_callback(*args, **kwargs) + def sign(*args, **kwargs): """ Given a dict (or OrderedDict) of parameters to send to the @@ -30,14 +54,6 @@ def verify(*args, **kwargs): """ return module.sign(*args, **kwargs) -def render_purchase_form_html(*args, **kwargs): - """ - Given a shopping cart, - Renders the HTML form for display on user's browser, which POSTS to Hosted Processors - Returns the HTML as a string - """ - return module.render_purchase_form_html(*args, **kwargs) - def payment_accepted(*args, **kwargs): """ Given params returned by the CC processor, check that processor has accepted the payment diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py index bc132a3d54..e863688133 100644 --- a/lms/djangoapps/shoppingcart/processors/exceptions.py +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -7,5 +7,5 @@ class CCProcessorException(PaymentException): class CCProcessorDataException(CCProcessorException): pass -class CCProcessorWrongAmountException(PaymentException): +class CCProcessorWrongAmountException(CCProcessorException): pass \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 58e51f0b40..892c66d5bb 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -6,7 +6,6 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), - url(r'^purchased/$', 'purchased'), - url(r'^postpay_accept_callback/$', 'postpay_accept_callback'), + url(r'^postpay_callback/$', 'postpay_callback'), #Both the ~accept and ~reject callback pages are handled here url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), ) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index f5540aafbb..87df7eaf1b 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,13 +1,14 @@ import logging - -from django.http import HttpResponse, HttpResponseRedirect, Http404 +from django.http import HttpResponse, HttpResponseRedirect, HttpResponseNotFound, HttpResponseForbidden, Http404 +from django.utils.translation import ugettext as _ from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required +from student.models import CourseEnrollment +from xmodule.modulestore.exceptions import ItemNotFoundError from mitxmako.shortcuts import render_to_response from .models import * -from .processors import verify, payment_accepted, render_purchase_form_html, record_purchase -from .processors.exceptions import CCProcessorDataException, CCProcessorWrongAmountException +from .processors import process_postpay_callback, render_purchase_form_html log = logging.getLogger("shoppingcart") @@ -16,20 +17,22 @@ def test(request, course_id): item1.purchased_callback(request.user.id) return HttpResponse('OK') -@login_required -def purchased(request): - #verify() -- signatures, total cost match up, etc. Need error handling code ( - # If verify fails probaly need to display a contact email/number) - cart = Order.get_cart_for_user(request.user) - cart.purchase() - return HttpResponseRedirect('/') -@login_required def add_course_to_cart(request, course_id): + if not request.user.is_authenticated(): + return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart')) cart = Order.get_cart_for_user(request.user) - # TODO: Catch 500 here for course that does not exist, period - PaidCourseRegistration.add_to_order(cart, course_id, 200) - return HttpResponse("Added") + if PaidCourseRegistration.part_of_order(cart, course_id): + return HttpResponseNotFound(_('The course {0} is already in your cart.'.format(course_id))) + if CourseEnrollment.objects.filter(user=request.user, course_id=course_id).exists(): + return HttpResponseNotFound(_('You are already registered in course {0}.'.format(course_id))) + try: + PaidCourseRegistration.add_to_order(cart, course_id) + except ItemNotFoundError: + return HttpResponseNotFound(_('The course you requested does not exist.')) + if request.method == 'GET': + return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) + return HttpResponse(_("Course added to cart.")) @login_required def show_cart(request): @@ -62,31 +65,23 @@ def remove_item(request): return HttpResponse('OK') @csrf_exempt -def postpay_accept_callback(request): +def postpay_callback(request): """ - Receives the POST-back from processor and performs the validation and displays a receipt - and does some other stuff - - HANDLES THE ACCEPT AND REVIEW CASES + Receives the POST-back from processor. + Mainly this calls the processor-specific code to check if the payment was accepted, and to record the order + if it was, and to generate an error page. + If successful this function should have the side effect of changing the "cart" into a full "order" in the DB. + The cart can then render a success page which links to receipt pages. + If unsuccessful the order will be left untouched and HTML messages giving more detailed error info will be + returned. """ - # TODO: Templates and logic for all error cases and the REVIEW CASE - params = request.POST.dict() - if verify(params): - try: - result = payment_accepted(params) - if result['accepted']: - # ACCEPTED CASE first - record_purchase(params, result['order']) - #render_receipt - return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) - else: - return HttpResponse("CC Processor has not accepted the payment.") - except CCProcessorWrongAmountException: - return HttpResponse("Charged the wrong amount, contact our user support") - except CCProcessorDataException: - return HttpResponse("Exception: the processor returned invalid data") + result = process_postpay_callback(request) + if result['success']: + return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: - return HttpResponse("There has been a communication problem blah blah. Not Validated") + return render_to_response('shoppingcart.processor_error.html', {'order':result['order'], + 'error_html': result['error_html']}) + def show_receipt(request, ordernum): """ @@ -107,7 +102,7 @@ def show_receipt(request, ordernum): 'order_items': order_items, 'any_refunds': any_refunds}) -def show_orders(request): +#def show_orders(request): """ Displays all orders of a user """ diff --git a/lms/templates/courseware/course_about.html b/lms/templates/courseware/course_about.html index e4a453133d..4d22e6959c 100644 --- a/lms/templates/courseware/course_about.html +++ b/lms/templates/courseware/course_about.html @@ -59,7 +59,6 @@ %endif - })(this) @@ -93,6 +92,7 @@ ${_("View Courseware")} %endif + %else: ${_("Register for {course.display_number_with_default}").format(course=course) | h} diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 677077ba2d..0754cac311 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -25,7 +25,7 @@ - + ${form_html} % else:

    ${_("You have selected no items for purchase.")}

    @@ -44,6 +44,10 @@ location.reload(true); }); }); + + $('#back_input').click(function(){ + history.back(); + }); }); diff --git a/lms/templates/shoppingcart/receipt.html b/lms/templates/shoppingcart/receipt.html index 01de3b3f83..0386b6b353 100644 --- a/lms/templates/shoppingcart/receipt.html +++ b/lms/templates/shoppingcart/receipt.html @@ -6,7 +6,11 @@ <%block name="title">${_("Receipt for Order")} ${order.id} - +% if notification is not UNDEFINED: +
    + ${notification} +
    +% endif

    ${_(settings.PLATFORM_NAME + " (" + settings.SITE_NAME + ")" + " Electronic Receipt")}

    From 461b4da349c0d67bf5754bf318f1dd0411bbe21f Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Fri, 9 Aug 2013 12:57:34 -0400 Subject: [PATCH 100/244] Add in new VerifiedCertificate order item --- .../0003_auto__add_verifiedcertificate.py | 111 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 36 +++++- lms/djangoapps/shoppingcart/tests.py | 31 +++-- lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 8 ++ 5 files changed, 177 insertions(+), 12 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py new file mode 100644 index 0000000000..25c0d46948 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py @@ -0,0 +1,111 @@ +# -*- 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 'VerifiedCertificate' + db.create_table('shoppingcart_verifiedcertificate', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('course_enrollment', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['student.CourseEnrollment'])), + )) + db.send_create_signal('shoppingcart', ['VerifiedCertificate']) + + + def backwards(self, orm): + # Deleting model 'VerifiedCertificate' + db.delete_table('shoppingcart_verifiedcertificate') + + + 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'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.verifiedcertificate': { + 'Meta': {'object_name': 'VerifiedCertificate', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + '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']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index acc0545ab7..42e6bc842a 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -217,4 +217,38 @@ class PaidCourseRegistration(OrderItem): # PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem ORDER_ITEM_SUBTYPES = { PaidCourseRegistration: 'paidcourseregistration', -} \ No newline at end of file + VerifiedCertificate: 'verifiedcertificate', +} + + +class VerifiedCertificate(OrderItem): + """ + This is an inventory item for purchasing verified certificates + """ + course_id = models.CharField(max_length=128, db_index=True) + course_enrollment = models.ForeignKey(CourseEnrollment) + + @classmethod + def add_to_order(cls, order, course_id, course_enrollment, cost, currency='usd'): + """ + Add a VerifiedCertificate item to an order + """ + # TODO: error checking + item, _created = cls.objects.get_or_create( + order=order, + user=order.user, + course_id=course_id, + course_enrollment=course_enrollment + ) + item.status = order.status + item.qty = 1 + item.unit_cost = cost + item.line_cost = cost + item.line_desc = "Verified Certificate for Course {0}".format(course_id) + item.currency = currency + item.save() + return item + + def purchased_callback(self): + # TODO: add code around putting student in the verified track + pass diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 501deb776c..55b5ae0141 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -1,16 +1,27 @@ """ -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. +Tests for the Shopping Cart """ +from factory import DjangoModelFactory from django.test import TestCase +from shoppingcart import models +from student.tests.factories import UserFactory -class SimpleTest(TestCase): - def test_basic_addition(self): - """ - Tests that 1 + 1 always equals 2. - """ - self.assertEqual(1 + 1, 2) +class OrderFactory(DjangoModelFactory): + FACTORY_FOR = models.Order + + +class OrderItem(DjangoModelFactory): + FACTORY_FOR = models.OrderItem + + +class OrderTest(TestCase): + def setUp(self): + self.user = UserFactory.create() + self.cart = OrderFactory.create(user=self.user, status='cart') + + def test_total_cost(self): + # add items to the order + for _ in xrange(5): + pass diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 892c66d5bb..1ec4f9402e 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -4,8 +4,9 @@ urlpatterns = patterns('shoppingcart.views', # nopep8 url(r'^$','show_cart'), url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), + url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', 'register_for_verified_cert'), url(r'^clear/$','clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^postpay_callback/$', 'postpay_callback'), #Both the ~accept and ~reject callback pages are handled here url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), -) \ No newline at end of file +) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 87df7eaf1b..718893069e 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -34,6 +34,14 @@ def add_course_to_cart(request, course_id): return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) return HttpResponse(_("Course added to cart.")) + +@login_required +def register_for_verified_cert(request, course_id): + cart = Order.get_cart_for_user(request.user) + enrollment, _completed = CourseEnrollment.objects.get_or_create(user=request.user, course_id=course_id) + VerifiedCertificate.add_to_order(cart, course_id, enrollment, 25) + return HttpResponse("Added") + @login_required def show_cart(request): cart = Order.get_cart_for_user(request.user) From 23a15aed57367192510da7e93e4d29b320ba9308 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 12 Aug 2013 12:18:26 -0400 Subject: [PATCH 101/244] Pull CyberSource values from environment variables when in a dev environment. --- lms/djangoapps/shoppingcart/models.py | 4 +++- lms/djangoapps/shoppingcart/views.py | 3 +-- lms/envs/dev.py | 7 +++++++ 3 files changed, 11 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 42e6bc842a..66e6dfca2e 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -229,11 +229,13 @@ class VerifiedCertificate(OrderItem): course_enrollment = models.ForeignKey(CourseEnrollment) @classmethod - def add_to_order(cls, order, course_id, course_enrollment, cost, currency='usd'): + def add_to_order(cls, order, course_id, cost, currency='usd'): """ Add a VerifiedCertificate item to an order """ + # TODO: add the basic enrollment # TODO: error checking + course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode="verified") item, _created = cls.objects.get_or_create( order=order, user=order.user, diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 718893069e..f6ca5d0837 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -38,8 +38,7 @@ def add_course_to_cart(request, course_id): @login_required def register_for_verified_cert(request, course_id): cart = Order.get_cart_for_user(request.user) - enrollment, _completed = CourseEnrollment.objects.get_or_create(user=request.user, course_id=course_id) - VerifiedCertificate.add_to_order(cart, course_id, enrollment, 25) + VerifiedCertificate.add_to_order(cart, course_id, 30) return HttpResponse("Added") @login_required diff --git a/lms/envs/dev.py b/lms/envs/dev.py index d47c7bf82d..9150adb3a3 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -258,6 +258,13 @@ SEGMENT_IO_LMS_KEY = os.environ.get('SEGMENT_IO_LMS_KEY') if SEGMENT_IO_LMS_KEY: MITX_FEATURES['SEGMENT_IO_LMS'] = True +###################### Payment ##############################3 + +CC_PROCESSOR['CyberSource']['SHARED_SECRET'] = os.environ.get('CYBERSOURCE_SHARED_SECRET', '') +CC_PROCESSOR['CyberSource']['MERCHANT_ID'] = os.environ.get('CYBERSOURCE_MERCHANT_ID', '') +CC_PROCESSOR['CyberSource']['SERIAL_NUMBER'] = os.environ.get('CYBERSOURCE_SERIAL_NUMBER', '') +CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_PURCHASE_ENDPOINT', '') + ########################## USER API ######################## EDX_API_KEY = None From 10c96cb897152c83fd348ea1bf6d33e82c540d2d Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 10:12:06 -0400 Subject: [PATCH 102/244] Remove enrollment_cost from course_module --- lms/djangoapps/shoppingcart/models.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 66e6dfca2e..eb4e9f578d 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -180,10 +180,6 @@ class PaidCourseRegistration(OrderItem): item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status item.qty = 1 - if cost is None: - cost = course.enrollment_cost['cost'] - if currency is None: - currency = course.enrollment_cost['currency'] item.unit_cost = cost item.line_cost = cost item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) From 4070984cac27530e964a60bd4e03f352e084bf00 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 13:36:13 -0400 Subject: [PATCH 103/244] Some cleanup fixes to get verified certs working. --- lms/djangoapps/shoppingcart/models.py | 32 ++++++++++++++++----------- lms/djangoapps/shoppingcart/views.py | 9 ++------ 2 files changed, 21 insertions(+), 20 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index eb4e9f578d..54e7a33889 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -61,6 +61,12 @@ class Order(models.Model): else: return items[0].currency + def clear(self): + """ + Clear out all the items in the cart + """ + self.orderitem_set.all().delete() + def purchase(self, first='', last='', street1='', street2='', city='', state='', postalcode='', country='', ccnum='', cardtype='', processor_reply_dump=''): """ @@ -208,15 +214,6 @@ class PaidCourseRegistration(OrderItem): "run:{0}".format(run)]) -# Each entry is a dictionary of ModelName: 'lower_case_model_name' -# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for -# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem -ORDER_ITEM_SUBTYPES = { - PaidCourseRegistration: 'paidcourseregistration', - VerifiedCertificate: 'verifiedcertificate', -} - - class VerifiedCertificate(OrderItem): """ This is an inventory item for purchasing verified certificates @@ -229,8 +226,6 @@ class VerifiedCertificate(OrderItem): """ Add a VerifiedCertificate item to an order """ - # TODO: add the basic enrollment - # TODO: error checking course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode="verified") item, _created = cls.objects.get_or_create( order=order, @@ -248,5 +243,16 @@ class VerifiedCertificate(OrderItem): return item def purchased_callback(self): - # TODO: add code around putting student in the verified track - pass + """ + When purchase goes through, activate the course enrollment + """ + self.course_enrollment.activate() + + +# Each entry is a dictionary of ModelName: 'lower_case_model_name' +# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for +# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem +ORDER_ITEM_SUBTYPES = { + PaidCourseRegistration: 'paidcourseregistration', + VerifiedCertificate: 'verifiedcertificate', +} diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index f6ca5d0837..91dff59aed 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -57,7 +57,7 @@ def show_cart(request): @login_required def clear_cart(request): cart = Order.get_cart_for_user(request.user) - cart.orderitem_set.all().delete() + cart.clear() return HttpResponse('Cleared') @login_required @@ -89,7 +89,7 @@ def postpay_callback(request): return render_to_response('shoppingcart.processor_error.html', {'order':result['order'], 'error_html': result['error_html']}) - +@login_required def show_receipt(request, ordernum): """ Displays a receipt for a particular order. @@ -108,8 +108,3 @@ def show_receipt(request, ordernum): return render_to_response('shoppingcart/receipt.html', {'order': order, 'order_items': order_items, 'any_refunds': any_refunds}) - -#def show_orders(request): - """ - Displays all orders of a user - """ From 5a90a6590f0781694134edb5b96dfae97dded5d1 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 14:18:46 -0400 Subject: [PATCH 104/244] Put shopping cart views behind flags --- lms/djangoapps/shoppingcart/urls.py | 25 ++++++++++++++++++------- lms/envs/common.py | 3 +++ lms/envs/dev.py | 1 + 3 files changed, 22 insertions(+), 7 deletions(-) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 1ec4f9402e..7893d29c20 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -1,12 +1,23 @@ from django.conf.urls import patterns, include, url +from django.conf import settings urlpatterns = patterns('shoppingcart.views', # nopep8 - url(r'^$','show_cart'), - url(r'^(?P[^/]+/[^/]+/[^/]+)/$','test'), - url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$','add_course_to_cart'), - url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', 'register_for_verified_cert'), - url(r'^clear/$','clear_cart'), - url(r'^remove_item/$', 'remove_item'), - url(r'^postpay_callback/$', 'postpay_callback'), #Both the ~accept and ~reject callback pages are handled here + url(r'^postpay_callback/$', 'postpay_callback'), # Both the ~accept and ~reject callback pages are handled here url(r'^receipt/(?P[0-9]*)/$', 'show_receipt'), ) +if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: + urlpatterns += patterns( + 'shoppingcart.views', + url(r'^$', 'show_cart'), + url(r'^clear/$', 'clear_cart'), + url(r'^remove_item/$', 'remove_item'), + ) + +if settings.DEBUG: + urlpatterns += patterns( + 'shoppingcart.views', + url(r'^(?P[^/]+/[^/]+/[^/]+)/$', 'test'), + url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart'), + url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', + 'register_for_verified_cert'), + ) diff --git a/lms/envs/common.py b/lms/envs/common.py index 7e4d23f065..c5b174b077 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -154,6 +154,9 @@ MITX_FEATURES = { # Toggle to enable chat availability (configured on a per-course # basis in Studio) 'ENABLE_CHAT': False, + + # Toggle the availability of the shopping cart page + 'ENABLE_SHOPPING_CART': False } # Used for A/B testing diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 9150adb3a3..cc78dcc6ca 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -30,6 +30,7 @@ MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True FEEDBACK_SUBMISSION_EMAIL = "dummy@example.com" From 84628e105ff4c53fdd23dd72abbf6b55405894aa Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 19 Aug 2013 15:22:17 -0400 Subject: [PATCH 105/244] Start building tests --- lms/djangoapps/shoppingcart/tests.py | 20 ++++++++++++++------ 1 file changed, 14 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 55b5ae0141..521b9e594e 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -4,24 +4,32 @@ Tests for the Shopping Cart from factory import DjangoModelFactory from django.test import TestCase -from shoppingcart import models +from shoppingcart.models import Order, VerifiedCertificate from student.tests.factories import UserFactory class OrderFactory(DjangoModelFactory): - FACTORY_FOR = models.Order + FACTORY_FOR = Order -class OrderItem(DjangoModelFactory): - FACTORY_FOR = models.OrderItem +class VerifiedCertificateFactory(DjangoModelFactory): + FACTORY_FOR = VerifiedCertificate class OrderTest(TestCase): def setUp(self): self.user = UserFactory.create() self.cart = OrderFactory.create(user=self.user, status='cart') + self.course_id = "test/course" + + def test_add_item_to_cart(self): + pass def test_total_cost(self): # add items to the order - for _ in xrange(5): - pass + cost = 30 + iterations = 5 + for _ in xrange(iterations): + VerifiedCertificate.add_to_order(self.cart, self.user, self.course_id, cost) + self.assertEquals(self.cart.total_cost, cost * iterations) + From 6f3e83b86cd93f1df91c2c7cffbd7b010bc151ab Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Tue, 20 Aug 2013 15:28:19 -0400 Subject: [PATCH 106/244] Major cleanup work on ShoppingCart models * Make currency a property of the Order (for validation purposes) * Remove null=True from Char fields * Use InheritanceManager for subclassing OrderItem * Change VerifiedCertificate to better handle some new use cases * Cleaned out old migrations * Tests! --- .../shoppingcart/migrations/0001_initial.py | 64 +++++- ...d_field_order_bill_to_first__add_field_.py | 183 ------------------ ...003_auto__add_field_order_bill_to_state.py | 96 --------- .../0003_auto__add_verifiedcertificate.py | 111 ----------- lms/djangoapps/shoppingcart/models.py | 166 ++++++++-------- lms/djangoapps/shoppingcart/tests.py | 88 +++++++-- lms/djangoapps/shoppingcart/views.py | 2 +- lms/envs/common.py | 6 +- requirements/edx/base.txt | 1 + 9 files changed, 219 insertions(+), 498 deletions(-) delete mode 100644 lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py delete mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py delete mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py diff --git a/lms/djangoapps/shoppingcart/migrations/0001_initial.py b/lms/djangoapps/shoppingcart/migrations/0001_initial.py index 779eccc94d..ea6a250f77 100644 --- a/lms/djangoapps/shoppingcart/migrations/0001_initial.py +++ b/lms/djangoapps/shoppingcart/migrations/0001_initial.py @@ -12,9 +12,20 @@ class Migration(SchemaMigration): db.create_table('shoppingcart_order', ( ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), + ('currency', self.gf('django.db.models.fields.CharField')(default='usd', max_length=8)), ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), - ('nonce', self.gf('django.db.models.fields.CharField')(max_length=128)), ('purchase_time', self.gf('django.db.models.fields.DateTimeField')(null=True, blank=True)), + ('bill_to_first', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_last', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_street1', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('bill_to_street2', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('bill_to_city', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_state', self.gf('django.db.models.fields.CharField')(max_length=8, blank=True)), + ('bill_to_postalcode', self.gf('django.db.models.fields.CharField')(max_length=16, blank=True)), + ('bill_to_country', self.gf('django.db.models.fields.CharField')(max_length=64, blank=True)), + ('bill_to_ccnum', self.gf('django.db.models.fields.CharField')(max_length=8, blank=True)), + ('bill_to_cardtype', self.gf('django.db.models.fields.CharField')(max_length=32, blank=True)), + ('processor_reply_dump', self.gf('django.db.models.fields.TextField')(blank=True)), )) db.send_create_signal('shoppingcart', ['Order']) @@ -25,9 +36,10 @@ class Migration(SchemaMigration): ('user', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'])), ('status', self.gf('django.db.models.fields.CharField')(default='cart', max_length=32)), ('qty', self.gf('django.db.models.fields.IntegerField')(default=1)), - ('unit_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)), - ('line_cost', self.gf('django.db.models.fields.FloatField')(default=0.0)), + ('unit_cost', self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2)), + ('line_cost', self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2)), ('line_desc', self.gf('django.db.models.fields.CharField')(default='Misc. Item', max_length=1024)), + ('currency', self.gf('django.db.models.fields.CharField')(default='usd', max_length=8)), )) db.send_create_signal('shoppingcart', ['OrderItem']) @@ -38,6 +50,15 @@ class Migration(SchemaMigration): )) db.send_create_signal('shoppingcart', ['PaidCourseRegistration']) + # Adding model 'CertificateItem' + db.create_table('shoppingcart_certificateitem', ( + ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('course_enrollment', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['student.CourseEnrollment'])), + ('mode', self.gf('django.db.models.fields.SlugField')(max_length=50)), + )) + db.send_create_signal('shoppingcart', ['CertificateItem']) + def backwards(self, orm): # Deleting model 'Order' @@ -49,6 +70,9 @@ class Migration(SchemaMigration): # Deleting model 'PaidCourseRegistration' db.delete_table('shoppingcart_paidcourseregistration') + # Deleting model 'CertificateItem' + db.delete_table('shoppingcart_certificateitem') + models = { 'auth.group': { @@ -87,29 +111,57 @@ class Migration(SchemaMigration): 'model': ('django.db.models.fields.CharField', [], {'max_length': '100'}), 'name': ('django.db.models.fields.CharField', [], {'max_length': '100'}) }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, 'shoppingcart.order': { 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'nonce': ('django.db.models.fields.CharField', [], {'max_length': '128'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) }, 'shoppingcart.orderitem': { 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'line_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) }, 'shoppingcart.paidcourseregistration': { 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", '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'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) } } diff --git a/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py deleted file mode 100644 index 940116f7b8..0000000000 --- a/lms/djangoapps/shoppingcart/migrations/0002_auto__del_field_order_nonce__add_field_order_bill_to_first__add_field_.py +++ /dev/null @@ -1,183 +0,0 @@ -# -*- 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): - # Deleting field 'Order.nonce' - db.delete_column('shoppingcart_order', 'nonce') - - # Adding field 'Order.bill_to_first' - db.add_column('shoppingcart_order', 'bill_to_first', - self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_last' - db.add_column('shoppingcart_order', 'bill_to_last', - self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_street1' - db.add_column('shoppingcart_order', 'bill_to_street1', - self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_street2' - db.add_column('shoppingcart_order', 'bill_to_street2', - self.gf('django.db.models.fields.CharField')(max_length=128, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_city' - db.add_column('shoppingcart_order', 'bill_to_city', - self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_postalcode' - db.add_column('shoppingcart_order', 'bill_to_postalcode', - self.gf('django.db.models.fields.CharField')(max_length=16, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_country' - db.add_column('shoppingcart_order', 'bill_to_country', - self.gf('django.db.models.fields.CharField')(max_length=64, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_ccnum' - db.add_column('shoppingcart_order', 'bill_to_ccnum', - self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.bill_to_cardtype' - db.add_column('shoppingcart_order', 'bill_to_cardtype', - self.gf('django.db.models.fields.CharField')(max_length=32, null=True, blank=True), - keep_default=False) - - # Adding field 'Order.processor_reply_dump' - db.add_column('shoppingcart_order', 'processor_reply_dump', - self.gf('django.db.models.fields.TextField')(null=True, blank=True), - keep_default=False) - - # Adding field 'OrderItem.currency' - db.add_column('shoppingcart_orderitem', 'currency', - self.gf('django.db.models.fields.CharField')(default='usd', max_length=8), - keep_default=False) - - - def backwards(self, orm): - # Adding field 'Order.nonce' - db.add_column('shoppingcart_order', 'nonce', - self.gf('django.db.models.fields.CharField')(default='defaultNonce', max_length=128), - keep_default=False) - - # Deleting field 'Order.bill_to_first' - db.delete_column('shoppingcart_order', 'bill_to_first') - - # Deleting field 'Order.bill_to_last' - db.delete_column('shoppingcart_order', 'bill_to_last') - - # Deleting field 'Order.bill_to_street1' - db.delete_column('shoppingcart_order', 'bill_to_street1') - - # Deleting field 'Order.bill_to_street2' - db.delete_column('shoppingcart_order', 'bill_to_street2') - - # Deleting field 'Order.bill_to_city' - db.delete_column('shoppingcart_order', 'bill_to_city') - - # Deleting field 'Order.bill_to_postalcode' - db.delete_column('shoppingcart_order', 'bill_to_postalcode') - - # Deleting field 'Order.bill_to_country' - db.delete_column('shoppingcart_order', 'bill_to_country') - - # Deleting field 'Order.bill_to_ccnum' - db.delete_column('shoppingcart_order', 'bill_to_ccnum') - - # Deleting field 'Order.bill_to_cardtype' - db.delete_column('shoppingcart_order', 'bill_to_cardtype') - - # Deleting field 'Order.processor_reply_dump' - db.delete_column('shoppingcart_order', 'processor_reply_dump') - - # Deleting field 'OrderItem.currency' - db.delete_column('shoppingcart_orderitem', 'currency') - - - 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'}) - }, - 'shoppingcart.order': { - 'Meta': {'object_name': 'Order'}, - 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), - 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), - 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), - 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.orderitem': { - 'Meta': {'object_name': 'OrderItem'}, - 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), - 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), - 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.paidcourseregistration': { - 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - } - } - - complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py deleted file mode 100644 index 85923794b6..0000000000 --- a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_field_order_bill_to_state.py +++ /dev/null @@ -1,96 +0,0 @@ -# -*- 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 field 'Order.bill_to_state' - db.add_column('shoppingcart_order', 'bill_to_state', - self.gf('django.db.models.fields.CharField')(max_length=8, null=True, blank=True), - keep_default=False) - - - def backwards(self, orm): - # Deleting field 'Order.bill_to_state' - db.delete_column('shoppingcart_order', 'bill_to_state') - - - 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'}) - }, - 'shoppingcart.order': { - 'Meta': {'object_name': 'Order'}, - 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), - 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), - 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), - 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), - 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.orderitem': { - 'Meta': {'object_name': 'OrderItem'}, - 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), - 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), - 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.paidcourseregistration': { - 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - } - } - - complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py deleted file mode 100644 index 25c0d46948..0000000000 --- a/lms/djangoapps/shoppingcart/migrations/0003_auto__add_verifiedcertificate.py +++ /dev/null @@ -1,111 +0,0 @@ -# -*- 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 'VerifiedCertificate' - db.create_table('shoppingcart_verifiedcertificate', ( - ('orderitem_ptr', self.gf('django.db.models.fields.related.OneToOneField')(to=orm['shoppingcart.OrderItem'], unique=True, primary_key=True)), - ('course_id', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), - ('course_enrollment', self.gf('django.db.models.fields.related.ForeignKey')(to=orm['student.CourseEnrollment'])), - )) - db.send_create_signal('shoppingcart', ['VerifiedCertificate']) - - - def backwards(self, orm): - # Deleting model 'VerifiedCertificate' - db.delete_table('shoppingcart_verifiedcertificate') - - - 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'}) - }, - 'shoppingcart.order': { - 'Meta': {'object_name': 'Order'}, - 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'null': 'True', 'blank': 'True'}), - 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'null': 'True', 'blank': 'True'}), - 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'null': 'True', 'blank': 'True'}), - 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'null': 'True', 'blank': 'True'}), - 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'null': 'True', 'blank': 'True'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), - 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.orderitem': { - 'Meta': {'object_name': 'OrderItem'}, - 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), - 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), - 'line_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), - 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), - 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), - 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), - 'unit_cost': ('django.db.models.fields.FloatField', [], {'default': '0.0'}), - 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) - }, - 'shoppingcart.paidcourseregistration': { - 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - }, - 'shoppingcart.verifiedcertificate': { - 'Meta': {'object_name': 'VerifiedCertificate', '_ormbases': ['shoppingcart.OrderItem']}, - 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), - 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), - 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) - }, - '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']"}) - } - } - - complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 54e7a33889..3a4039c9e1 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1,21 +1,27 @@ import pytz import logging -from datetime import datetime +from datetime import datetime from django.db import models from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User -from courseware.courses import course_image_url, get_course_about_section +from django.utils.translation import ugettext as _ +from model_utils.managers import InheritanceManager +from courseware.courses import get_course_about_section from student.views import course_from_id -from student.models import CourseEnrollmentAllowed, CourseEnrollment +from student.models import CourseEnrollment from statsd import statsd log = logging.getLogger("shoppingcart") +class InvalidCartItem(Exception): + pass + ORDER_STATUSES = ( ('cart', 'cart'), ('purchased', 'purchased'), ('refunded', 'refunded'), # Not used for now ) + class Order(models.Model): """ This is the model for an order. Before purchase, an Order and its related OrderItems are used @@ -23,43 +29,40 @@ class Order(models.Model): FOR ANY USER, THERE SHOULD ONLY EVER BE ZERO OR ONE ORDER WITH STATUS='cart'. """ user = models.ForeignKey(User, db_index=True) + currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) purchase_time = models.DateTimeField(null=True, blank=True) # Now we store data needed to generate a reasonable receipt # These fields only make sense after the purchase - bill_to_first = models.CharField(max_length=64, null=True, blank=True) - bill_to_last = models.CharField(max_length=64, null=True, blank=True) - bill_to_street1 = models.CharField(max_length=128, null=True, blank=True) - bill_to_street2 = models.CharField(max_length=128, null=True, blank=True) - bill_to_city = models.CharField(max_length=64, null=True, blank=True) - bill_to_state = models.CharField(max_length=8, null=True, blank=True) - bill_to_postalcode = models.CharField(max_length=16, null=True, blank=True) - bill_to_country = models.CharField(max_length=64, null=True, blank=True) - bill_to_ccnum = models.CharField(max_length=8, null=True, blank=True) # last 4 digits - bill_to_cardtype = models.CharField(max_length=32, null=True, blank=True) + bill_to_first = models.CharField(max_length=64, blank=True) + bill_to_last = models.CharField(max_length=64, blank=True) + bill_to_street1 = models.CharField(max_length=128, blank=True) + bill_to_street2 = models.CharField(max_length=128, blank=True) + bill_to_city = models.CharField(max_length=64, blank=True) + bill_to_state = models.CharField(max_length=8, blank=True) + bill_to_postalcode = models.CharField(max_length=16, blank=True) + bill_to_country = models.CharField(max_length=64, blank=True) + bill_to_ccnum = models.CharField(max_length=8, blank=True) # last 4 digits + bill_to_cardtype = models.CharField(max_length=32, blank=True) # a JSON dump of the CC processor response, for completeness - processor_reply_dump = models.TextField(null=True, blank=True) + processor_reply_dump = models.TextField(blank=True) @classmethod def get_cart_for_user(cls, user): """ Always use this to preserve the property that at most 1 order per user has status = 'cart' """ - order, created = cls.objects.get_or_create(user=user, status='cart') - return order + # find the newest element in the db + try: + cart_order = cls.objects.filter(user=user, status='cart').order_by('-id')[:1].get() + except ObjectDoesNotExist: + # if nothing exists in the database, create a new cart + cart_order, _created = cls.objects.get_or_create(user=user, status='cart') + return cart_order @property def total_cost(self): - return sum([i.line_cost for i in self.orderitem_set.filter(status=self.status)]) - - @property - def currency(self): - """Assumes that all cart items are in the same currency""" - items = self.orderitem_set.all() - if not items: - return 'usd' - else: - return items[0].currency + return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) def clear(self): """ @@ -87,7 +90,10 @@ class Order(models.Model): self.bill_to_cardtype = cardtype self.processor_reply_dump = processor_reply_dump self.save() - for item in self.orderitem_set.all(): + # this should return all of the objects with the correct types of the + # subclasses + orderitems = OrderItem.objects.filter(order=self).select_subclasses() + for item in orderitems: item.status = 'purchased' item.purchased_callback() item.save() @@ -101,60 +107,38 @@ class OrderItem(models.Model): Each implementation of OrderItem should provide its own purchased_callback as a method. """ + objects = InheritanceManager() order = models.ForeignKey(Order, db_index=True) # this is denormalized, but convenient for SQL queries for reports, etc. user should always be = order.user user = models.ForeignKey(User, db_index=True) # this is denormalized, but convenient for SQL queries for reports, etc. status should always be = order.status status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) qty = models.IntegerField(default=1) - unit_cost = models.FloatField(default=0.0) - line_cost = models.FloatField(default=0.0) # qty * unit_cost + unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) + line_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) # qty * unit_cost line_desc = models.CharField(default="Misc. Item", max_length=1024) - currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes + currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes - def add_to_order(self, *args, **kwargs): + @classmethod + def add_to_order(cls, *args, **kwargs): """ A suggested convenience function for subclasses. """ - raise NotImplementedError + # this is a validation step to verify that the currency of the item we + # are adding is the same as the currency of the order we are adding it + # to + if isinstance(args[0], Order): + currency = kwargs['currency'] if 'currency' in kwargs else 'usd' + order = args[0] + if order.currency != currency and order.orderitem_set.count() > 0: + raise InvalidCartItem(_("Trying to add a different currency into the cart")) def purchased_callback(self): """ This is called on each inventory item in the shopping cart when the purchase goes through. - - NOTE: We want to provide facilities for doing something like - for item in OrderItem.objects.filter(order_id=order_id): - item.purchased_callback() - - Unfortunately the QuerySet used determines the class to be OrderItem, and not its most specific - subclasses. That means this parent class implementation of purchased_callback needs to act as - a dispatcher to call the callback the proper subclasses, and as such it needs to know about all - possible subclasses. - So keep ORDER_ITEM_SUBTYPES up-to-date """ - for cls, lc_classname in ORDER_ITEM_SUBTYPES.iteritems(): - try: - #Uses https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance to test subclass - sub_instance = getattr(self,lc_classname) - sub_instance.purchased_callback() - except (ObjectDoesNotExist, AttributeError): - log.exception('Cannot call purchase_callback on non-existent subclass attribute {0} of OrderItem'\ - .format(lc_classname)) - pass - - def is_of_subtype(self, cls): - """ - Checks if self is also a type of cls, in addition to being an OrderItem - Uses https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance to test for subclass - """ - if cls not in ORDER_ITEM_SUBTYPES: - return False - try: - getattr(self, ORDER_ITEM_SUBTYPES[cls]) - return True - except (ObjectDoesNotExist, AttributeError): - return False + raise NotImplementedError class PaidCourseRegistration(OrderItem): @@ -180,6 +164,8 @@ class PaidCourseRegistration(OrderItem): Returns the order item """ + super(PaidCourseRegistration, cls).add_to_order(order, course_id, cost, currency=currency) + # TODO: Possibly add checking for whether student is already enrolled in course course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to # throw errors if it doesn't @@ -190,6 +176,8 @@ class PaidCourseRegistration(OrderItem): item.line_cost = cost item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) item.currency = currency + order.currency = currency + order.save() item.save() return item @@ -214,45 +202,61 @@ class PaidCourseRegistration(OrderItem): "run:{0}".format(run)]) -class VerifiedCertificate(OrderItem): +class CertificateItem(OrderItem): """ - This is an inventory item for purchasing verified certificates + This is an inventory item for purchasing certificates """ course_id = models.CharField(max_length=128, db_index=True) course_enrollment = models.ForeignKey(CourseEnrollment) + mode = models.SlugField() @classmethod - def add_to_order(cls, order, course_id, cost, currency='usd'): + def add_to_order(cls, order, course_id, cost, mode, currency='usd'): """ - Add a VerifiedCertificate item to an order + Add a CertificateItem to an order + + Returns the CertificateItem object after saving + + `order` - an order that this item should be added to, generally the cart order + `course_id` - the course that we would like to purchase as a CertificateItem + `cost` - the amount the user will be paying for this CertificateItem + `mode` - the course mode that this certificate is going to be issued for + + This item also creates a new enrollment if none exists for this user and this course. + + Example Usage: + cart = Order.get_cart_for_user(user) + CertificateItem.add_to_order(cart, 'edX/Test101/2013_Fall', 30, 'verified') + """ - course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode="verified") + super(CertificateItem, cls).add_to_order(order, course_id, cost, currency=currency) + try: + course_enrollment = CourseEnrollment.objects.get(user=order.user, course_id=course_id) + except ObjectDoesNotExist: + course_enrollment = CourseEnrollment.create_enrollment(order.user, course_id, mode=mode) item, _created = cls.objects.get_or_create( order=order, user=order.user, course_id=course_id, - course_enrollment=course_enrollment + course_enrollment=course_enrollment, + mode=mode ) item.status = order.status item.qty = 1 item.unit_cost = cost item.line_cost = cost - item.line_desc = "Verified Certificate for Course {0}".format(course_id) + item.line_desc = "{mode} certificate for course {course_id}".format(mode=item.mode, + course_id=course_id) item.currency = currency + order.currency = currency + order.save() item.save() return item def purchased_callback(self): """ - When purchase goes through, activate the course enrollment + When purchase goes through, activate and update the course enrollment for the correct mode """ + self.course_enrollment.mode = self.mode + self.course_enrollment.save() self.course_enrollment.activate() - - -# Each entry is a dictionary of ModelName: 'lower_case_model_name' -# See https://docs.djangoproject.com/en/1.4/topics/db/models/#multi-table-inheritance for -# PLEASE KEEP THIS LIST UP_TO_DATE WITH THE SUBCLASSES OF OrderItem -ORDER_ITEM_SUBTYPES = { - PaidCourseRegistration: 'paidcourseregistration', - VerifiedCertificate: 'verifiedcertificate', -} diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 521b9e594e..61a10f2f75 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -4,32 +4,86 @@ Tests for the Shopping Cart from factory import DjangoModelFactory from django.test import TestCase -from shoppingcart.models import Order, VerifiedCertificate +from shoppingcart.models import Order, CertificateItem, InvalidCartItem from student.tests.factories import UserFactory - - -class OrderFactory(DjangoModelFactory): - FACTORY_FOR = Order - - -class VerifiedCertificateFactory(DjangoModelFactory): - FACTORY_FOR = VerifiedCertificate +from student.models import CourseEnrollment class OrderTest(TestCase): def setUp(self): self.user = UserFactory.create() - self.cart = OrderFactory.create(user=self.user, status='cart') self.course_id = "test/course" + self.cost = 40 - def test_add_item_to_cart(self): - pass + def test_get_cart_for_user(self): + # create a cart + cart = Order.get_cart_for_user(user=self.user) + # add something to it + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + # should return the same cart + cart2 = Order.get_cart_for_user(user=self.user) + self.assertEquals(cart2.orderitem_set.count(), 1) + + def test_cart_clear(self): + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + CertificateItem.add_to_order(cart, 'test/course1', self.cost, 'verified') + self.assertEquals(cart.orderitem_set.count(), 2) + cart.clear() + self.assertEquals(cart.orderitem_set.count(), 0) + + def test_add_item_to_cart_currency_match(self): + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified', currency='eur') + # verify that a new item has been added + self.assertEquals(cart.orderitem_set.count(), 1) + # verify that the cart's currency was updated + self.assertEquals(cart.currency, 'eur') + with self.assertRaises(InvalidCartItem): + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified', currency='usd') + # assert that this item did not get added to the cart + self.assertEquals(cart.orderitem_set.count(), 1) def test_total_cost(self): + cart = Order.get_cart_for_user(user=self.user) # add items to the order - cost = 30 - iterations = 5 - for _ in xrange(iterations): - VerifiedCertificate.add_to_order(self.cart, self.user, self.course_id, cost) - self.assertEquals(self.cart.total_cost, cost * iterations) + course_costs = [('test/course1', 30), + ('test/course2', 40), + ('test/course3', 10), + ('test/course4', 20)] + for course, cost in course_costs: + CertificateItem.add_to_order(cart, course, cost, 'verified') + self.assertEquals(cart.orderitem_set.count(), len(course_costs)) + self.assertEquals(cart.total_cost, sum(cost for _course, cost in course_costs)) + def test_purchase(self): + # This test is for testing the subclassing functionality of OrderItem, but in + # order to do this, we end up testing the specific functionality of + # CertificateItem, which is not quite good unit test form. Sorry. + cart = Order.get_cart_for_user(user=self.user) + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + # course enrollment object should be created but still inactive + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + cart.purchase() + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + +class CertificateItemTest(TestCase): + """ + Tests for verifying specific CertificateItem functionality + """ + def setUp(self): + self.user = UserFactory.create() + self.course_id = "test/course" + self.cost = 40 + + def test_existing_enrollment(self): + CourseEnrollment.enroll(self.user, self.course_id) + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + # verify that we are still enrolled + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + cart.purchase() + enrollment = CourseEnrollment.objects.get(user=self.user, course_id=self.course_id) + self.assertEquals(enrollment.mode, u'verified') diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 91dff59aed..bdf8eb317f 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -38,7 +38,7 @@ def add_course_to_cart(request, course_id): @login_required def register_for_verified_cert(request, course_id): cart = Order.get_cart_for_user(request.user) - VerifiedCertificate.add_to_order(cart, course_id, 30) + CertificateItem.add_to_order(cart, course_id, 30, 'verified') return HttpResponse("Added") @login_required diff --git a/lms/envs/common.py b/lms/envs/common.py index c5b174b077..8181f97789 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -438,10 +438,10 @@ ZENDESK_API_KEY = None PAYMENT_SUPPORT_EMAIL = 'payment@edx.org' ##### Using cybersource by default ##### CC_PROCESSOR = { - 'CyberSource' : { + 'CyberSource': { 'SHARED_SECRET': '', - 'MERCHANT_ID' : '', - 'SERIAL_NUMBER' : '', + 'MERCHANT_ID': '', + 'SERIAL_NUMBER': '', 'ORDERPAGE_VERSION': '7', 'PURCHASE_ENDPOINT': '', } diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9179315797..d700aaa195 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -53,6 +53,7 @@ South==0.7.6 sympy==0.7.1 xmltodict==0.4.1 django-ratelimit-backend==0.6 +django-model-utils==1.4.0 # Used for debugging ipython==0.13.1 From 5fbb12cb61ad4eec67c3d767b62553a1c58c39de Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 13:20:06 -0700 Subject: [PATCH 107/244] make PaidCourseRegistration mode aware --- common/djangoapps/course_modes/models.py | 15 ++++++++ common/djangoapps/course_modes/tests.py | 3 ++ lms/djangoapps/shoppingcart/exceptions.py | 5 +++ lms/djangoapps/shoppingcart/models.py | 35 ++++++++++++++----- .../shoppingcart/processors/exceptions.py | 3 +- lms/envs/dev.py | 4 +++ 6 files changed, 55 insertions(+), 10 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/exceptions.py diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 561c078b3b..3d1c6f0563 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -51,3 +51,18 @@ class CourseMode(models.Model): if not modes: modes = [cls.DEFAULT_MODE] return modes + + @classmethod + def mode_for_course(cls, course_id, mode_slug): + """ + Returns the mode for the course corresponding to mode_slug. + + If this particular mode is not set for the course, returns None + """ + modes = cls.modes_for_course(course_id) + + matched = filter(lambda m: m.slug == mode_slug, modes) + if matched: + return matched[0] + else: + return None diff --git a/common/djangoapps/course_modes/tests.py b/common/djangoapps/course_modes/tests.py index 907797bf17..1fba5ca197 100644 --- a/common/djangoapps/course_modes/tests.py +++ b/common/djangoapps/course_modes/tests.py @@ -60,3 +60,6 @@ class CourseModeModelTest(TestCase): modes = CourseMode.modes_for_course(self.course_id) self.assertEqual(modes, set_modes) + self.assertEqual(mode1, CourseMode.mode_for_course(self.course_id, u'honor')) + self.assertEqual(mode2, CourseMode.mode_for_course(self.course_id, u'verified')) + self.assertIsNone(CourseMode.mode_for_course(self.course_id, 'DNE')) diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py new file mode 100644 index 0000000000..fdfb9ccdb9 --- /dev/null +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -0,0 +1,5 @@ +class PaymentException(Exception): + pass + +class PurchasedCallbackException(PaymentException): + pass \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 3a4039c9e1..7c73adbd9a 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -6,10 +6,17 @@ from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User from django.utils.translation import ugettext as _ from model_utils.managers import InheritanceManager -from courseware.courses import get_course_about_section +from courseware.courses import course_image_url, get_course_about_section + +from xmodule.modulestore.django import modulestore +from xmodule.course_module import CourseDescriptor + +from course_modes.models import CourseMode from student.views import course_from_id from student.models import CourseEnrollment from statsd import statsd +from .exceptions import * + log = logging.getLogger("shoppingcart") class InvalidCartItem(Exception): @@ -157,7 +164,7 @@ class PaidCourseRegistration(OrderItem): if item.is_of_subtype(PaidCourseRegistration)] @classmethod - def add_to_order(cls, order, course_id, cost=None, currency=None): + def add_to_order(cls, order, course_id, mode_slug=None, cost=None, currency=None): """ A standardized way to create these objects, with sensible defaults filled in. Will update the cost if called on an order that already carries the course. @@ -171,10 +178,21 @@ class PaidCourseRegistration(OrderItem): # throw errors if it doesn't item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status + if not mode_slug: + mode_slug = CourseMode.DEFAULT_MODE.slug + ### Get this course_mode + course_mode = CourseMode.mode_for_course(course_id, mode_slug) + if not course_mode: + course_mode = CourseMode.DEFAULT_MODE + if not cost: + cost = course_mode.min_price + if not currency: + currency = course_mode.currency item.qty = 1 item.unit_cost = cost item.line_cost = cost - item.line_desc = 'Registration for Course: {0}'.format(get_course_about_section(course, "title")) + item.line_desc = 'Registration for Course: {0}. Mode: {1}'.format(get_course_about_section(course, "title"), + course_mode.name) item.currency = currency order.currency = currency order.save() @@ -188,11 +206,12 @@ class PaidCourseRegistration(OrderItem): CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment would in fact be quite silly since there's a clear back door. """ - course = course_from_id(self.course_id) # actually fetch the course to make sure it exists, use this to - # throw errors if it doesn't - # use get_or_create here to gracefully handle case where the user is already enrolled in the course, for - # whatever reason. - CourseEnrollment.objects.get_or_create(user=self.user, course_id=self.course_id) + course_loc = CourseDescriptor.id_to_location(self.course_id) + course_exists = modulestore().has_item(self.course_id, course_loc) + if not course_exists: + raise PurchasedCallbackException( + "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + CourseEnrollment.enroll(user=self.user, course_id=self.course_id) log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) org, course_num, run = self.course_id.split("/") diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py index e863688133..098ed0f1af 100644 --- a/lms/djangoapps/shoppingcart/processors/exceptions.py +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -1,5 +1,4 @@ -class PaymentException(Exception): - pass +from shoppingcart.exceptions import PaymentException class CCProcessorException(PaymentException): pass diff --git a/lms/envs/dev.py b/lms/envs/dev.py index cc78dcc6ca..554c72dd89 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -270,6 +270,10 @@ CC_PROCESSOR['CyberSource']['PURCHASE_ENDPOINT'] = os.environ.get('CYBERSOURCE_P ########################## USER API ######################## EDX_API_KEY = None + +####################### Shoppingcart ########################### +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True + ##################################################################### # Lastly, see if the developer has any local overrides. try: From 6f70c9b9ce97682e30adb29a8e7e52d083aa99e1 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 14:49:26 -0700 Subject: [PATCH 108/244] Adding migration to store purchased mode in PaidCourseRegistration --- common/djangoapps/course_modes/models.py | 1 + common/djangoapps/student/models.py | 3 - lms/djangoapps/shoppingcart/exceptions.py | 5 +- ...__add_field_paidcourseregistration_mode.py | 114 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 18 +-- lms/djangoapps/shoppingcart/views.py | 2 +- 6 files changed, 129 insertions(+), 14 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 3d1c6f0563..6362b7061f 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -33,6 +33,7 @@ class CourseMode(models.Model): currency = models.CharField(default="usd", max_length=8) DEFAULT_MODE = Mode('honor', _('Honor Code Certificate'), 0, '', 'usd') + DEFAULT_MODE_SLUG = 'honor' class Meta: """ meta attributes of this model """ diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 6b5897e97d..3d977b28c9 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -827,9 +827,6 @@ class CourseEnrollment(models.Model): @classmethod def is_enrolled(cls, user, course_id): """ - Remove the user from a given course. If the relevant `CourseEnrollment` - object doesn't exist, we log an error but don't throw an exception. - Returns True if the user is enrolled in the course (the entry must exist and it must have `is_active=True`). Otherwise, returns False. diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py index fdfb9ccdb9..5c147194a1 100644 --- a/lms/djangoapps/shoppingcart/exceptions.py +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -2,4 +2,7 @@ class PaymentException(Exception): pass class PurchasedCallbackException(PaymentException): - pass \ No newline at end of file + pass + +class InvalidCartItem(PaymentException): + pass diff --git a/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py new file mode 100644 index 0000000000..1a6730c769 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py @@ -0,0 +1,114 @@ +# -*- 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 field 'PaidCourseRegistration.mode' + db.add_column('shoppingcart_paidcourseregistration', 'mode', + self.gf('django.db.models.fields.SlugField')(default='honor', max_length=50), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'PaidCourseRegistration.mode' + db.delete_column('shoppingcart_paidcourseregistration', 'mode') + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", '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'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 7c73adbd9a..e2dad911da 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -19,9 +19,6 @@ from .exceptions import * log = logging.getLogger("shoppingcart") -class InvalidCartItem(Exception): - pass - ORDER_STATUSES = ( ('cart', 'cart'), ('purchased', 'purchased'), @@ -153,6 +150,7 @@ class PaidCourseRegistration(OrderItem): This is an inventory item for paying for a course registration """ course_id = models.CharField(max_length=128, db_index=True) + mode = models.SlugField(default=CourseMode.DEFAULT_MODE_SLUG) @classmethod def part_of_order(cls, order, course_id): @@ -164,7 +162,7 @@ class PaidCourseRegistration(OrderItem): if item.is_of_subtype(PaidCourseRegistration)] @classmethod - def add_to_order(cls, order, course_id, mode_slug=None, cost=None, currency=None): + def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): """ A standardized way to create these objects, with sensible defaults filled in. Will update the cost if called on an order that already carries the course. @@ -178,16 +176,18 @@ class PaidCourseRegistration(OrderItem): # throw errors if it doesn't item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) item.status = order.status - if not mode_slug: - mode_slug = CourseMode.DEFAULT_MODE.slug + ### Get this course_mode course_mode = CourseMode.mode_for_course(course_id, mode_slug) if not course_mode: + # user could have specified a mode that's not set, in that case return the DEFAULT_MODE course_mode = CourseMode.DEFAULT_MODE if not cost: cost = course_mode.min_price if not currency: currency = course_mode.currency + + item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost item.line_cost = cost @@ -202,8 +202,8 @@ class PaidCourseRegistration(OrderItem): def purchased_callback(self): """ When purchased, this should enroll the user in the course. We are assuming that - course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found in - CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment + course settings for enrollment date are configured such that only if the (user.email, course_id) pair is found + in CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment would in fact be quite silly since there's a clear back door. """ course_loc = CourseDescriptor.id_to_location(self.course_id) @@ -211,7 +211,7 @@ class PaidCourseRegistration(OrderItem): if not course_exists: raise PurchasedCallbackException( "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) - CourseEnrollment.enroll(user=self.user, course_id=self.course_id) + CourseEnrollment.enroll(user=self.user, course_id=self.course_id, mode=self.mode) log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) org, course_num, run = self.course_id.split("/") diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index bdf8eb317f..52837228b9 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -24,7 +24,7 @@ def add_course_to_cart(request, course_id): cart = Order.get_cart_for_user(request.user) if PaidCourseRegistration.part_of_order(cart, course_id): return HttpResponseNotFound(_('The course {0} is already in your cart.'.format(course_id))) - if CourseEnrollment.objects.filter(user=request.user, course_id=course_id).exists(): + if CourseEnrollment.is_enrolled(user=request.user, course_id=course_id): return HttpResponseNotFound(_('You are already registered in course {0}.'.format(course_id))) try: PaidCourseRegistration.add_to_order(cart, course_id) From ca3651fa355d6ee4b0556e0453da620e9aa0bc77 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 18:17:06 -0700 Subject: [PATCH 109/244] add handling of CyberSource non-ACCEPT decisions --- .../shoppingcart/processors/CyberSource.py | 242 +++++++++++++++--- .../shoppingcart/processors/exceptions.py | 3 + lms/djangoapps/shoppingcart/views.py | 4 +- lms/templates/shoppingcart/error.html | 14 + 4 files changed, 226 insertions(+), 37 deletions(-) create mode 100644 lms/templates/shoppingcart/error.html diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 75ad754237..d8e53843cc 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -8,18 +8,21 @@ import binascii import re import json from collections import OrderedDict, defaultdict +from decimal import Decimal, InvalidOperation from hashlib import sha1 +from textwrap import dedent from django.conf import settings from django.utils.translation import ugettext as _ from mitxmako.shortcuts import render_to_string from shoppingcart.models import Order -from .exceptions import CCProcessorException, CCProcessorDataException, CCProcessorWrongAmountException +from .exceptions import * shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') +payment_support_email = settings.PAYMENT_SUPPORT_EMAIL def process_postpay_callback(request): """ @@ -34,27 +37,23 @@ def process_postpay_callback(request): return a helpful-enough error message in error_html. """ params = request.POST.dict() - if verify_signatures(params): - try: - result = payment_accepted(params) - if result['accepted']: - # SUCCESS CASE first, rest are some sort of oddity - record_purchase(params, result['order']) - return {'success': True, - 'order': result['order'], - 'error_html': ''} - else: - return {'success': False, - 'order': result['order'], - 'error_html': get_processor_error_html(params)} - except CCProcessorException as e: + try: + verify_signatures(params) + result = payment_accepted(params) + if result['accepted']: + # SUCCESS CASE first, rest are some sort of oddity + record_purchase(params, result['order']) + return {'success': True, + 'order': result['order'], + 'error_html': ''} + else: return {'success': False, - 'order': None, #due to exception we may not have the order - 'error_html': get_exception_html(params, e)} - else: + 'order': result['order'], + 'error_html': get_processor_decline_html(params)} + except CCProcessorException as e: return {'success': False, - 'order': None, - 'error_html': get_signature_error_html(params)} + 'order': None, #due to exception we may not have the order + 'error_html': get_processor_exception_html(params, e)} def hash(value): @@ -87,15 +86,18 @@ def sign(params): def verify_signatures(params): """ Verify the signatures accompanying the POST back from Cybersource Hosted Order Page + + returns silently if verified + + raises CCProcessorSignatureException if not verified """ signed_fields = params.get('signedFields', '').split(',') data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) signed_fields_sig = hash(params.get('signedFields', '')) data += ",signedFieldsPublicSignature=" + signed_fields_sig returned_sig = params.get('signedDataPublicSignature','') - if not returned_sig: - return False - return hash(data) == returned_sig + if hash(data) != returned_sig: + raise CCProcessorSignatureException() def render_purchase_form_html(cart, user): @@ -130,11 +132,18 @@ def render_purchase_form_html(cart, user): def payment_accepted(params): """ Check that cybersource has accepted the payment + params: a dictionary of POST parameters returned by CyberSource in their post-payment callback + + returns: true if the payment was correctly accepted, for the right amount + false if the payment was not accepted + + raises: CCProcessorDataException if the returned message did not provide required parameters + CCProcessorWrongAmountException if the amount charged is different than the order amount + """ #make sure required keys are present and convert their values to the right type valid_params = {} for key, type in [('orderNumber', int), - ('ccAuthReply_amount', float), ('orderCurrency', str), ('decision', str)]: if key not in params: @@ -154,7 +163,16 @@ def payment_accepted(params): raise CCProcessorDataException(_("The payment processor accepted an order whose number is not in our system.")) if valid_params['decision'] == 'ACCEPT': - if valid_params['ccAuthReply_amount'] == order.total_cost and valid_params['orderCurrency'] == order.currency: + try: + # Moved reading of charged_amount from the valid_params loop above because + # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter + charged_amt = Decimal(params['ccAuthReply_amount']) + except InvalidOperation: + raise CCProcessorDataException( + _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) + ) + + if charged_amt == order.total_cost and valid_params['orderCurrency'] == order.currency: return {'accepted': True, 'amt_charged': valid_params['ccAuthReply_amount'], 'currency': valid_params['orderCurrency'], @@ -197,21 +215,67 @@ def record_purchase(params, order): processor_reply_dump=json.dumps(params) ) -def get_processor_error_html(params): - """Have to parse through the error codes for all the other cases""" - return "

    ERROR!

    " +def get_processor_decline_html(params): + """Have to parse through the error codes to return a helpful message""" + msg = _(dedent( + """ +

    + Sorry! Our payment processor did not accept your payment. + The decision in they returned was {decision}, + and the reason was {reason_code}:{reason_msg}. + You were not charged. Please try a different form of payment. + Contact us with payment-specific questions at {email}. +

    + """)) -def get_exception_html(params, exp): + return msg.format( + decision=params['decision'], + reason_code=params['reasonCode'], + reason_msg=REASONCODE_MAP[params['reasonCode']], + email=payment_support_email) + + +def get_processor_exception_html(params, exception): """Return error HTML associated with exception""" - return "

    EXCEPTION!

    " -def get_signature_error_html(params): - """Return error HTML associated with signature failure""" - return "

    EXCEPTION!

    " + if isinstance(exception, CCProcessorDataException): + msg = _(dedent( + """ +

    + Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! + We apologize that we cannot verify whether the charge went through and take further action on your order. + The specific error message is: {msg}. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) + return msg + elif isinstance(exception, CCProcessorWrongAmountException): + msg = _(dedent( + """ +

    + Sorry! Due to an error your purchase was charged for a different amount than the order total! + The specific error message is: {msg}. + Your credit card has probably been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) + return msg + elif isinstance(exception, CCProcessorSignatureException): + msg = _(dedent( + """ +

    + Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are + unable to validate that the message actually came from the payment processor. + We apologize that we cannot verify whether the charge went through and take further action on your order. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

    + """.format(email=payment_support_email))) + return msg + + # fallthrough case, which basically never happens + return '

    EXCEPTION!

    ' -CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") -CARDTYPE_MAP.update( +CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN").update( { '001': 'Visa', '002': 'MasterCard', @@ -233,3 +297,111 @@ CARDTYPE_MAP.update( '043': 'GE Money UK card' } ) + +REASONCODE_MAP = defaultdict(lambda:"UNKNOWN REASON") +REASONCODE_MAP.update( + { + '100' : _('Successful transaction.'), + '101' : _('The request is missing one or more required fields.'), + '102' : _('One or more fields in the request contains invalid data.'), + '104' : _(dedent( + """ + The merchantReferenceCode sent with this authorization request matches the + merchantReferenceCode of another authorization request that you sent in the last 15 minutes. + Possible fix: retry the payment after 15 minutes. + """)), + '150' : _('Error: General system failure. Possible fix: retry the payment after a few minutes.'), + '151' : _(dedent( + """ + Error: The request was received but there was a server timeout. + This error does not include timeouts between the client and the server. + Possible fix: retry the payment after some time. + """)), + '152' : _(dedent( + """ + Error: The request was received, but a service did not finish running in time + Possible fix: retry the payment after some time. + """)), + '201' : _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'), + '202' : _(dedent( + """ + Expired card. You might also receive this if the expiration date you + provided does not match the date the issuing bank has on file. + Possible fix: retry with another form of payment + """)), + '203' : _(dedent( + """ + General decline of the card. No other information provided by the issuing bank. + Possible fix: retry with another form of payment + """)), + '204' : _('Insufficient funds in the account. Possible fix: retry with another form of payment'), + # 205 was Stolen or lost card. Might as well not show this message to the person using such a card. + '205' : _('Unknown reason'), + '207' : _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), + '208' : _(dedent( + """ + Inactive card or card not authorized for card-not-present transactions. + Possible fix: retry with another form of payment + """)), + '210' : _('The card has reached the credit limit. Possible fix: retry with another form of payment'), + '211' : _('Invalid card verification number. Possible fix: retry with another form of payment'), + # 221 was The customer matched an entry on the processor's negative file. + # Might as well not show this message to the person using such a card. + '221' : _('Unknown reason'), + '231' : _('Invalid account number. Possible fix: retry with another form of payment'), + '232' : _(dedent( + """ + The card type is not accepted by the payment processor. + Possible fix: retry with another form of payment + """)), + '233' : _('General decline by the processor. Possible fix: retry with another form of payment'), + '234' : _(dedent( + """ + There is a problem with our CyberSource merchant configuration. Please let us know at {0} + """.format(payment_support_email))), + # reason code 235 only applies if we are processing a capture through the API. so we should never see it + '235' : _('The requested amount exceeds the originally authorized amount.'), + '236' : _('Processor Failure. Possible fix: retry the payment'), + # reason code 238 only applies if we are processing a capture through the API. so we should never see it + '238' : _('The authorization has already been captured'), + # reason code 239 only applies if we are processing a capture or credit through the API, + # so we should never see it + '239' : _('The requested transaction amount must match the previous transaction amount.'), + '240' : _(dedent( + """ + The card type sent is invalid or does not correlate with the credit card number. + Possible fix: retry with the same card or another form of payment + """)), + # reason code 241 only applies when we are processing a capture or credit through the API, + # so we should never see it + '241' : _('The request ID is invalid.'), + # reason code 242 occurs if there was not a previously successful authorization request or + # if the previously successful authorization has already been used by another capture request. + # This reason code only applies when we are processing a capture through the API + # so we should never see it + '242' : _(dedent( + """ + You requested a capture through the API, but there is no corresponding, unused authorization record. + """)), + # we should never see 243 + '243' : _('The transaction has already been settled or reversed.'), + # reason code 246 applies only if we are processing a void through the API. so we should never see it + '246' : _(dedent( + """ + The capture or credit is not voidable because the capture or credit information has already been + submitted to your processor. Or, you requested a void for a type of transaction that cannot be voided. + """)), + # reason code 247 applies only if we are processing a void through the API. so we should never see it + '247' : _('You requested a credit for a capture that was previously voided'), + '250' : _(dedent( + """ + Error: The request was received, but there was a timeout at the payment processor. + Possible fix: retry the payment. + """)), + '520' : _(dedent( + """ + The authorization request was approved by the issuing bank but declined by CyberSource.' + Possible fix: retry with a different form of payment. + """)), + } +) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py index 098ed0f1af..6779ac11a6 100644 --- a/lms/djangoapps/shoppingcart/processors/exceptions.py +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -3,6 +3,9 @@ from shoppingcart.exceptions import PaymentException class CCProcessorException(PaymentException): pass +class CCProcessorSignatureException(CCProcessorException): + pass + class CCProcessorDataException(CCProcessorException): pass diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 52837228b9..85334df6a6 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -86,8 +86,8 @@ def postpay_callback(request): if result['success']: return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: - return render_to_response('shoppingcart.processor_error.html', {'order':result['order'], - 'error_html': result['error_html']}) + return render_to_response('shoppingcart/error.html', {'order':result['order'], + 'error_html': result['error_html']}) @login_required def show_receipt(request, ordernum): diff --git a/lms/templates/shoppingcart/error.html b/lms/templates/shoppingcart/error.html new file mode 100644 index 0000000000..da88dc1a78 --- /dev/null +++ b/lms/templates/shoppingcart/error.html @@ -0,0 +1,14 @@ +<%! from django.utils.translation import ugettext as _ %> +<%! from django.core.urlresolvers import reverse %> + +<%inherit file="../main.html" /> + +<%block name="title">${_("Payment Error")} + + +
    +

    ${_("There was an error processing your order!")}

    + ${error_html} + +

    ${_("Return to cart to retry payment")}

    +
    From 9fdf60ff454adb8aa761173c249e88861dbcc078 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 18:55:59 -0700 Subject: [PATCH 110/244] change method sig of process_postpay_callback --- lms/djangoapps/shoppingcart/models.py | 14 +++++++------- .../shoppingcart/processors/CyberSource.py | 13 +++++++------ lms/djangoapps/shoppingcart/views.py | 3 ++- 3 files changed, 16 insertions(+), 14 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index e2dad911da..895f466273 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -158,8 +158,7 @@ class PaidCourseRegistration(OrderItem): Is the course defined by course_id in the order? """ return course_id in [item.paidcourseregistration.course_id - for item in order.orderitem_set.all() - if item.is_of_subtype(PaidCourseRegistration)] + for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")] @classmethod def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): @@ -169,15 +168,11 @@ class PaidCourseRegistration(OrderItem): Returns the order item """ - super(PaidCourseRegistration, cls).add_to_order(order, course_id, cost, currency=currency) - # TODO: Possibly add checking for whether student is already enrolled in course course = course_from_id(course_id) # actually fetch the course to make sure it exists, use this to # throw errors if it doesn't - item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) - item.status = order.status - ### Get this course_mode + ### handle default arguments for mode_slug, cost, currency course_mode = CourseMode.mode_for_course(course_id, mode_slug) if not course_mode: # user could have specified a mode that's not set, in that case return the DEFAULT_MODE @@ -187,6 +182,11 @@ class PaidCourseRegistration(OrderItem): if not currency: currency = course_mode.currency + super(PaidCourseRegistration, cls).add_to_order(order, course_id, cost, currency=currency) + + item, created = cls.objects.get_or_create(order=order, user=order.user, course_id=course_id) + item.status = order.status + item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index d8e53843cc..e7f593db4a 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -24,7 +24,7 @@ orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION' purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') payment_support_email = settings.PAYMENT_SUPPORT_EMAIL -def process_postpay_callback(request): +def process_postpay_callback(params): """ The top level call to this module, basically This function is handed the callback request after the customer has entered the CC info and clicked "buy" @@ -36,7 +36,6 @@ def process_postpay_callback(request): If unsuccessful this function should not have those side effects but should try to figure out why and return a helpful-enough error message in error_html. """ - params = request.POST.dict() try: verify_signatures(params) result = payment_accepted(params) @@ -164,17 +163,18 @@ def payment_accepted(params): if valid_params['decision'] == 'ACCEPT': try: - # Moved reading of charged_amount from the valid_params loop above because + # Moved reading of charged_amount here from the valid_params loop above because # only 'ACCEPT' messages have a 'ccAuthReply_amount' parameter charged_amt = Decimal(params['ccAuthReply_amount']) except InvalidOperation: raise CCProcessorDataException( - _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) + _("The payment processor returned a badly-typed value {0} for param {1}.".format( + params['ccAuthReply_amount'], 'ccAuthReply_amount')) ) if charged_amt == order.total_cost and valid_params['orderCurrency'] == order.currency: return {'accepted': True, - 'amt_charged': valid_params['ccAuthReply_amount'], + 'amt_charged': charged_amt, 'currency': valid_params['orderCurrency'], 'order': order} else: @@ -275,7 +275,8 @@ def get_processor_exception_html(params, exception): return '

    EXCEPTION!

    ' -CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN").update( +CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") +CARDTYPE_MAP.update( { '001': 'Visa', '002': 'MasterCard', diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 85334df6a6..0d046b9a4b 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -82,7 +82,8 @@ def postpay_callback(request): If unsuccessful the order will be left untouched and HTML messages giving more detailed error info will be returned. """ - result = process_postpay_callback(request) + params = request.POST.dict() + result = process_postpay_callback(params) if result['success']: return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: From d140ffd868c2b0cf4401c191f5c5f05e1635ef77 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 21:36:32 -0700 Subject: [PATCH 111/244] Start of tests for CyberSource processor --- .../shoppingcart/processors/CyberSource.py | 28 ++++---- .../shoppingcart/processors/__init__.py | 40 +---------- .../shoppingcart/processors/tests/__init__.py | 0 .../processors/tests/test_CyberSource.py | 69 +++++++++++++++++++ 4 files changed, 85 insertions(+), 52 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/processors/tests/__init__.py create mode 100644 lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index e7f593db4a..20b2b1bda8 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -17,13 +17,6 @@ from mitxmako.shortcuts import render_to_string from shoppingcart.models import Order from .exceptions import * -shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') -merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') -serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') -orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') -purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') -payment_support_email = settings.PAYMENT_SUPPORT_EMAIL - def process_postpay_callback(params): """ The top level call to this module, basically @@ -59,6 +52,7 @@ def hash(value): """ Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page """ + shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') hash_obj = hmac.new(shared_secret, value, sha1) return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want @@ -68,6 +62,10 @@ def sign(params): params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ + merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') + orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') + serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') + params['merchantID'] = merchant_id params['orderPage_timestamp'] = int(time.time()*1000) params['orderPage_version'] = orderPage_version @@ -82,7 +80,7 @@ def sign(params): return params -def verify_signatures(params): +def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='signedDataPublicSignature'): """ Verify the signatures accompanying the POST back from Cybersource Hosted Order Page @@ -90,11 +88,11 @@ def verify_signatures(params): raises CCProcessorSignatureException if not verified """ - signed_fields = params.get('signedFields', '').split(',') + signed_fields = params.get(signed_fields_key, '').split(',') data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) - signed_fields_sig = hash(params.get('signedFields', '')) + signed_fields_sig = hash(params.get(signed_fields_key, '')) data += ",signedFieldsPublicSignature=" + signed_fields_sig - returned_sig = params.get('signedDataPublicSignature','') + returned_sig = params.get(full_sig_key, '') if hash(data) != returned_sig: raise CCProcessorSignatureException() @@ -103,11 +101,12 @@ def render_purchase_form_html(cart, user): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ + purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') + total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() params = OrderedDict() - params['comment'] = 'Stanford OpenEdX Purchase' params['amount'] = amount params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' @@ -217,6 +216,8 @@ def record_purchase(params, order): def get_processor_decline_html(params): """Have to parse through the error codes to return a helpful message""" + payment_support_email = settings.PAYMENT_SUPPORT_EMAIL + msg = _(dedent( """

    @@ -238,6 +239,7 @@ def get_processor_decline_html(params): def get_processor_exception_html(params, exception): """Return error HTML associated with exception""" + payment_support_email = settings.PAYMENT_SUPPORT_EMAIL if isinstance(exception, CCProcessorDataException): msg = _(dedent( """ @@ -359,7 +361,7 @@ REASONCODE_MAP.update( '234' : _(dedent( """ There is a problem with our CyberSource merchant configuration. Please let us know at {0} - """.format(payment_support_email))), + """.format(settings.PAYMENT_SUPPORT_EMAIL))), # reason code 235 only applies if we are processing a capture through the API. so we should never see it '235' : _('The requested amount exceeds the originally authorized amount.'), '236' : _('Processor Failure. Possible fix: retry the payment'), diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index 45a6e3114d..bbbbe41cde 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -3,11 +3,7 @@ from django.conf import settings ### Now code that determines, using settings, which actual processor implementation we're using. processor_name = settings.CC_PROCESSOR.keys()[0] module = __import__('shoppingcart.processors.' + processor_name, - fromlist=['sign', - 'verify', - 'render_purchase_form_html' - 'payment_accepted', - 'record_purchase', + fromlist=['render_purchase_form_html' 'process_postpay_callback', ]) @@ -34,37 +30,3 @@ def process_postpay_callback(*args, **kwargs): """ return module.process_postpay_callback(*args, **kwargs) -def sign(*args, **kwargs): - """ - Given a dict (or OrderedDict) of parameters to send to the - credit card processor, signs them in the manner expected by - the processor - - Returns a dict containing the signature - """ - return module.sign(*args, **kwargs) - -def verify(*args, **kwargs): - """ - Given a dict (or OrderedDict) of parameters to returned by the - credit card processor, verifies them in the manner specified by - the processor - - Returns a boolean - """ - return module.sign(*args, **kwargs) - -def payment_accepted(*args, **kwargs): - """ - Given params returned by the CC processor, check that processor has accepted the payment - Returns a dict of {accepted:bool, amt_charged:float, currency:str, order:Order} - """ - return module.payment_accepted(*args, **kwargs) - -def record_purchase(*args, **kwargs): - """ - Given params returned by the CC processor, record that the purchase has occurred in - the database and also run callbacks - """ - return module.record_purchase(*args, **kwargs) - diff --git a/lms/djangoapps/shoppingcart/processors/tests/__init__.py b/lms/djangoapps/shoppingcart/processors/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py new file mode 100644 index 0000000000..0dc3887437 --- /dev/null +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -0,0 +1,69 @@ +""" +Tests for the CyberSource processor handler +""" +from collections import OrderedDict +from django.test import TestCase +from django.test.utils import override_settings +from django.conf import settings +from shoppingcart.processors.CyberSource import * +from shoppingcart.processors.exceptions import CCProcessorSignatureException + +TEST_CC_PROCESSOR = { + 'CyberSource' : { + 'SHARED_SECRET': 'secret', + 'MERCHANT_ID' : 'edx_test', + 'SERIAL_NUMBER' : '12345', + 'ORDERPAGE_VERSION': '7', + 'PURCHASE_ENDPOINT': '', + } +} + +@override_settings(CC_PROCESSOR=TEST_CC_PROCESSOR) +class CyberSourceTests(TestCase): + + def setUp(self): + pass + + def test_override_settings(self): + self.assertEquals(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') + self.assertEquals(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') + + def test_hash(self): + """ + Tests the hash function. Basically just hardcodes the answer. + """ + self.assertEqual(hash('test'), 'GqNJWF7X7L07nEhqMAZ+OVyks1Y=') + self.assertEqual(hash('edx '), '/KowheysqM2PFYuxVKg0P8Flfk4=') + + def test_sign_then_verify(self): + """ + "loopback" test: + Tests the that the verify function verifies parameters signed by the sign function + """ + params = OrderedDict() + params['amount'] = "12.34" + params['currency'] = 'usd' + params['orderPage_transactionType'] = 'sale' + params['orderNumber'] = "567" + + verify_signatures(sign(params), signed_fields_key='orderPage_signedFields', + full_sig_key='orderPage_signaturePublic') + + # if the above verify_signature fails it will throw an exception, so basically we're just + # testing for the absence of that exception. the trivial assert below does that + self.assertEqual(1, 1) + + def test_verify_exception(self): + """ + Tests that failure to verify raises the proper CCProcessorSignatureException + """ + params = OrderedDict() + params['a'] = 'A' + params['b'] = 'B' + params['signedFields'] = 'A,B' + params['signedDataPublicSignature'] = 'WONTVERIFY' + + with self.assertRaises(CCProcessorSignatureException): + verify_signatures(params) + + From 055ad5357d3300be16c6c279e57996045156a791 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Tue, 20 Aug 2013 23:58:27 -0700 Subject: [PATCH 112/244] 100% coverage on CyberSource.py --- .../shoppingcart/processors/CyberSource.py | 27 +-- .../processors/tests/test_CyberSource.py | 224 +++++++++++++++++- lms/djangoapps/shoppingcart/views.py | 2 +- 3 files changed, 232 insertions(+), 21 deletions(-) diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 20b2b1bda8..740908624c 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -45,7 +45,7 @@ def process_postpay_callback(params): except CCProcessorException as e: return {'success': False, 'order': None, #due to exception we may not have the order - 'error_html': get_processor_exception_html(params, e)} + 'error_html': get_processor_exception_html(e)} def hash(value): @@ -57,7 +57,7 @@ def hash(value): return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want -def sign(params): +def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='orderPage_signaturePublic'): """ params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource @@ -74,8 +74,8 @@ def sign(params): values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) fields_sig = hash(fields) values += ",signedFieldsPublicSignature=" + fields_sig - params['orderPage_signaturePublic'] = hash(values) - params['orderPage_signedFields'] = fields + params[full_sig_key] = hash(values) + params[signed_fields_key] = fields return params @@ -97,7 +97,7 @@ def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='si raise CCProcessorSignatureException() -def render_purchase_form_html(cart, user): +def render_purchase_form_html(cart): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ @@ -111,14 +111,6 @@ def render_purchase_form_html(cart, user): params['currency'] = cart.currency params['orderPage_transactionType'] = 'sale' params['orderNumber'] = "{0:d}".format(cart.id) - idx=1 - for item in cart_items: - prefix = "item_{0:d}_".format(idx) - params[prefix+'productSKU'] = "{0:d}".format(item.id) - params[prefix+'quantity'] = item.qty - params[prefix+'productName'] = item.line_desc - params[prefix+'unitPrice'] = item.unit_cost - params[prefix+'taxAmount'] = "0.00" signed_param_dict = sign(params) return render_to_string('shoppingcart/cybersource_form.html', { @@ -179,14 +171,14 @@ def payment_accepted(params): else: raise CCProcessorWrongAmountException( _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}."\ - .format(valid_params['ccAuthReply_amount'], valid_params['orderCurrency'], + .format(charged_amt, valid_params['orderCurrency'], order.total_cost, order.currency)) ) else: return {'accepted': False, 'amt_charged': 0, 'currency': 'usd', - 'order': None} + 'order': order} def record_purchase(params, order): @@ -236,7 +228,7 @@ def get_processor_decline_html(params): email=payment_support_email) -def get_processor_exception_html(params, exception): +def get_processor_exception_html(exception): """Return error HTML associated with exception""" payment_support_email = settings.PAYMENT_SUPPORT_EMAIL @@ -267,10 +259,11 @@ def get_processor_exception_html(params, exception):

    Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are unable to validate that the message actually came from the payment processor. + The specific error message is: {msg}. We apologize that we cannot verify whether the charge went through and take further action on your order. Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}.

    - """.format(email=payment_support_email))) + """.format(msg=exception.message, email=payment_support_email))) return msg # fallthrough case, which basically never happens diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py index 0dc3887437..df719d33b3 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -5,8 +5,12 @@ from collections import OrderedDict from django.test import TestCase from django.test.utils import override_settings from django.conf import settings +from student.tests.factories import UserFactory +from shoppingcart.models import Order, OrderItem from shoppingcart.processors.CyberSource import * -from shoppingcart.processors.exceptions import CCProcessorSignatureException +from shoppingcart.processors.exceptions import * +from mock import patch, Mock + TEST_CC_PROCESSOR = { 'CyberSource' : { @@ -25,8 +29,8 @@ class CyberSourceTests(TestCase): pass def test_override_settings(self): - self.assertEquals(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') - self.assertEquals(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') + self.assertEqual(settings.CC_PROCESSOR['CyberSource']['MERCHANT_ID'], 'edx_test') + self.assertEqual(settings.CC_PROCESSOR['CyberSource']['SHARED_SECRET'], 'secret') def test_hash(self): """ @@ -66,4 +70,218 @@ class CyberSourceTests(TestCase): with self.assertRaises(CCProcessorSignatureException): verify_signatures(params) + def test_get_processor_decline_html(self): + """ + Tests the processor decline html message + """ + DECISION = 'REJECT' + for code, reason in REASONCODE_MAP.iteritems(): + params={ + 'decision': DECISION, + 'reasonCode': code, + } + html = get_processor_decline_html(params) + self.assertIn(DECISION, html) + self.assertIn(reason, html) + self.assertIn(code, html) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) + def test_get_processor_exception_html(self): + """ + Tests the processor exception html message + """ + for type in [CCProcessorSignatureException, CCProcessorWrongAmountException, CCProcessorDataException]: + error_msg = "An exception message of with exception type {0}".format(str(type)) + exception = type(error_msg) + html = get_processor_exception_html(exception) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, html) + self.assertIn('Sorry!', html) + self.assertIn(error_msg, html) + + # test base case + self.assertIn("EXCEPTION!", get_processor_exception_html(CCProcessorException())) + + def test_record_purchase(self): + """ + Tests record_purchase with good and without returned CCNum + """ + student1 = UserFactory() + student1.save() + student2 = UserFactory() + student2.save() + params_cc = {'card_accountNumber':'1234', 'card_cardType':'001', 'billTo_firstName':student1.first_name} + params_nocc = {'card_accountNumber':'', 'card_cardType':'002', 'billTo_firstName':student2.first_name} + order1 = Order.get_cart_for_user(student1) + order2 = Order.get_cart_for_user(student2) + record_purchase(params_cc, order1) + record_purchase(params_nocc, order2) + self.assertEqual(order1.bill_to_ccnum, '1234') + self.assertEqual(order1.bill_to_cardtype, 'Visa') + self.assertEqual(order1.bill_to_first, student1.first_name) + self.assertEqual(order1.status, 'purchased') + + order2 = Order.objects.get(user=student2) + self.assertEqual(order2.bill_to_ccnum, '####') + self.assertEqual(order2.bill_to_cardtype, 'MasterCard') + self.assertEqual(order2.bill_to_first, student2.first_name) + self.assertEqual(order2.status, 'purchased') + + def test_payment_accepted_invalid_dict(self): + """ + Tests exception is thrown when params to payment_accepted don't have required key + or have an bad value + """ + baseline = { + 'orderNumber': '1', + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + } + wrong = { + 'orderNumber': 'k', + } + # tests for missing key + for key in baseline: + params = baseline.copy() + del params[key] + with self.assertRaises(CCProcessorDataException): + payment_accepted(params) + + # tests for keys with value that can't be converted to proper type + for key in wrong: + params = baseline.copy() + params[key] = wrong[key] + with self.assertRaises(CCProcessorDataException): + payment_accepted(params) + + def test_payment_accepted_order(self): + """ + Tests payment_accepted cases with an order + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + 'ccAuthReply_amount': '0.00' + } + + # tests for an order number that doesn't match up + params_bad_ordernum = params.copy() + params_bad_ordernum['orderNumber'] = str(order1.id+10) + with self.assertRaises(CCProcessorDataException): + payment_accepted(params_bad_ordernum) + + # tests for a reply amount of the wrong type + params_wrong_type_amt = params.copy() + params_wrong_type_amt['ccAuthReply_amount'] = 'ab' + with self.assertRaises(CCProcessorDataException): + payment_accepted(params_wrong_type_amt) + + # tests for a reply amount of the wrong type + params_wrong_amt = params.copy() + params_wrong_amt['ccAuthReply_amount'] = '1.00' + with self.assertRaises(CCProcessorWrongAmountException): + payment_accepted(params_wrong_amt) + + # tests for a not accepted order + params_not_accepted = params.copy() + params_not_accepted['decision'] = "REJECT" + self.assertFalse(payment_accepted(params_not_accepted)['accepted']) + + # finally, tests an accepted order + self.assertTrue(payment_accepted(params)['accepted']) + + @patch('shoppingcart.processors.CyberSource.render_to_string', autospec=True) + def test_render_purchase_form_html(self, render): + """ + Tests the rendering of the purchase form + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + item1 = OrderItem(order=order1, user=student1, unit_cost=1.0, line_cost=1.0) + item1.save() + html = render_purchase_form_html(order1) + ((template, context), render_kwargs) = render.call_args + + self.assertEqual(template, 'shoppingcart/cybersource_form.html') + self.assertDictContainsSubset({'amount': '1.00', + 'currency': 'usd', + 'orderPage_transactionType': 'sale', + 'orderNumber':str(order1.id)}, + context['params']) + + def test_process_postpay_exception(self): + """ + Tests the exception path of process_postpay_callback + """ + baseline = { + 'orderNumber': '1', + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + } + # tests for missing key + for key in baseline: + params = baseline.copy() + del params[key] + result = process_postpay_callback(params) + self.assertFalse(result['success']) + self.assertIsNone(result['order']) + self.assertIn('error_msg', result['error_html']) + + @patch('shoppingcart.processors.CyberSource.verify_signatures', Mock(return_value=True)) + def test_process_postpay_accepted(self): + """ + Tests the ACCEPTED path of process_postpay + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'ACCEPT', + 'ccAuthReply_amount': '0.00' + } + result = process_postpay_callback(params) + self.assertTrue(result['success']) + self.assertEqual(result['order'], order1) + order1 = Order.objects.get(id=order1.id) # reload from DB to capture side-effect of process_postpay_callback + self.assertEqual(order1.status, 'purchased') + self.assertFalse(result['error_html']) + + @patch('shoppingcart.processors.CyberSource.verify_signatures', Mock(return_value=True)) + def test_process_postpay_not_accepted(self): + """ + Tests the non-ACCEPTED path of process_postpay + """ + student1 = UserFactory() + student1.save() + + order1 = Order.get_cart_for_user(student1) + params = { + 'card_accountNumber': '1234', + 'card_cardType': '001', + 'billTo_firstName': student1.first_name, + 'orderNumber': str(order1.id), + 'orderCurrency': 'usd', + 'decision': 'REJECT', + 'ccAuthReply_amount': '0.00', + 'reasonCode': '207' + } + result = process_postpay_callback(params) + self.assertFalse(result['success']) + self.assertEqual(result['order'], order1) + self.assertEqual(order1.status, 'cart') + self.assertIn(REASONCODE_MAP['207'], result['error_html']) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 0d046b9a4b..fa8345f33e 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -47,7 +47,7 @@ def show_cart(request): total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() - form_html = render_purchase_form_html(cart, request.user) + form_html = render_purchase_form_html(cart) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, 'amount': amount, From b475ac36f1e5a8d4ead7193da5e8b65e0632e3e4 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 09:31:32 -0400 Subject: [PATCH 113/244] Some pep8/pylint cleanup --- lms/djangoapps/shoppingcart/exceptions.py | 2 + .../shoppingcart/processors/CyberSource.py | 188 +++++++++--------- .../shoppingcart/processors/__init__.py | 3 +- .../shoppingcart/processors/exceptions.py | 6 +- .../processors/tests/test_CyberSource.py | 25 +-- lms/djangoapps/shoppingcart/views.py | 8 +- 6 files changed, 124 insertions(+), 108 deletions(-) diff --git a/lms/djangoapps/shoppingcart/exceptions.py b/lms/djangoapps/shoppingcart/exceptions.py index 5c147194a1..029dc079bb 100644 --- a/lms/djangoapps/shoppingcart/exceptions.py +++ b/lms/djangoapps/shoppingcart/exceptions.py @@ -1,8 +1,10 @@ class PaymentException(Exception): pass + class PurchasedCallbackException(PaymentException): pass + class InvalidCartItem(PaymentException): pass diff --git a/lms/djangoapps/shoppingcart/processors/CyberSource.py b/lms/djangoapps/shoppingcart/processors/CyberSource.py index 740908624c..5952668d8f 100644 --- a/lms/djangoapps/shoppingcart/processors/CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/CyberSource.py @@ -15,7 +15,8 @@ from django.conf import settings from django.utils.translation import ugettext as _ from mitxmako.shortcuts import render_to_string from shoppingcart.models import Order -from .exceptions import * +from shoppingcart.processors.exceptions import * + def process_postpay_callback(params): """ @@ -42,19 +43,19 @@ def process_postpay_callback(params): return {'success': False, 'order': result['order'], 'error_html': get_processor_decline_html(params)} - except CCProcessorException as e: + except CCProcessorException as error: return {'success': False, - 'order': None, #due to exception we may not have the order - 'error_html': get_processor_exception_html(e)} + 'order': None, # due to exception we may not have the order + 'error_html': get_processor_exception_html(error)} -def hash(value): +def processor_hash(value): """ Performs the base64(HMAC_SHA1(key, value)) used by CyberSource Hosted Order Page """ - shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET','') + shared_secret = settings.CC_PROCESSOR['CyberSource'].get('SHARED_SECRET', '') hash_obj = hmac.new(shared_secret, value, sha1) - return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want + return binascii.b2a_base64(hash_obj.digest())[:-1] # last character is a '\n', which we don't want def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='orderPage_signaturePublic'): @@ -62,19 +63,19 @@ def sign(params, signed_fields_key='orderPage_signedFields', full_sig_key='order params needs to be an ordered dict, b/c cybersource documentation states that order is important. Reverse engineered from PHP version provided by cybersource """ - merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID','') - orderPage_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION','7') - serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER','') + merchant_id = settings.CC_PROCESSOR['CyberSource'].get('MERCHANT_ID', '') + order_page_version = settings.CC_PROCESSOR['CyberSource'].get('ORDERPAGE_VERSION', '7') + serial_number = settings.CC_PROCESSOR['CyberSource'].get('SERIAL_NUMBER', '') params['merchantID'] = merchant_id - params['orderPage_timestamp'] = int(time.time()*1000) - params['orderPage_version'] = orderPage_version + params['orderPage_timestamp'] = int(time.time() * 1000) + params['orderPage_version'] = order_page_version params['orderPage_serialNumber'] = serial_number fields = ",".join(params.keys()) - values = ",".join(["{0}={1}".format(i,params[i]) for i in params.keys()]) - fields_sig = hash(fields) + values = ",".join(["{0}={1}".format(i, params[i]) for i in params.keys()]) + fields_sig = processor_hash(fields) values += ",signedFieldsPublicSignature=" + fields_sig - params[full_sig_key] = hash(values) + params[full_sig_key] = processor_hash(values) params[signed_fields_key] = fields return params @@ -90,10 +91,10 @@ def verify_signatures(params, signed_fields_key='signedFields', full_sig_key='si """ signed_fields = params.get(signed_fields_key, '').split(',') data = ",".join(["{0}={1}".format(k, params.get(k, '')) for k in signed_fields]) - signed_fields_sig = hash(params.get(signed_fields_key, '')) + signed_fields_sig = processor_hash(params.get(signed_fields_key, '')) data += ",signedFieldsPublicSignature=" + signed_fields_sig returned_sig = params.get(full_sig_key, '') - if hash(data) != returned_sig: + if processor_hash(data) != returned_sig: raise CCProcessorSignatureException() @@ -101,7 +102,7 @@ def render_purchase_form_html(cart): """ Renders the HTML of the hidden POST form that must be used to initiate a purchase with CyberSource """ - purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT','') + purchase_endpoint = settings.CC_PROCESSOR['CyberSource'].get('PURCHASE_ENDPOINT', '') total_cost = cart.total_cost amount = "{0:0.2f}".format(total_cost) @@ -133,15 +134,15 @@ def payment_accepted(params): """ #make sure required keys are present and convert their values to the right type valid_params = {} - for key, type in [('orderNumber', int), - ('orderCurrency', str), - ('decision', str)]: + for key, key_type in [('orderNumber', int), + ('orderCurrency', str), + ('decision', str)]: if key not in params: raise CCProcessorDataException( _("The payment processor did not return a required parameter: {0}".format(key)) ) try: - valid_params[key] = type(params[key]) + valid_params[key] = key_type(params[key]) except ValueError: raise CCProcessorDataException( _("The payment processor returned a badly-typed value {0} for param {1}.".format(params[key], key)) @@ -170,7 +171,7 @@ def payment_accepted(params): 'order': order} else: raise CCProcessorWrongAmountException( - _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}."\ + _("The amount charged by the processor {0} {1} is different than the total cost of the order {2} {3}." .format(charged_amt, valid_params['orderCurrency'], order.total_cost, order.currency)) ) @@ -200,26 +201,27 @@ def record_purchase(params, order): city=params.get('billTo_city', ''), state=params.get('billTo_state', ''), country=params.get('billTo_country', ''), - postalcode=params.get('billTo_postalCode',''), + postalcode=params.get('billTo_postalCode', ''), ccnum=ccnum, cardtype=CARDTYPE_MAP[params.get('card_cardType', 'UNKNOWN')], processor_reply_dump=json.dumps(params) ) + def get_processor_decline_html(params): """Have to parse through the error codes to return a helpful message""" payment_support_email = settings.PAYMENT_SUPPORT_EMAIL msg = _(dedent( - """ -

    - Sorry! Our payment processor did not accept your payment. - The decision in they returned was {decision}, - and the reason was {reason_code}:{reason_msg}. - You were not charged. Please try a different form of payment. - Contact us with payment-specific questions at {email}. -

    - """)) + """ +

    + Sorry! Our payment processor did not accept your payment. + The decision in they returned was {decision}, + and the reason was {reason_code}:{reason_msg}. + You were not charged. Please try a different form of payment. + Contact us with payment-specific questions at {email}. +

    + """)) return msg.format( decision=params['decision'], @@ -234,43 +236,43 @@ def get_processor_exception_html(exception): payment_support_email = settings.PAYMENT_SUPPORT_EMAIL if isinstance(exception, CCProcessorDataException): msg = _(dedent( - """ -

    - Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! - We apologize that we cannot verify whether the charge went through and take further action on your order. - The specific error message is: {msg}. - Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. -

    - """.format(msg=exception.message, email=payment_support_email))) + """ +

    + Sorry! Our payment processor sent us back a payment confirmation that had inconsistent data! + We apologize that we cannot verify whether the charge went through and take further action on your order. + The specific error message is: {msg}. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) return msg elif isinstance(exception, CCProcessorWrongAmountException): msg = _(dedent( - """ -

    - Sorry! Due to an error your purchase was charged for a different amount than the order total! - The specific error message is: {msg}. - Your credit card has probably been charged. Contact us with payment-specific questions at {email}. -

    - """.format(msg=exception.message, email=payment_support_email))) + """ +

    + Sorry! Due to an error your purchase was charged for a different amount than the order total! + The specific error message is: {msg}. + Your credit card has probably been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) return msg elif isinstance(exception, CCProcessorSignatureException): msg = _(dedent( - """ -

    - Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are - unable to validate that the message actually came from the payment processor. - The specific error message is: {msg}. - We apologize that we cannot verify whether the charge went through and take further action on your order. - Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. -

    - """.format(msg=exception.message, email=payment_support_email))) + """ +

    + Sorry! Our payment processor sent us back a corrupted message regarding your charge, so we are + unable to validate that the message actually came from the payment processor. + The specific error message is: {msg}. + We apologize that we cannot verify whether the charge went through and take further action on your order. + Your credit card may possibly have been charged. Contact us with payment-specific questions at {email}. +

    + """.format(msg=exception.message, email=payment_support_email))) return msg # fallthrough case, which basically never happens return '

    EXCEPTION!

    ' -CARDTYPE_MAP = defaultdict(lambda:"UNKNOWN") +CARDTYPE_MAP = defaultdict(lambda: "UNKNOWN") CARDTYPE_MAP.update( { '001': 'Visa', @@ -294,110 +296,110 @@ CARDTYPE_MAP.update( } ) -REASONCODE_MAP = defaultdict(lambda:"UNKNOWN REASON") +REASONCODE_MAP = defaultdict(lambda: "UNKNOWN REASON") REASONCODE_MAP.update( { - '100' : _('Successful transaction.'), - '101' : _('The request is missing one or more required fields.'), - '102' : _('One or more fields in the request contains invalid data.'), - '104' : _(dedent( + '100': _('Successful transaction.'), + '101': _('The request is missing one or more required fields.'), + '102': _('One or more fields in the request contains invalid data.'), + '104': _(dedent( """ The merchantReferenceCode sent with this authorization request matches the merchantReferenceCode of another authorization request that you sent in the last 15 minutes. Possible fix: retry the payment after 15 minutes. """)), - '150' : _('Error: General system failure. Possible fix: retry the payment after a few minutes.'), - '151' : _(dedent( + '150': _('Error: General system failure. Possible fix: retry the payment after a few minutes.'), + '151': _(dedent( """ Error: The request was received but there was a server timeout. This error does not include timeouts between the client and the server. Possible fix: retry the payment after some time. """)), - '152' : _(dedent( + '152': _(dedent( """ Error: The request was received, but a service did not finish running in time Possible fix: retry the payment after some time. """)), - '201' : _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'), - '202' : _(dedent( + '201': _('The issuing bank has questions about the request. Possible fix: retry with another form of payment'), + '202': _(dedent( """ Expired card. You might also receive this if the expiration date you provided does not match the date the issuing bank has on file. Possible fix: retry with another form of payment """)), - '203' : _(dedent( + '203': _(dedent( """ General decline of the card. No other information provided by the issuing bank. Possible fix: retry with another form of payment """)), - '204' : _('Insufficient funds in the account. Possible fix: retry with another form of payment'), + '204': _('Insufficient funds in the account. Possible fix: retry with another form of payment'), # 205 was Stolen or lost card. Might as well not show this message to the person using such a card. - '205' : _('Unknown reason'), - '207' : _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), - '208' : _(dedent( + '205': _('Unknown reason'), + '207': _('Issuing bank unavailable. Possible fix: retry again after a few minutes'), + '208': _(dedent( """ Inactive card or card not authorized for card-not-present transactions. Possible fix: retry with another form of payment """)), - '210' : _('The card has reached the credit limit. Possible fix: retry with another form of payment'), - '211' : _('Invalid card verification number. Possible fix: retry with another form of payment'), + '210': _('The card has reached the credit limit. Possible fix: retry with another form of payment'), + '211': _('Invalid card verification number. Possible fix: retry with another form of payment'), # 221 was The customer matched an entry on the processor's negative file. # Might as well not show this message to the person using such a card. - '221' : _('Unknown reason'), - '231' : _('Invalid account number. Possible fix: retry with another form of payment'), - '232' : _(dedent( + '221': _('Unknown reason'), + '231': _('Invalid account number. Possible fix: retry with another form of payment'), + '232': _(dedent( """ The card type is not accepted by the payment processor. Possible fix: retry with another form of payment """)), - '233' : _('General decline by the processor. Possible fix: retry with another form of payment'), - '234' : _(dedent( + '233': _('General decline by the processor. Possible fix: retry with another form of payment'), + '234': _(dedent( """ There is a problem with our CyberSource merchant configuration. Please let us know at {0} """.format(settings.PAYMENT_SUPPORT_EMAIL))), # reason code 235 only applies if we are processing a capture through the API. so we should never see it - '235' : _('The requested amount exceeds the originally authorized amount.'), - '236' : _('Processor Failure. Possible fix: retry the payment'), + '235': _('The requested amount exceeds the originally authorized amount.'), + '236': _('Processor Failure. Possible fix: retry the payment'), # reason code 238 only applies if we are processing a capture through the API. so we should never see it - '238' : _('The authorization has already been captured'), + '238': _('The authorization has already been captured'), # reason code 239 only applies if we are processing a capture or credit through the API, # so we should never see it - '239' : _('The requested transaction amount must match the previous transaction amount.'), - '240' : _(dedent( + '239': _('The requested transaction amount must match the previous transaction amount.'), + '240': _(dedent( """ The card type sent is invalid or does not correlate with the credit card number. Possible fix: retry with the same card or another form of payment """)), # reason code 241 only applies when we are processing a capture or credit through the API, # so we should never see it - '241' : _('The request ID is invalid.'), + '241': _('The request ID is invalid.'), # reason code 242 occurs if there was not a previously successful authorization request or # if the previously successful authorization has already been used by another capture request. # This reason code only applies when we are processing a capture through the API # so we should never see it - '242' : _(dedent( + '242': _(dedent( """ You requested a capture through the API, but there is no corresponding, unused authorization record. """)), # we should never see 243 - '243' : _('The transaction has already been settled or reversed.'), + '243': _('The transaction has already been settled or reversed.'), # reason code 246 applies only if we are processing a void through the API. so we should never see it - '246' : _(dedent( + '246': _(dedent( """ The capture or credit is not voidable because the capture or credit information has already been submitted to your processor. Or, you requested a void for a type of transaction that cannot be voided. """)), # reason code 247 applies only if we are processing a void through the API. so we should never see it - '247' : _('You requested a credit for a capture that was previously voided'), - '250' : _(dedent( + '247': _('You requested a credit for a capture that was previously voided'), + '250': _(dedent( """ Error: The request was received, but there was a timeout at the payment processor. Possible fix: retry the payment. """)), - '520' : _(dedent( + '520': _(dedent( """ The authorization request was approved by the issuing bank but declined by CyberSource.' Possible fix: retry with a different form of payment. """)), } -) \ No newline at end of file +) diff --git a/lms/djangoapps/shoppingcart/processors/__init__.py b/lms/djangoapps/shoppingcart/processors/__init__.py index bbbbe41cde..4051d4c3ec 100644 --- a/lms/djangoapps/shoppingcart/processors/__init__.py +++ b/lms/djangoapps/shoppingcart/processors/__init__.py @@ -7,6 +7,7 @@ module = __import__('shoppingcart.processors.' + processor_name, 'process_postpay_callback', ]) + def render_purchase_form_html(*args, **kwargs): """ The top level call to this module to begin the purchase. @@ -16,6 +17,7 @@ def render_purchase_form_html(*args, **kwargs): """ return module.render_purchase_form_html(*args, **kwargs) + def process_postpay_callback(*args, **kwargs): """ The top level call to this module after the purchase. @@ -29,4 +31,3 @@ def process_postpay_callback(*args, **kwargs): return a helpful-enough error message in error_html. """ return module.process_postpay_callback(*args, **kwargs) - diff --git a/lms/djangoapps/shoppingcart/processors/exceptions.py b/lms/djangoapps/shoppingcart/processors/exceptions.py index 6779ac11a6..202f143cce 100644 --- a/lms/djangoapps/shoppingcart/processors/exceptions.py +++ b/lms/djangoapps/shoppingcart/processors/exceptions.py @@ -1,13 +1,17 @@ from shoppingcart.exceptions import PaymentException + class CCProcessorException(PaymentException): pass + class CCProcessorSignatureException(CCProcessorException): pass + class CCProcessorDataException(CCProcessorException): pass + class CCProcessorWrongAmountException(CCProcessorException): - pass \ No newline at end of file + pass diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py index df719d33b3..de9e5939f0 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -13,15 +13,16 @@ from mock import patch, Mock TEST_CC_PROCESSOR = { - 'CyberSource' : { + 'CyberSource': { 'SHARED_SECRET': 'secret', - 'MERCHANT_ID' : 'edx_test', - 'SERIAL_NUMBER' : '12345', + 'MERCHANT_ID': 'edx_test', + 'SERIAL_NUMBER': '12345', 'ORDERPAGE_VERSION': '7', 'PURCHASE_ENDPOINT': '', } } + @override_settings(CC_PROCESSOR=TEST_CC_PROCESSOR) class CyberSourceTests(TestCase): @@ -36,8 +37,8 @@ class CyberSourceTests(TestCase): """ Tests the hash function. Basically just hardcodes the answer. """ - self.assertEqual(hash('test'), 'GqNJWF7X7L07nEhqMAZ+OVyks1Y=') - self.assertEqual(hash('edx '), '/KowheysqM2PFYuxVKg0P8Flfk4=') + self.assertEqual(processor_hash('test'), 'GqNJWF7X7L07nEhqMAZ+OVyks1Y=') + self.assertEqual(processor_hash('edx '), '/KowheysqM2PFYuxVKg0P8Flfk4=') def test_sign_then_verify(self): """ @@ -76,7 +77,7 @@ class CyberSourceTests(TestCase): """ DECISION = 'REJECT' for code, reason in REASONCODE_MAP.iteritems(): - params={ + params = { 'decision': DECISION, 'reasonCode': code, } @@ -109,8 +110,8 @@ class CyberSourceTests(TestCase): student1.save() student2 = UserFactory() student2.save() - params_cc = {'card_accountNumber':'1234', 'card_cardType':'001', 'billTo_firstName':student1.first_name} - params_nocc = {'card_accountNumber':'', 'card_cardType':'002', 'billTo_firstName':student2.first_name} + params_cc = {'card_accountNumber': '1234', 'card_cardType': '001', 'billTo_firstName': student1.first_name} + params_nocc = {'card_accountNumber': '', 'card_cardType': '002', 'billTo_firstName': student2.first_name} order1 = Order.get_cart_for_user(student1) order2 = Order.get_cart_for_user(student2) record_purchase(params_cc, order1) @@ -173,7 +174,7 @@ class CyberSourceTests(TestCase): # tests for an order number that doesn't match up params_bad_ordernum = params.copy() - params_bad_ordernum['orderNumber'] = str(order1.id+10) + params_bad_ordernum['orderNumber'] = str(order1.id + 10) with self.assertRaises(CCProcessorDataException): payment_accepted(params_bad_ordernum) @@ -215,7 +216,7 @@ class CyberSourceTests(TestCase): self.assertDictContainsSubset({'amount': '1.00', 'currency': 'usd', 'orderPage_transactionType': 'sale', - 'orderNumber':str(order1.id)}, + 'orderNumber': str(order1.id)}, context['params']) def test_process_postpay_exception(self): @@ -257,7 +258,7 @@ class CyberSourceTests(TestCase): result = process_postpay_callback(params) self.assertTrue(result['success']) self.assertEqual(result['order'], order1) - order1 = Order.objects.get(id=order1.id) # reload from DB to capture side-effect of process_postpay_callback + order1 = Order.objects.get(id=order1.id) # reload from DB to capture side-effect of process_postpay_callback self.assertEqual(order1.status, 'purchased') self.assertFalse(result['error_html']) @@ -284,4 +285,4 @@ class CyberSourceTests(TestCase): self.assertFalse(result['success']) self.assertEqual(result['order'], order1) self.assertEqual(order1.status, 'cart') - self.assertIn(REASONCODE_MAP['207'], result['error_html']) \ No newline at end of file + self.assertIn(REASONCODE_MAP['207'], result['error_html']) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index fa8345f33e..ce94ca8428 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -12,6 +12,7 @@ from .processors import process_postpay_callback, render_purchase_form_html log = logging.getLogger("shoppingcart") + def test(request, course_id): item1 = PaidCourseRegistration(course_id, 200) item1.purchased_callback(request.user.id) @@ -41,6 +42,7 @@ def register_for_verified_cert(request, course_id): CertificateItem.add_to_order(cart, course_id, 30, 'verified') return HttpResponse("Added") + @login_required def show_cart(request): cart = Order.get_cart_for_user(request.user) @@ -54,12 +56,14 @@ def show_cart(request): 'form_html': form_html, }) + @login_required def clear_cart(request): cart = Order.get_cart_for_user(request.user) cart.clear() return HttpResponse('Cleared') + @login_required def remove_item(request): item_id = request.REQUEST.get('id', '-1') @@ -71,6 +75,7 @@ def remove_item(request): log.exception('Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(item_id)) return HttpResponse('OK') + @csrf_exempt def postpay_callback(request): """ @@ -87,9 +92,10 @@ def postpay_callback(request): if result['success']: return HttpResponseRedirect(reverse('shoppingcart.views.show_receipt', args=[result['order'].id])) else: - return render_to_response('shoppingcart/error.html', {'order':result['order'], + return render_to_response('shoppingcart/error.html', {'order': result['order'], 'error_html': result['error_html']}) + @login_required def show_receipt(request, ordernum): """ From 9798d020d101b760751b602399410d1e07101a56 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 11:10:47 -0400 Subject: [PATCH 114/244] Clean up views and models. --- lms/djangoapps/shoppingcart/models.py | 16 +++++++++++++++- lms/djangoapps/shoppingcart/views.py | 9 +++++++-- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 895f466273..a738dd2107 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -6,7 +6,7 @@ from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User from django.utils.translation import ugettext as _ from model_utils.managers import InheritanceManager -from courseware.courses import course_image_url, get_course_about_section +from courseware.courses import get_course_about_section from xmodule.modulestore.django import modulestore from xmodule.course_module import CourseDescriptor @@ -66,6 +66,7 @@ class Order(models.Model): @property def total_cost(self): + """ Return the total cost of the order """ return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) def clear(self): @@ -79,6 +80,19 @@ class Order(models.Model): """ Call to mark this order as purchased. Iterates through its OrderItems and calls their purchased_callback + + `first` - first name of person billed (e.g. John) + `last` - last name of person billed (e.g. Smith) + `street1` - first line of a street address of the billing address (e.g. 11 Cambridge Center) + `street2` - second line of a street address of the billing address (e.g. Suite 101) + `city` - city of the billing address (e.g. Cambridge) + `state` - code of the state, province, or territory of the billing address (e.g. MA) + `postalcode` - postal code of the billing address (e.g. 02142) + `country` - country code of the billing address (e.g. US) + `ccnum` - last 4 digits of the credit card number of the credit card billed (e.g. 1111) + `cardtype` - 3-digit code representing the card type used (e.g. 001) + `processor_reply_dump` - all the parameters returned by the processor + """ self.status = 'purchased' self.purchase_time = datetime.now(pytz.utc) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index ce94ca8428..8e56971d47 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -1,13 +1,14 @@ import logging from django.http import HttpResponse, HttpResponseRedirect, HttpResponseNotFound, HttpResponseForbidden, Http404 from django.utils.translation import ugettext as _ +from django.views.decorators.http import require_POST from django.core.urlresolvers import reverse from django.views.decorators.csrf import csrf_exempt from django.contrib.auth.decorators import login_required from student.models import CourseEnrollment from xmodule.modulestore.exceptions import ItemNotFoundError from mitxmako.shortcuts import render_to_response -from .models import * +from .models import Order, PaidCourseRegistration, CertificateItem, OrderItem from .processors import process_postpay_callback, render_purchase_form_html log = logging.getLogger("shoppingcart") @@ -38,6 +39,9 @@ def add_course_to_cart(request, course_id): @login_required def register_for_verified_cert(request, course_id): + """ + Add a CertificateItem to the cart + """ cart = Order.get_cart_for_user(request.user) CertificateItem.add_to_order(cart, course_id, 30, 'verified') return HttpResponse("Added") @@ -77,6 +81,7 @@ def remove_item(request): @csrf_exempt +@require_POST def postpay_callback(request): """ Receives the POST-back from processor. @@ -111,7 +116,7 @@ def show_receipt(request, ordernum): raise Http404('Order not found!') order_items = order.orderitem_set.all() - any_refunds = "refunded" in [i.status for i in order_items] + any_refunds = any(i.status == "refunded" for i in order_items) return render_to_response('shoppingcart/receipt.html', {'order': order, 'order_items': order_items, 'any_refunds': any_refunds}) From 1bff390ba893afd3d93214d534af9df05c3d44b0 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 09:58:13 -0700 Subject: [PATCH 115/244] 100% coverage on shoppingcart/models.py --- lms/djangoapps/shoppingcart/models.py | 10 +++- lms/djangoapps/shoppingcart/tests.py | 82 ++++++++++++++++++++++++++- 2 files changed, 89 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index a738dd2107..69ae0311a4 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -220,11 +220,17 @@ class PaidCourseRegistration(OrderItem): in CourseEnrollmentAllowed will the user be allowed to enroll. Otherwise requiring payment would in fact be quite silly since there's a clear back door. """ - course_loc = CourseDescriptor.id_to_location(self.course_id) - course_exists = modulestore().has_item(self.course_id, course_loc) + try: + course_loc = CourseDescriptor.id_to_location(self.course_id) + course_exists = modulestore().has_item(self.course_id, course_loc) + except ValueError: + raise PurchasedCallbackException( + "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + if not course_exists: raise PurchasedCallbackException( "The customer purchased Course {0}, but that course doesn't exist!".format(self.course_id)) + CourseEnrollment.enroll(user=self.user, course_id=self.course_id, mode=self.mode) log.info("Enrolled {0} in paid course {1}, paid ${2}".format(self.user.email, self.course_id, self.line_cost)) diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 61a10f2f75..5754d2173d 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -4,9 +4,15 @@ Tests for the Shopping Cart from factory import DjangoModelFactory from django.test import TestCase -from shoppingcart.models import Order, CertificateItem, InvalidCartItem +from django.test.utils import override_settings +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration from student.tests.factories import UserFactory from student.models import CourseEnrollment +from course_modes.models import CourseMode +from .exceptions import PurchasedCallbackException class OrderTest(TestCase): @@ -69,6 +75,80 @@ class OrderTest(TestCase): self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) +class OrderItemTest(TestCase): + def setUp(self): + self.user = UserFactory.create() + + def test_orderItem_purchased_callback(self): + """ + This tests that calling purchased_callback on the base OrderItem class raises NotImplementedError + """ + item = OrderItem(user=self.user, order=Order.get_cart_for_user(self.user)) + with self.assertRaises(NotImplementedError): + item.purchased_callback() + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class PaidCourseRegistrationTest(ModuleStoreTestCase): + def setUp(self): + self.user = UserFactory.create() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.cart = Order.get_cart_for_user(self.user) + + def test_add_to_order(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + + self.assertEqual(reg1.unit_cost, self.cost) + self.assertEqual(reg1.line_cost, self.cost) + self.assertEqual(reg1.unit_cost, self.course_mode.min_price) + self.assertEqual(reg1.mode, "honor") + self.assertEqual(reg1.user, self.user) + self.assertEqual(reg1.status, "cart") + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + self.assertFalse(PaidCourseRegistration.part_of_order(self.cart, self.course_id+"abcd")) + self.assertEqual(self.cart.total_cost, self.cost) + + def test_add_with_default_mode(self): + """ + Tests add_to_cart where the mode specified in the argument is NOT in the database + and NOT the default "honor". In this case it just adds the user in the CourseMode.DEFAULT_MODE, 0 price + """ + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id, mode_slug="DNE") + + self.assertEqual(reg1.unit_cost, 0) + self.assertEqual(reg1.line_cost, 0) + self.assertEqual(reg1.mode, "honor") + self.assertEqual(reg1.user, self.user) + self.assertEqual(reg1.status, "cart") + self.assertEqual(self.cart.total_cost, 0) + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + + def test_purchased_callback(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + reg1.purchased_callback() + self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + def test_purchased_callback_exception(self): + reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + reg1.course_id = "changedforsomereason" + reg1.save() + with self.assertRaises(PurchasedCallbackException): + reg1.purchased_callback() + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + reg1.course_id = "abc/efg/hij" + reg1.save() + with self.assertRaises(PurchasedCallbackException): + reg1.purchased_callback() + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + + class CertificateItemTest(TestCase): """ Tests for verifying specific CertificateItem functionality From ee10cf7e96bea8b3c10e0dce7c25d34a91282d0d Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 10:06:14 -0700 Subject: [PATCH 116/244] minor changes to PaidCourseRegistrationTest.test_purchased_callback --- lms/djangoapps/shoppingcart/tests.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 5754d2173d..10b59deee6 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -131,8 +131,10 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): def test_purchased_callback(self): reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) - reg1.purchased_callback() + self.cart.purchase() self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + reg1 = PaidCourseRegistration.objects.get(id=reg1.id) # reload from DB to get side-effect + self.assertEqual(reg1.status, "purchased") def test_purchased_callback_exception(self): reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) From e30ebf504171f1fab34f620f9d00fb8d5a91dcac Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 10:31:18 -0700 Subject: [PATCH 117/244] move currency formatting into template --- lms/djangoapps/shoppingcart/views.py | 3 +-- lms/templates/shoppingcart/list.html | 2 +- 2 files changed, 2 insertions(+), 3 deletions(-) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 8e56971d47..be363f1422 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -51,12 +51,11 @@ def register_for_verified_cert(request, course_id): def show_cart(request): cart = Order.get_cart_for_user(request.user) total_cost = cart.total_cost - amount = "{0:0.2f}".format(total_cost) cart_items = cart.orderitem_set.all() form_html = render_purchase_form_html(cart) return render_to_response("shoppingcart/list.html", {'shoppingcart_items': cart_items, - 'amount': amount, + 'amount': total_cost, 'form_html': form_html, }) diff --git a/lms/templates/shoppingcart/list.html b/lms/templates/shoppingcart/list.html index 0754cac311..cf452baab0 100644 --- a/lms/templates/shoppingcart/list.html +++ b/lms/templates/shoppingcart/list.html @@ -21,7 +21,7 @@ [x] % endfor ${_("Total Amount")} - ${amount} + ${"{0:0.2f}".format(amount)} From 6c19f3a7adf7fe430be98932577806eba9308bbf Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 13:53:58 -0400 Subject: [PATCH 118/244] Add jsinput_spec back in. --- common/static/js/capa/spec/jsinput_spec.js | 70 ++++++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 common/static/js/capa/spec/jsinput_spec.js diff --git a/common/static/js/capa/spec/jsinput_spec.js b/common/static/js/capa/spec/jsinput_spec.js new file mode 100644 index 0000000000..a4a4f6e57d --- /dev/null +++ b/common/static/js/capa/spec/jsinput_spec.js @@ -0,0 +1,70 @@ +xdescribe("A jsinput has:", function () { + + beforeEach(function () { + $('#fixture').remove(); + $.ajax({ + async: false, + url: 'mainfixture.html', + success: function(data) { + $('body').append($(data)); + } + }); + }); + + + + describe("The jsinput constructor", function(){ + + var iframe1 = $(document).find('iframe')[0]; + + var testJsElem = jsinputConstructor({ + id: 1, + elem: iframe1, + passive: false + }); + + it("Returns an object", function(){ + expect(typeof(testJsElem)).toEqual('object'); + }); + + it("Adds the object to the jsinput array", function() { + expect(jsinput.exists(1)).toBe(true); + }); + + describe("The returned object", function() { + + it("Has a public 'update' method", function(){ + expect(testJsElem.update).toBeDefined(); + }); + + it("Returns an 'update' that is idempotent", function(){ + var orig = testJsElem.update(); + for (var i = 0; i++; i < 5) { + expect(testJsElem.update()).toEqual(orig); + } + }); + + it("Changes the parent's inputfield", function() { + testJsElem.update(); + + }); + }); + }); + + + describe("The walkDOM functions", function() { + + walkDOM(); + + it("Creates (at least) one object per iframe", function() { + jsinput.arr.length >= 2; + }); + + it("Does not create multiple objects with the same id", function() { + while (jsinput.arr.length > 0) { + var elem = jsinput.arr.pop(); + expect(jsinput.exists(elem.id)).toBe(false); + } + }); + }); +}) From 3a0a56f3a98b9834718db814428d5eb50f5ff83c Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 14:49:18 -0400 Subject: [PATCH 119/244] Remove line_cost from OrderItem --- ...003_auto__del_field_orderitem_line_cost.py | 113 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 9 +- lms/djangoapps/shoppingcart/tests.py | 5 +- 3 files changed, 122 insertions(+), 5 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py new file mode 100644 index 0000000000..8402248aae --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py @@ -0,0 +1,113 @@ +# -*- 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): + # Deleting field 'OrderItem.line_cost' + db.delete_column('shoppingcart_orderitem', 'line_cost') + + + def backwards(self, orm): + # Adding field 'OrderItem.line_cost' + db.add_column('shoppingcart_orderitem', 'line_cost', + self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2), + keep_default=False) + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", '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'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 69ae0311a4..4387a8352c 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -133,14 +133,19 @@ class OrderItem(models.Model): status = models.CharField(max_length=32, default='cart', choices=ORDER_STATUSES) qty = models.IntegerField(default=1) unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) - line_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) # qty * unit_cost line_desc = models.CharField(default="Misc. Item", max_length=1024) currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes + @property + def line_cost(self): + return self.qty * self.unit_cost + @classmethod def add_to_order(cls, *args, **kwargs): """ A suggested convenience function for subclasses. + + NOTE: This does not add anything items to the cart. That is left up to the subclasses """ # this is a validation step to verify that the currency of the item we # are adding is the same as the currency of the order we are adding it @@ -204,7 +209,6 @@ class PaidCourseRegistration(OrderItem): item.mode = course_mode.slug item.qty = 1 item.unit_cost = cost - item.line_cost = cost item.line_desc = 'Registration for Course: {0}. Mode: {1}'.format(get_course_about_section(course, "title"), course_mode.name) item.currency = currency @@ -283,7 +287,6 @@ class CertificateItem(OrderItem): item.status = order.status item.qty = 1 item.unit_cost = cost - item.line_cost = cost item.line_desc = "{mode} certificate for course {course_id}".format(mode=item.mode, course_id=course_id) item.currency = currency diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests.py index 10b59deee6..39d0f0b301 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests.py @@ -87,6 +87,7 @@ class OrderItemTest(TestCase): with self.assertRaises(NotImplementedError): item.purchased_callback() + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class PaidCourseRegistrationTest(ModuleStoreTestCase): def setUp(self): @@ -111,7 +112,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): self.assertEqual(reg1.user, self.user) self.assertEqual(reg1.status, "cart") self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) - self.assertFalse(PaidCourseRegistration.part_of_order(self.cart, self.course_id+"abcd")) + self.assertFalse(PaidCourseRegistration.part_of_order(self.cart, self.course_id + "abcd")) self.assertEqual(self.cart.total_cost, self.cost) def test_add_with_default_mode(self): @@ -133,7 +134,7 @@ class PaidCourseRegistrationTest(ModuleStoreTestCase): reg1 = PaidCourseRegistration.add_to_order(self.cart, self.course_id) self.cart.purchase() self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) - reg1 = PaidCourseRegistration.objects.get(id=reg1.id) # reload from DB to get side-effect + reg1 = PaidCourseRegistration.objects.get(id=reg1.id) # reload from DB to get side-effect self.assertEqual(reg1.status, "purchased") def test_purchased_callback_exception(self): From dee127360b7670da2cbc7691416d5af71e5e3a55 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 15:18:21 -0400 Subject: [PATCH 120/244] Clean up models, add some error handling --- common/djangoapps/course_modes/models.py | 2 +- .../shoppingcart/migrations/0001_initial.py | 4 +--- ...__add_field_paidcourseregistration_mode.py | 4 +--- ...003_auto__del_field_orderitem_line_cost.py | 4 +--- lms/djangoapps/shoppingcart/models.py | 19 +++++++++++-------- 5 files changed, 15 insertions(+), 18 deletions(-) diff --git a/common/djangoapps/course_modes/models.py b/common/djangoapps/course_modes/models.py index 6362b7061f..7a5e711f44 100644 --- a/common/djangoapps/course_modes/models.py +++ b/common/djangoapps/course_modes/models.py @@ -62,7 +62,7 @@ class CourseMode(models.Model): """ modes = cls.modes_for_course(course_id) - matched = filter(lambda m: m.slug == mode_slug, modes) + matched = [m for m in modes if m.slug == mode_slug] if matched: return matched[0] else: diff --git a/lms/djangoapps/shoppingcart/migrations/0001_initial.py b/lms/djangoapps/shoppingcart/migrations/0001_initial.py index ea6a250f77..24ffeb1e59 100644 --- a/lms/djangoapps/shoppingcart/migrations/0001_initial.py +++ b/lms/djangoapps/shoppingcart/migrations/0001_initial.py @@ -59,7 +59,6 @@ class Migration(SchemaMigration): )) db.send_create_signal('shoppingcart', ['CertificateItem']) - def backwards(self, orm): # Deleting model 'Order' db.delete_table('shoppingcart_order') @@ -73,7 +72,6 @@ class Migration(SchemaMigration): # Deleting model 'CertificateItem' db.delete_table('shoppingcart_certificateitem') - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, @@ -165,4 +163,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['shoppingcart'] \ No newline at end of file + complete_apps = ['shoppingcart'] diff --git a/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py index 1a6730c769..97f46aee81 100644 --- a/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py +++ b/lms/djangoapps/shoppingcart/migrations/0002_auto__add_field_paidcourseregistration_mode.py @@ -13,12 +13,10 @@ class Migration(SchemaMigration): self.gf('django.db.models.fields.SlugField')(default='honor', max_length=50), keep_default=False) - def backwards(self, orm): # Deleting field 'PaidCourseRegistration.mode' db.delete_column('shoppingcart_paidcourseregistration', 'mode') - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, @@ -111,4 +109,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['shoppingcart'] \ No newline at end of file + complete_apps = ['shoppingcart'] diff --git a/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py index 8402248aae..080a6f1af2 100644 --- a/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py +++ b/lms/djangoapps/shoppingcart/migrations/0003_auto__del_field_orderitem_line_cost.py @@ -11,14 +11,12 @@ class Migration(SchemaMigration): # Deleting field 'OrderItem.line_cost' db.delete_column('shoppingcart_orderitem', 'line_cost') - def backwards(self, orm): # Adding field 'OrderItem.line_cost' db.add_column('shoppingcart_orderitem', 'line_cost', self.gf('django.db.models.fields.DecimalField')(default=0.0, max_digits=30, decimal_places=2), keep_default=False) - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, @@ -110,4 +108,4 @@ class Migration(SchemaMigration): } } - complete_apps = ['shoppingcart'] \ No newline at end of file + complete_apps = ['shoppingcart'] diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 4387a8352c..415a9ebe50 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -5,6 +5,7 @@ from django.db import models from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User from django.utils.translation import ugettext as _ +from django.db import transaction from model_utils.managers import InheritanceManager from courseware.courses import get_course_about_section @@ -113,8 +114,8 @@ class Order(models.Model): orderitems = OrderItem.objects.filter(order=self).select_subclasses() for item in orderitems: item.status = 'purchased' - item.purchased_callback() item.save() + item.purchased_callback() class OrderItem(models.Model): @@ -138,23 +139,23 @@ class OrderItem(models.Model): @property def line_cost(self): + """ Return the total cost of this OrderItem """ return self.qty * self.unit_cost @classmethod - def add_to_order(cls, *args, **kwargs): + def add_to_order(cls, order, *args, **kwargs): """ A suggested convenience function for subclasses. - NOTE: This does not add anything items to the cart. That is left up to the subclasses + NOTE: This does not add anything to the cart. That is left up to the + subclasses to implement for themselves """ # this is a validation step to verify that the currency of the item we # are adding is the same as the currency of the order we are adding it # to - if isinstance(args[0], Order): - currency = kwargs['currency'] if 'currency' in kwargs else 'usd' - order = args[0] - if order.currency != currency and order.orderitem_set.count() > 0: - raise InvalidCartItem(_("Trying to add a different currency into the cart")) + currency = kwargs.get('currency', 'usd') + if order.currency != currency and order.orderitem_set.exists(): + raise InvalidCartItem(_("Trying to add a different currency into the cart")) def purchased_callback(self): """ @@ -180,6 +181,7 @@ class PaidCourseRegistration(OrderItem): for item in order.orderitem_set.all().select_subclasses("paidcourseregistration")] @classmethod + @transaction.commit_on_success def add_to_order(cls, order, course_id, mode_slug=CourseMode.DEFAULT_MODE_SLUG, cost=None, currency=None): """ A standardized way to create these objects, with sensible defaults filled in. @@ -254,6 +256,7 @@ class CertificateItem(OrderItem): mode = models.SlugField() @classmethod + @transaction.commit_on_success def add_to_order(cls, order, course_id, cost, mode, currency='usd'): """ Add a CertificateItem to an order From e8db9e8f2a78b82a2e2e615e17e0096a6967cf4e Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Wed, 21 Aug 2013 16:40:42 -0400 Subject: [PATCH 121/244] Make each item purchase transaction atomic and add the ability to record the fulfillment time --- ...uto__add_field_orderitem_fulfilled_time.py | 114 ++++++++++++++++++ lms/djangoapps/shoppingcart/models.py | 18 ++- 2 files changed, 129 insertions(+), 3 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py diff --git a/lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py b/lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py new file mode 100644 index 0000000000..bbaf185184 --- /dev/null +++ b/lms/djangoapps/shoppingcart/migrations/0004_auto__add_field_orderitem_fulfilled_time.py @@ -0,0 +1,114 @@ +# -*- 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 field 'OrderItem.fulfilled_time' + db.add_column('shoppingcart_orderitem', 'fulfilled_time', + self.gf('django.db.models.fields.DateTimeField')(null=True), + keep_default=False) + + + def backwards(self, orm): + # Deleting field 'OrderItem.fulfilled_time' + db.delete_column('shoppingcart_orderitem', 'fulfilled_time') + + + 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'}) + }, + 'shoppingcart.certificateitem': { + 'Meta': {'object_name': 'CertificateItem', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_enrollment': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['student.CourseEnrollment']"}), + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'shoppingcart.order': { + 'Meta': {'object_name': 'Order'}, + 'bill_to_cardtype': ('django.db.models.fields.CharField', [], {'max_length': '32', 'blank': 'True'}), + 'bill_to_ccnum': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_city': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_country': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_first': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_last': ('django.db.models.fields.CharField', [], {'max_length': '64', 'blank': 'True'}), + 'bill_to_postalcode': ('django.db.models.fields.CharField', [], {'max_length': '16', 'blank': 'True'}), + 'bill_to_state': ('django.db.models.fields.CharField', [], {'max_length': '8', 'blank': 'True'}), + 'bill_to_street1': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'bill_to_street2': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'processor_reply_dump': ('django.db.models.fields.TextField', [], {'blank': 'True'}), + 'purchase_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True', 'blank': 'True'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.orderitem': { + 'Meta': {'object_name': 'OrderItem'}, + 'currency': ('django.db.models.fields.CharField', [], {'default': "'usd'", 'max_length': '8'}), + 'fulfilled_time': ('django.db.models.fields.DateTimeField', [], {'null': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'line_desc': ('django.db.models.fields.CharField', [], {'default': "'Misc. Item'", 'max_length': '1024'}), + 'order': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['shoppingcart.Order']"}), + 'qty': ('django.db.models.fields.IntegerField', [], {'default': '1'}), + 'status': ('django.db.models.fields.CharField', [], {'default': "'cart'", 'max_length': '32'}), + 'unit_cost': ('django.db.models.fields.DecimalField', [], {'default': '0.0', 'max_digits': '30', 'decimal_places': '2'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + }, + 'shoppingcart.paidcourseregistration': { + 'Meta': {'object_name': 'PaidCourseRegistration', '_ormbases': ['shoppingcart.OrderItem']}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'mode': ('django.db.models.fields.SlugField', [], {'default': "'honor'", 'max_length': '50'}), + 'orderitem_ptr': ('django.db.models.fields.related.OneToOneField', [], {'to': "orm['shoppingcart.OrderItem']", 'unique': 'True', 'primary_key': 'True'}) + }, + 'student.courseenrollment': { + 'Meta': {'ordering': "('user', 'course_id')", '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'}), + 'is_active': ('django.db.models.fields.BooleanField', [], {'default': 'True'}), + 'mode': ('django.db.models.fields.CharField', [], {'default': "'honor'", 'max_length': '100'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']"}) + } + } + + complete_apps = ['shoppingcart'] \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 415a9ebe50..490aac23a4 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -108,14 +108,14 @@ class Order(models.Model): self.bill_to_ccnum = ccnum self.bill_to_cardtype = cardtype self.processor_reply_dump = processor_reply_dump + # save these changes on the order, then we can tell when we are in an + # inconsistent state self.save() # this should return all of the objects with the correct types of the # subclasses orderitems = OrderItem.objects.filter(order=self).select_subclasses() for item in orderitems: - item.status = 'purchased' - item.save() - item.purchased_callback() + item.purchase_item() class OrderItem(models.Model): @@ -136,6 +136,7 @@ class OrderItem(models.Model): unit_cost = models.DecimalField(default=0.0, decimal_places=2, max_digits=30) line_desc = models.CharField(default="Misc. Item", max_length=1024) currency = models.CharField(default="usd", max_length=8) # lower case ISO currency codes + fulfilled_time = models.DateTimeField(null=True) @property def line_cost(self): @@ -157,6 +158,17 @@ class OrderItem(models.Model): if order.currency != currency and order.orderitem_set.exists(): raise InvalidCartItem(_("Trying to add a different currency into the cart")) + @transaction.commit_on_success + def purchase_item(self): + """ + This is basically a wrapper around purchased_callback that handles + modifying the OrderItem itself + """ + self.purchased_callback() + self.status = 'purchased' + self.fulfilled_time = datetime.now(pytz.utc) + self.save() + def purchased_callback(self): """ This is called on each inventory item in the shopping cart when the From 2c4b1e17b45a28103a1c0f6a0d2901b940123948 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 13:30:52 -0700 Subject: [PATCH 122/244] started view tests --- lms/djangoapps/shoppingcart/tests/__init__.py | 0 .../{tests.py => tests/test_models.py} | 4 +-- .../shoppingcart/tests/test_views.py | 34 +++++++++++++++++++ lms/djangoapps/shoppingcart/urls.py | 3 +- lms/djangoapps/shoppingcart/views.py | 6 ---- 5 files changed, 37 insertions(+), 10 deletions(-) create mode 100644 lms/djangoapps/shoppingcart/tests/__init__.py rename lms/djangoapps/shoppingcart/{tests.py => tests/test_models.py} (98%) create mode 100644 lms/djangoapps/shoppingcart/tests/test_views.py diff --git a/lms/djangoapps/shoppingcart/tests/__init__.py b/lms/djangoapps/shoppingcart/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/shoppingcart/tests.py b/lms/djangoapps/shoppingcart/tests/test_models.py similarity index 98% rename from lms/djangoapps/shoppingcart/tests.py rename to lms/djangoapps/shoppingcart/tests/test_models.py index 39d0f0b301..f15edfed44 100644 --- a/lms/djangoapps/shoppingcart/tests.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -1,5 +1,5 @@ """ -Tests for the Shopping Cart +Tests for the Shopping Cart Models """ from factory import DjangoModelFactory @@ -12,7 +12,7 @@ from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartIt from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode -from .exceptions import PurchasedCallbackException +from ..exceptions import PurchasedCallbackException class OrderTest(TestCase): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py new file mode 100644 index 0000000000..a05096ab92 --- /dev/null +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -0,0 +1,34 @@ +""" +Tests for Shopping Cart views +""" +from django.test import TestCase +from django.test.utils import override_settings +from django.core.urlresolvers import reverse + +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from shoppingcart.views import add_course_to_cart +from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration +from student.tests.factories import UserFactory +from student.models import CourseEnrollment +from course_modes.models import CourseMode +from ..exceptions import PurchasedCallbackException + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, DEBUG=True) +class ShoppingCartViewsTests(ModuleStoreTestCase): + def setUp(self): + self.user = UserFactory.create() + self.course_id = "MITx/999/Robot_Super_Course" + self.cost = 40 + self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') + self.course_mode = CourseMode(course_id=self.course_id, + mode_slug="honor", + mode_display_name="honor cert", + min_price=self.cost) + self.course_mode.save() + self.cart = Order.get_cart_for_user(self.user) + + def test_add_course_to_cart_anon(self): + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 403) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 7893d29c20..8818a10c06 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -16,8 +16,7 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: if settings.DEBUG: urlpatterns += patterns( 'shoppingcart.views', - url(r'^(?P[^/]+/[^/]+/[^/]+)/$', 'test'), - url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart'), + url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', 'register_for_verified_cert'), ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index be363f1422..39efab4771 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -14,12 +14,6 @@ from .processors import process_postpay_callback, render_purchase_form_html log = logging.getLogger("shoppingcart") -def test(request, course_id): - item1 = PaidCourseRegistration(course_id, 200) - item1.purchased_callback(request.user.id) - return HttpResponse('OK') - - def add_course_to_cart(request, course_id): if not request.user.is_authenticated(): return HttpResponseForbidden(_('You must be logged-in to add to a shopping cart')) From 1202b77d7dd7cd93e66290e204be190a37c0713a Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 18:56:03 -0700 Subject: [PATCH 123/244] shopping cart view tests. coverage full except for debug line --- lms/djangoapps/shoppingcart/models.py | 5 +- .../shoppingcart/tests/test_views.py | 193 ++++++++++++++++++ lms/djangoapps/shoppingcart/views.py | 2 +- lms/envs/test.py | 2 + 4 files changed, 200 insertions(+), 2 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 490aac23a4..1ad71ff625 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -67,7 +67,10 @@ class Order(models.Model): @property def total_cost(self): - """ Return the total cost of the order """ + """ + Return the total cost of the cart. If the order has been purchased, returns total of + all purchased and not refunded items. + """ return sum(i.line_cost for i in self.orderitem_set.filter(status=self.status)) def clear(self): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index a05096ab92..96bcef6fcd 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -1,12 +1,16 @@ """ Tests for Shopping Cart views """ +from urlparse import urlparse + from django.test import TestCase from django.test.utils import override_settings from django.core.urlresolvers import reverse +from django.utils.translation import ugettext as _ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from xmodule.modulestore.exceptions import ItemNotFoundError from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from shoppingcart.views import add_course_to_cart from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartItem, PaidCourseRegistration @@ -14,11 +18,28 @@ from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode from ..exceptions import PurchasedCallbackException +from mitxmako.shortcuts import render_to_response +from shoppingcart.processors import render_purchase_form_html, process_postpay_callback +from mock import patch, Mock + +def mock_render_purchase_form_html(*args, **kwargs): + return render_purchase_form_html(*args, **kwargs) + +form_mock = Mock(side_effect=mock_render_purchase_form_html) + +def mock_render_to_response(*args, **kwargs): + return render_to_response(*args, **kwargs) + +render_mock = Mock(side_effect=mock_render_to_response) + +postpay_mock = Mock() @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, DEBUG=True) class ShoppingCartViewsTests(ModuleStoreTestCase): def setUp(self): self.user = UserFactory.create() + self.user.set_password('password') + self.user.save() self.course_id = "MITx/999/Robot_Super_Course" self.cost = 40 self.course = CourseFactory.create(org='MITx', number='999', display_name='Robot Super Course') @@ -29,6 +50,178 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.course_mode.save() self.cart = Order.get_cart_for_user(self.user) + def login_user(self): + self.client.login(username=self.user.username, password="password") + + def test_add_course_to_cart_anon(self): resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) self.assertEqual(resp.status_code, 403) + + def test_add_course_to_cart_already_in_cart(self): + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 404) + self.assertIn(_('The course {0} is already in your cart.'.format(self.course_id)), resp.content) + + def test_add_course_to_cart_already_registered(self): + CourseEnrollment.enroll(self.user, self.course_id) + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 404) + self.assertIn(_('You are already registered in course {0}.'.format(self.course_id)), resp.content) + + def test_add_nonexistent_course_to_cart(self): + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=['non/existent/course'])) + self.assertEqual(resp.status_code, 404) + self.assertIn(_("The course you requested does not exist."), resp.content) + + def test_add_course_to_cart_success(self): + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) + self.assertEqual(resp.status_code, 200) + self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) + + def test_register_for_verified_cert(self): + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.register_for_verified_cert', args=[self.course_id])) + self.assertEqual(resp.status_code, 200) + self.assertIn(self.course_id, [ci.course_id for ci in + self.cart.orderitem_set.all().select_subclasses('certificateitem')]) + + @patch('shoppingcart.views.render_purchase_form_html', form_mock) + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_cart(self): + self.login_user() + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + resp = self.client.get(reverse('shoppingcart.views.show_cart', args=[])) + self.assertEqual(resp.status_code, 200) + + ((purchase_form_arg_cart,), _) = form_mock.call_args + purchase_form_arg_cart_items = purchase_form_arg_cart.orderitem_set.all().select_subclasses() + self.assertIn(reg_item, purchase_form_arg_cart_items) + self.assertIn(cert_item, purchase_form_arg_cart_items) + self.assertEqual(len(purchase_form_arg_cart_items), 2) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/list.html') + self.assertEqual(len(context['shoppingcart_items']), 2) + self.assertEqual(context['amount'], 80) + self.assertIn("80.00", context['form_html']) + + def test_clear_cart(self): + self.login_user() + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.assertEquals(self.cart.orderitem_set.count(), 2) + resp = self.client.post(reverse('shoppingcart.views.clear_cart', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 0) + + @patch('shoppingcart.views.log.exception') + def test_remove_item(self, exception_log): + self.login_user() + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.assertEquals(self.cart.orderitem_set.count(), 2) + resp = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': reg_item.id}) + self.assertEqual(resp.status_code, 200) + self.assertEquals(self.cart.orderitem_set.count(), 1) + self.assertNotIn(reg_item, self.cart.orderitem_set.all().select_subclasses()) + + self.cart.purchase() + resp2 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': cert_item.id}) + self.assertEqual(resp2.status_code, 200) + exception_log.assert_called_with( + 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(cert_item.id)) + + resp3 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), + {'id': -1}) + self.assertEqual(resp3.status_code, 200) + exception_log.assert_called_with( + 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(-1)) + + @patch('shoppingcart.views.process_postpay_callback', postpay_mock) + def test_postpay_callback_success(self): + postpay_mock.return_value = {'success': True, 'order': self.cart} + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) + self.assertEqual(resp.status_code, 302) + self.assertEqual(urlparse(resp.__getitem__('location')).path, + reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + + @patch('shoppingcart.views.process_postpay_callback', postpay_mock) + @patch('shoppingcart.views.render_to_response', render_mock) + def test_postpay_callback_failure(self): + postpay_mock.return_value = {'success': False, 'order': self.cart, 'error_html':'ERROR_TEST!!!'} + self.login_user() + resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) + self.assertEqual(resp.status_code, 200) + self.assertIn('ERROR_TEST!!!', resp.content) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/error.html') + self.assertEqual(context['order'], self.cart) + self.assertEqual(context['error_html'], 'ERROR_TEST!!!') + + def test_show_receipt_404s(self): + PaidCourseRegistration.add_to_order(self.cart, self.course_id) + CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.cart.purchase() + + user2 = UserFactory.create() + cart2 = Order.get_cart_for_user(user2) + PaidCourseRegistration.add_to_order(cart2, self.course_id) + cart2.purchase() + + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[cart2.id])) + self.assertEqual(resp.status_code, 404) + + resp2 = self.client.get(reverse('shoppingcart.views.show_receipt', args=[1000])) + self.assertEqual(resp2.status_code, 404) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_receipt_success(self): + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + self.assertEqual(resp.status_code, 200) + self.assertIn('FirstNameTesting123', resp.content) + self.assertIn('StreetTesting123', resp.content) + self.assertIn('80.00', resp.content) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/receipt.html') + self.assertEqual(context['order'], self.cart) + self.assertIn(reg_item.orderitem_ptr, context['order_items']) + self.assertIn(cert_item.orderitem_ptr, context['order_items']) + self.assertFalse(context['any_refunds']) + + @patch('shoppingcart.views.render_to_response', render_mock) + def test_show_receipt_success_refund(self): + reg_item = PaidCourseRegistration.add_to_order(self.cart, self.course_id) + cert_item = CertificateItem.add_to_order(self.cart, 'test/course1', self.cost, 'verified') + self.cart.purchase(first='FirstNameTesting123', street1='StreetTesting123') + cert_item.status = "refunded" + cert_item.save() + self.assertEqual(self.cart.total_cost, 40) + self.login_user() + resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) + self.assertEqual(resp.status_code, 200) + self.assertIn('40.00', resp.content) + + ((template, context), _) = render_mock.call_args + self.assertEqual(template, 'shoppingcart/receipt.html') + self.assertEqual(context['order'], self.cart) + self.assertIn(reg_item.orderitem_ptr, context['order_items']) + self.assertIn(cert_item.orderitem_ptr, context['order_items']) + self.assertTrue(context['any_refunds']) \ No newline at end of file diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index 39efab4771..a99568b133 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -26,7 +26,7 @@ def add_course_to_cart(request, course_id): PaidCourseRegistration.add_to_order(cart, course_id) except ItemNotFoundError: return HttpResponseNotFound(_('The course you requested does not exist.')) - if request.method == 'GET': + if request.method == 'GET': ### This is temporary for testing purposes and will go away before we pull return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) return HttpResponse(_("Course added to cart.")) diff --git a/lms/envs/test.py b/lms/envs/test.py index bf2df444f4..a9c51310f6 100644 --- a/lms/envs/test.py +++ b/lms/envs/test.py @@ -32,6 +32,8 @@ MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True +MITX_FEATURES['ENABLE_SHOPPING_CART'] = True + # Need wiki for courseware views to work. TODO (vshnayder): shouldn't need it. WIKI_ENABLED = True From d7e311f5d2ff65e5216f92115c6ae3162edd8cb8 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Wed, 21 Aug 2013 19:17:31 -0700 Subject: [PATCH 124/244] remove DEBUG flag from cart addition urls--causing test failure --- lms/djangoapps/shoppingcart/tests/test_views.py | 2 +- lms/djangoapps/shoppingcart/urls.py | 6 +----- 2 files changed, 2 insertions(+), 6 deletions(-) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 96bcef6fcd..48c85900b0 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -34,7 +34,7 @@ render_mock = Mock(side_effect=mock_render_to_response) postpay_mock = Mock() -@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, DEBUG=True) +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class ShoppingCartViewsTests(ModuleStoreTestCase): def setUp(self): self.user = UserFactory.create() diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 8818a10c06..533714b719 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -11,12 +11,8 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: url(r'^$', 'show_cart'), url(r'^clear/$', 'clear_cart'), url(r'^remove_item/$', 'remove_item'), - ) - -if settings.DEBUG: - urlpatterns += patterns( - 'shoppingcart.views', url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', 'register_for_verified_cert'), + ) From 2466a5f557207e2a2eed0f209c92a5e1d933a73c Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Wed, 21 Aug 2013 17:52:50 -0400 Subject: [PATCH 125/244] Allow Studio to display error modules. --- common/lib/xmodule/xmodule/error_module.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/error_module.py b/common/lib/xmodule/xmodule/error_module.py index e7483f485a..027067f4c0 100644 --- a/common/lib/xmodule/xmodule/error_module.py +++ b/common/lib/xmodule/xmodule/error_module.py @@ -9,8 +9,7 @@ import json import sys from lxml import etree -from xmodule.x_module import XModule -from xmodule.editing_module import JSONEditingDescriptor +from xmodule.x_module import XModule, XModuleDescriptor from xmodule.errortracker import exc_info_to_str from xmodule.modulestore import Location from xblock.core import String, Scope @@ -70,12 +69,15 @@ class NonStaffErrorModule(ErrorFields, XModule): }) -class ErrorDescriptor(ErrorFields, JSONEditingDescriptor): +class ErrorDescriptor(ErrorFields, XModuleDescriptor): """ Module that provides a raw editing view of broken xml. """ module_class = ErrorModule + def get_html(self): + return '' + @classmethod def _construct(cls, system, contents, error_msg, location): From 569727e60898e585b62a2cf972914ac32924e9b5 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 22 Aug 2013 10:34:16 -0400 Subject: [PATCH 126/244] Pep8 fixes --- common/lib/xmodule/xmodule/course_module.py | 310 +++++++++--------- .../shoppingcart/tests/test_models.py | 15 +- .../shoppingcart/tests/test_views.py | 9 +- lms/djangoapps/shoppingcart/views.py | 2 +- 4 files changed, 173 insertions(+), 163 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 4555395fef..10bcf4a4e3 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -147,51 +147,51 @@ class TextbookList(List): class CourseFields(object): textbooks = TextbookList(help="List of pairs of (title, url) for textbooks used in this course", - default=[], scope=Scope.content) + default=[], scope=Scope.content) wiki_slug = String(help="Slug that points to the wiki for this course", scope=Scope.content) enrollment_start = Date(help="Date that enrollment for this class is opened", scope=Scope.settings) enrollment_end = Date(help="Date that enrollment for this class is closed", scope=Scope.settings) start = Date(help="Start time when this module is visible", - # using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the - # time of first invocation of this stmt on the server - default=datetime.fromtimestamp(0, UTC()), - scope=Scope.settings) + # using now(UTC()) resulted in fractional seconds which screwed up comparisons and anyway w/b the + # time of first invocation of this stmt on the server + default=datetime.fromtimestamp(0, UTC()), + scope=Scope.settings) end = Date(help="Date that this class ends", scope=Scope.settings) advertised_start = String(help="Date that this course is advertised to start", scope=Scope.settings) grading_policy = Dict(help="Grading policy definition for this class", - default={"GRADER": [ - { - "type": "Homework", - "min_count": 12, - "drop_count": 2, - "short_label": "HW", - "weight": 0.15 - }, - { - "type": "Lab", - "min_count": 12, - "drop_count": 2, - "weight": 0.15 - }, - { - "type": "Midterm Exam", - "short_label": "Midterm", - "min_count": 1, - "drop_count": 0, - "weight": 0.3 - }, - { - "type": "Final Exam", - "short_label": "Final", - "min_count": 1, - "drop_count": 0, - "weight": 0.4 - } - ], - "GRADE_CUTOFFS": { - "Pass": 0.5 - }}, - scope=Scope.content) + default={"GRADER": [ + { + "type": "Homework", + "min_count": 12, + "drop_count": 2, + "short_label": "HW", + "weight": 0.15 + }, + { + "type": "Lab", + "min_count": 12, + "drop_count": 2, + "weight": 0.15 + }, + { + "type": "Midterm Exam", + "short_label": "Midterm", + "min_count": 1, + "drop_count": 0, + "weight": 0.3 + }, + { + "type": "Final Exam", + "short_label": "Final", + "min_count": 1, + "drop_count": 0, + "weight": 0.4 + } + ], + "GRADE_CUTOFFS": { + "Pass": 0.5 + }}, + scope=Scope.content) show_calculator = Boolean(help="Whether to show the calculator in this course", default=False, scope=Scope.settings) display_name = String(help="Display name for this module", default="Empty", display_name="Display Name", scope=Scope.settings) show_chat = Boolean(help="Whether to show the chat widget in this course", default=False, scope=Scope.settings) @@ -201,7 +201,7 @@ class CourseFields(object): discussion_topics = Dict( help="Map of topics names to ids", scope=Scope.settings - ) + ) testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings) announcement = Date(help="Date this course is announced", scope=Scope.settings) cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings) @@ -216,128 +216,124 @@ class CourseFields(object): advanced_modules = List(help="Beta modules used in your course", scope=Scope.settings) has_children = True checklists = List(scope=Scope.settings, - default=[ - {"short_description" : "Getting Started With Studio", - "items" : [{"short_description": "Add Course Team Members", - "long_description": "Grant your collaborators permission to edit your course so you can work together.", - "is_checked": False, - "action_url": "ManageUsers", - "action_text": "Edit Course Team", - "action_external": False}, - {"short_description": "Set Important Dates for Your Course", - "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Details & Schedule", - "action_external": False}, - {"short_description": "Draft Your Course's Grading Policy", - "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.", - "is_checked": False, - "action_url": "SettingsGrading", - "action_text": "Edit Grading Settings", - "action_external": False}, - {"short_description": "Explore the Other Studio Checklists", - "long_description": "Discover other available course authoring tools, and find help when you need it.", - "is_checked": False, - "action_url": "", - "action_text": "", - "action_external": False}] - }, - {"short_description" : "Draft a Rough Course Outline", - "items" : [{"short_description": "Create Your First Section and Subsection", - "long_description": "Use your course outline to build your first Section and Subsection.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Set Section Release Dates", - "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Designate a Subsection as Graded", - "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Reordering Course Content", - "long_description": "Use drag and drop to reorder the content in your course.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Renaming Sections", - "long_description": "Rename Sections by clicking the Section name from the Course Outline.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Deleting Course Content", - "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}, - {"short_description": "Add an Instructor-Only Section to Your Outline", - "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.", - "is_checked": False, - "action_url": "CourseOutline", - "action_text": "Edit Course Outline", - "action_external": False}] - }, - {"short_description" : "Explore edX's Support Tools", - "items" : [{"short_description": "Explore the Studio Help Forum", - "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.", - "is_checked": False, - "action_url": "http://help.edge.edx.org/", - "action_text": "Visit Studio Help", - "action_external": True}, - {"short_description": "Enroll in edX 101", - "long_description": "Register for edX 101, edX's primer for course creation.", - "is_checked": False, - "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", - "action_text": "Register for edX 101", - "action_external": True}, - {"short_description": "Download the Studio Documentation", - "long_description": "Download the searchable Studio reference documentation in PDF form.", - "is_checked": False, - "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", - "action_text": "Download Documentation", - "action_external": True}] - }, - {"short_description" : "Draft Your Course About Page", - "items" : [{"short_description": "Draft a Course Description", - "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Staff Bios", - "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Course FAQs", - "long_description": "Include a short list of frequently asked questions about your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}, - {"short_description": "Add Course Prerequisites", - "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.", - "is_checked": False, - "action_url": "SettingsDetails", - "action_text": "Edit Course Schedule & Details", - "action_external": False}] - } + default=[ + {"short_description": "Getting Started With Studio", + "items": [{"short_description": "Add Course Team Members", + "long_description": "Grant your collaborators permission to edit your course so you can work together.", + "is_checked": False, + "action_url": "ManageUsers", + "action_text": "Edit Course Team", + "action_external": False}, + {"short_description": "Set Important Dates for Your Course", + "long_description": "Establish your course's student enrollment and launch dates on the Schedule and Details page.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Details & Schedule", + "action_external": False}, + {"short_description": "Draft Your Course's Grading Policy", + "long_description": "Set up your assignment types and grading policy even if you haven't created all your assignments.", + "is_checked": False, + "action_url": "SettingsGrading", + "action_text": "Edit Grading Settings", + "action_external": False}, + {"short_description": "Explore the Other Studio Checklists", + "long_description": "Discover other available course authoring tools, and find help when you need it.", + "is_checked": False, + "action_url": "", + "action_text": "", + "action_external": False}]}, + {"short_description": "Draft a Rough Course Outline", + "items": [{"short_description": "Create Your First Section and Subsection", + "long_description": "Use your course outline to build your first Section and Subsection.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Set Section Release Dates", + "long_description": "Specify the release dates for each Section in your course. Sections become visible to students on their release dates.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Designate a Subsection as Graded", + "long_description": "Set a Subsection to be graded as a specific assignment type. Assignments within graded Subsections count toward a student's final grade.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Reordering Course Content", + "long_description": "Use drag and drop to reorder the content in your course.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Renaming Sections", + "long_description": "Rename Sections by clicking the Section name from the Course Outline.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Deleting Course Content", + "long_description": "Delete Sections, Subsections, or Units you don't need anymore. Be careful, as there is no Undo function.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}, + {"short_description": "Add an Instructor-Only Section to Your Outline", + "long_description": "Some course authors find using a section for unsorted, in-progress work useful. To do this, create a section and set the release date to the distant future.", + "is_checked": False, + "action_url": "CourseOutline", + "action_text": "Edit Course Outline", + "action_external": False}]}, + {"short_description": "Explore edX's Support Tools", + "items": [{"short_description": "Explore the Studio Help Forum", + "long_description": "Access the Studio Help forum from the menu that appears when you click your user name in the top right corner of Studio.", + "is_checked": False, + "action_url": "http://help.edge.edx.org/", + "action_text": "Visit Studio Help", + "action_external": True}, + {"short_description": "Enroll in edX 101", + "long_description": "Register for edX 101, edX's primer for course creation.", + "is_checked": False, + "action_url": "https://edge.edx.org/courses/edX/edX101/How_to_Create_an_edX_Course/about", + "action_text": "Register for edX 101", + "action_external": True}, + {"short_description": "Download the Studio Documentation", + "long_description": "Download the searchable Studio reference documentation in PDF form.", + "is_checked": False, + "action_url": "http://files.edx.org/Getting_Started_with_Studio.pdf", + "action_text": "Download Documentation", + "action_external": True}]}, + {"short_description": "Draft Your Course About Page", + "items": [{"short_description": "Draft a Course Description", + "long_description": "Courses on edX have an About page that includes a course video, description, and more. Draft the text students will read before deciding to enroll in your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Staff Bios", + "long_description": "Showing prospective students who their instructor will be is helpful. Include staff bios on the course About page.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Course FAQs", + "long_description": "Include a short list of frequently asked questions about your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}, + {"short_description": "Add Course Prerequisites", + "long_description": "Let students know what knowledge and/or skills they should have before they enroll in your course.", + "is_checked": False, + "action_url": "SettingsDetails", + "action_text": "Edit Course Schedule & Details", + "action_external": False}]} ]) info_sidebar_name = String(scope=Scope.settings, default='Course Handouts') 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) + scope=Scope.settings) course_image = String( help="Filename of the course image", scope=Scope.settings, diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index f15edfed44..75789964b1 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -3,8 +3,10 @@ Tests for the Shopping Cart Models """ from factory import DjangoModelFactory +from mock import patch from django.test import TestCase from django.test.utils import override_settings +from django.db import DatabaseError from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE @@ -12,7 +14,7 @@ from shoppingcart.models import Order, OrderItem, CertificateItem, InvalidCartIt from student.tests.factories import UserFactory from student.models import CourseEnrollment from course_modes.models import CourseMode -from ..exceptions import PurchasedCallbackException +from shoppingcart.exceptions import PurchasedCallbackException class OrderTest(TestCase): @@ -74,6 +76,17 @@ class OrderTest(TestCase): cart.purchase() self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + def test_purchase_item_failure(self): + # once again, we're testing against the specific implementation of + # CertificateItem + cart = Order.get_cart_for_user(user=self.user) + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + with patch('shoppingcart.models.CertificateItem.save', side_effect=DatabaseError): + with self.assertRaises(DatabaseError): + cart.purchase() + # verify that we rolled back the entire transaction + self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + class OrderItemTest(TestCase): def setUp(self): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 48c85900b0..b3b75870fc 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -22,6 +22,7 @@ from mitxmako.shortcuts import render_to_response from shoppingcart.processors import render_purchase_form_html, process_postpay_callback from mock import patch, Mock + def mock_render_purchase_form_html(*args, **kwargs): return render_purchase_form_html(*args, **kwargs) @@ -34,6 +35,7 @@ render_mock = Mock(side_effect=mock_render_to_response) postpay_mock = Mock() + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class ShoppingCartViewsTests(ModuleStoreTestCase): def setUp(self): @@ -53,7 +55,6 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): def login_user(self): self.client.login(username=self.user.username, password="password") - def test_add_course_to_cart_anon(self): resp = self.client.post(reverse('shoppingcart.views.add_course_to_cart', args=[self.course_id])) self.assertEqual(resp.status_code, 403) @@ -141,7 +142,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(cert_item.id)) resp3 = self.client.post(reverse('shoppingcart.views.remove_item', args=[]), - {'id': -1}) + {'id': -1}) self.assertEqual(resp3.status_code, 200) exception_log.assert_called_with( 'Cannot remove cart OrderItem id={0}. DoesNotExist or item is already purchased'.format(-1)) @@ -158,7 +159,7 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): @patch('shoppingcart.views.process_postpay_callback', postpay_mock) @patch('shoppingcart.views.render_to_response', render_mock) def test_postpay_callback_failure(self): - postpay_mock.return_value = {'success': False, 'order': self.cart, 'error_html':'ERROR_TEST!!!'} + postpay_mock.return_value = {'success': False, 'order': self.cart, 'error_html': 'ERROR_TEST!!!'} self.login_user() resp = self.client.post(reverse('shoppingcart.views.postpay_callback', args=[])) self.assertEqual(resp.status_code, 200) @@ -224,4 +225,4 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(context['order'], self.cart) self.assertIn(reg_item.orderitem_ptr, context['order_items']) self.assertIn(cert_item.orderitem_ptr, context['order_items']) - self.assertTrue(context['any_refunds']) \ No newline at end of file + self.assertTrue(context['any_refunds']) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index a99568b133..e10c3c94f9 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -26,7 +26,7 @@ def add_course_to_cart(request, course_id): PaidCourseRegistration.add_to_order(cart, course_id) except ItemNotFoundError: return HttpResponseNotFound(_('The course you requested does not exist.')) - if request.method == 'GET': ### This is temporary for testing purposes and will go away before we pull + if request.method == 'GET': # This is temporary for testing purposes and will go away before we pull return HttpResponseRedirect(reverse('shoppingcart.views.show_cart')) return HttpResponse(_("Course added to cart.")) From b761976167aa46bd6f37574abb4291b43ae99ba3 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Thu, 22 Aug 2013 11:01:09 -0400 Subject: [PATCH 127/244] Remove unnecessary verified certificate view. --- lms/djangoapps/shoppingcart/tests/test_views.py | 6 ------ lms/djangoapps/shoppingcart/urls.py | 3 --- lms/djangoapps/shoppingcart/views.py | 10 ---------- 3 files changed, 19 deletions(-) diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index b3b75870fc..25ee914ce6 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -85,12 +85,6 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): self.assertEqual(resp.status_code, 200) self.assertTrue(PaidCourseRegistration.part_of_order(self.cart, self.course_id)) - def test_register_for_verified_cert(self): - self.login_user() - resp = self.client.post(reverse('shoppingcart.views.register_for_verified_cert', args=[self.course_id])) - self.assertEqual(resp.status_code, 200) - self.assertIn(self.course_id, [ci.course_id for ci in - self.cart.orderitem_set.all().select_subclasses('certificateitem')]) @patch('shoppingcart.views.render_purchase_form_html', form_mock) @patch('shoppingcart.views.render_to_response', render_mock) diff --git a/lms/djangoapps/shoppingcart/urls.py b/lms/djangoapps/shoppingcart/urls.py index 533714b719..800c6077aa 100644 --- a/lms/djangoapps/shoppingcart/urls.py +++ b/lms/djangoapps/shoppingcart/urls.py @@ -12,7 +12,4 @@ if settings.MITX_FEATURES['ENABLE_SHOPPING_CART']: url(r'^clear/$', 'clear_cart'), url(r'^remove_item/$', 'remove_item'), url(r'^add/course/(?P[^/]+/[^/]+/[^/]+)/$', 'add_course_to_cart', name='add_course_to_cart'), - url(r'^register_verified_course/course/(?P[^/]+/[^/]+/[^/]+)/$', - 'register_for_verified_cert'), - ) diff --git a/lms/djangoapps/shoppingcart/views.py b/lms/djangoapps/shoppingcart/views.py index e10c3c94f9..a2f88c9c94 100644 --- a/lms/djangoapps/shoppingcart/views.py +++ b/lms/djangoapps/shoppingcart/views.py @@ -31,16 +31,6 @@ def add_course_to_cart(request, course_id): return HttpResponse(_("Course added to cart.")) -@login_required -def register_for_verified_cert(request, course_id): - """ - Add a CertificateItem to the cart - """ - cart = Order.get_cart_for_user(request.user) - CertificateItem.add_to_order(cart, course_id, 30, 'verified') - return HttpResponse("Added") - - @login_required def show_cart(request): cart = Order.get_cart_for_user(request.user) From 1cb52df16b4ea05828128fe9fdbef4caf792d454 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Thu, 22 Aug 2013 16:10:24 -0400 Subject: [PATCH 128/244] Removed unnecessary TestCase --- lms/djangoapps/courseware/tests/tests.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lms/djangoapps/courseware/tests/tests.py b/lms/djangoapps/courseware/tests/tests.py index 4486a6a032..98bcba4ed0 100644 --- a/lms/djangoapps/courseware/tests/tests.py +++ b/lms/djangoapps/courseware/tests/tests.py @@ -1,7 +1,6 @@ """ Test for LMS courseware app. """ -from django.test import TestCase from django.core.urlresolvers import reverse from django.test.utils import override_settings @@ -167,7 +166,7 @@ class TestMongoCoursesLoad(ModuleStoreTestCase, PageLoaderTestCase): @override_settings(MODULESTORE=TEST_DATA_DRAFT_MONGO_MODULESTORE) -class TestDraftModuleStore(ModuleStoreTestCase, TestCase): +class TestDraftModuleStore(ModuleStoreTestCase): def test_get_items_with_course_items(self): store = modulestore() From 75b76b37ef232d87c9ab3cf4d1bdedd7587e0963 Mon Sep 17 00:00:00 2001 From: Peter Fogg Date: Thu, 22 Aug 2013 16:41:07 -0400 Subject: [PATCH 129/244] Add delete confirmation to static pages. We used to use a regular JS `confirm`; this makes the UI consistent with the rest of Studio. --- .../features/static-pages.feature | 3 +- cms/static/coffee/src/views/tabs.coffee | 43 ++++++++++++------- 2 files changed, 29 insertions(+), 17 deletions(-) diff --git a/cms/djangoapps/contentstore/features/static-pages.feature b/cms/djangoapps/contentstore/features/static-pages.feature index c1a8ec91fc..67652ea8f1 100644 --- a/cms/djangoapps/contentstore/features/static-pages.feature +++ b/cms/djangoapps/contentstore/features/static-pages.feature @@ -11,8 +11,9 @@ Feature: Static Pages Given I have opened a new course in Studio And I go to the static pages page And I add a new page - When I will confirm all alerts And I "delete" the "Empty" page + Then I am shown a prompt + When I confirm the prompt Then I should not see a "Empty" static page # Safari won't update the name properly diff --git a/cms/static/coffee/src/views/tabs.coffee b/cms/static/coffee/src/views/tabs.coffee index 58f52f27a3..3ae80dc336 100644 --- a/cms/static/coffee/src/views/tabs.coffee +++ b/cms/static/coffee/src/views/tabs.coffee @@ -64,20 +64,31 @@ class CMS.Views.TabsEdit extends Backbone.View course: course_location_analytics deleteTab: (event) => - if not confirm 'Are you sure you want to delete this component? This action cannot be undone.' - return - $component = $(event.currentTarget).parents('.component') - - analytics.track "Deleted Static Page", - course: course_location_analytics - id: $component.data('id') - - $.post('/delete_item', { - id: $component.data('id') - }, => - $component.remove() - ) - - - + confirm = new CMS.Views.Prompt.Warning + title: gettext('Delete Component Confirmation') + message: gettext('Are you sure you want to delete this component? This action cannot be undone.') + actions: + primary: + text: gettext("OK") + click: (view) -> + view.hide() + $component = $(event.currentTarget).parents('.component') + analytics.track "Deleted Static Page", + course: course_location_analytics + id: $component.data('id') + deleting = new CMS.Views.Notification.Mini + title: gettext('Deleting') + '…' + deleting.show() + $.post('/delete_item', { + id: $component.data('id') + }, => + $component.remove() + deleting.hide() + ) + secondary: [ + text: gettext('Cancel') + click: (view) -> + view.hide() + ] + confirm.show() From 7edfd185b79ee02b80dd134a93c3ccfc493abd42 Mon Sep 17 00:00:00 2001 From: Chris Dodge Date: Thu, 22 Aug 2013 17:35:38 -0400 Subject: [PATCH 130/244] teardown the content database after tests --- cms/djangoapps/contentstore/tests/test_import_nostatic.py | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_import_nostatic.py b/cms/djangoapps/contentstore/tests/test_import_nostatic.py index aad6ffbfe4..67f2202011 100644 --- a/cms/djangoapps/contentstore/tests/test_import_nostatic.py +++ b/cms/djangoapps/contentstore/tests/test_import_nostatic.py @@ -18,12 +18,13 @@ from xmodule.modulestore.django import modulestore from xmodule.contentstore.django import contentstore from xmodule.modulestore.xml_importer import import_from_xml from xmodule.contentstore.content import StaticContent +from xmodule.contentstore.django import _CONTENTSTORE from xmodule.course_module import CourseDescriptor from xmodule.exceptions import NotFoundError from uuid import uuid4 - +from pymongo import MongoClient TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex @@ -58,6 +59,10 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase): self.client = Client() self.client.login(username=uname, password=password) + def tearDown(self): + MongoClient().drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + _CONTENTSTORE.clear() + def load_test_import_course(self): ''' Load the standard course used to test imports (for do_import_static=False behavior). From 3558ce02aea6375b0d5fa35019ae3eac5c8f39b5 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 22 Aug 2013 17:50:37 -0400 Subject: [PATCH 131/244] Properly reset state and test it --- .../xmodule/combined_open_ended_module.py | 12 ++++- .../combined_open_ended_modulev1.py | 53 +++++++++++-------- .../xmodule/tests/test_combined_open_ended.py | 24 +++++++-- .../xmodule/tests/test_util_open_ended.py | 6 +++ 4 files changed, 68 insertions(+), 27 deletions(-) diff --git a/common/lib/xmodule/xmodule/combined_open_ended_module.py b/common/lib/xmodule/xmodule/combined_open_ended_module.py index f7960b13b1..2e193a20b4 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -20,7 +20,7 @@ V1_SETTINGS_ATTRIBUTES = [ ] V1_STUDENT_ATTRIBUTES = ["current_task_number", "task_states", "state", - "student_attempts", "ready_to_reset"] + "student_attempts", "ready_to_reset", "old_task_states"] V1_ATTRIBUTES = V1_SETTINGS_ATTRIBUTES + V1_STUDENT_ATTRIBUTES @@ -183,6 +183,13 @@ class CombinedOpenEndedFields(object): default=0, scope=Scope.user_state ) + old_task_states = List( + help=("A list of lists of state dictionaries for student states that are saved." + "This field is only populated if the instructor changes tasks after" + "the module is created and students have attempted it (ie changes a self assessed problem to " + "self and peer assessed."), + scope = Scope.user_state + ) task_states = List( help="List of state dictionaries of each task within this module.", scope=Scope.user_state @@ -380,6 +387,9 @@ class CombinedOpenEndedModule(CombinedOpenEndedFields, XModule): if self.task_states is None: self.task_states = [] + if self.old_task_states is None: + self.old_task_states = [] + version_tuple = VERSION_TUPLES[self.version] self.student_attributes = version_tuple.student_attributes 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 5e7c2f4a94..ab524e731a 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 @@ -103,6 +103,8 @@ class CombinedOpenEndedV1Module(): self.current_task_number = instance_state.get('current_task_number', 0) # This loads the states of the individual children self.task_states = instance_state.get('task_states', []) + #This gets any old task states that have been persisted after the instructor changed the tasks. + self.old_task_states = instance_state.get('old_task_states', []) # Overall state of the combined open ended module self.state = instance_state.get('state', self.INITIAL) @@ -168,22 +170,19 @@ class CombinedOpenEndedV1Module(): """ Sometimes a teacher will change the xml definition of a problem in Studio. This means that the state passed to the module is invalid. - If that is the case, delete it. + If that is the case, moved it to old_task_states and delete task_states. """ - info_message = "Combined open ended user state for user {0} in location {1} was invalid. Reset it.".format(self.system.anonymous_student_id, self.location.url()) #If we are on a task that is greater than the number of available tasks, it is an invalid state #If the current task number is greater than the number of tasks we have in the xml definition, our state is invalid. - if self.current_task_number>len(self.task_states) or self.current_task_number>len(self.task_xml): - self.current_task_number = min([len(self.task_states),len(self.task_xml)]) - log.info(info_message) + if self.current_task_number > len(self.task_states) or self.current_task_number > len(self.task_xml): + self.current_task_number = min([len(self.task_states), len(self.task_xml)]) - 1 #If the length of the task xml is less than the length of the task states, state is invalid - elif len(self.task_xml)table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"\", \"post_assessment\": \"[3]\", \"score\": 3}], \"max_score\": 3, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"{\\\"submission_id\\\": 3098, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3235, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"{\\\"submission_id\\\": 3099, \\\"score\\\": 3, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"to replicate the experiment , the procedure would require more detail . one piece of information that is omitted is the amount of vinegar used in the experiment . it is also important to know what temperature the experiment was kept at during the hours . finally , the procedure needs to include details about the experiment , for example if the whole sample must be submerged .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3237, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality3\\\"}\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"{\\\"submission_id\\\": 3100, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"e the mass of four different samples . pour vinegar in each of four separate , but identical , containers . place a sample of one material into one container and label . repeat with remaining samples , placing a single sample into a single container . after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . \\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3239, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"\", \"post_assessment\": \"{\\\"submission_id\\\": 3101, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"invalid essay .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3241, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}], \"max_score\": 3, \"child_state\": \"done\"}"], "attempts": "10000", "student_attempts": 0, "due": null, "state": "done", "accept_file_upload": false, "display_name": "Science Question -- Machine Assessed"}""" - instance_state = json.loads(instance_state) + instance_state = json.loads(MOCK_INSTANCE_STATE) rubric = """ @@ -722,4 +738,4 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore): #Try to reset, should fail because only 1 attempt is allowed reset_data = json.loads(module.handle_ajax("reset", {})) - self.assertEqual(reset_data['success'], False) + self.assertEqual(reset_data['success'], False) \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py index c717d52d31..ac543959bf 100644 --- a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py @@ -52,3 +52,9 @@ class DummyModulestore(object): location = Location(location) descriptor = self.modulestore.get_instance(course.id, location, depth=None) return descriptor.xmodule(self.test_system) + +TEST_STATE_SA_IN = ["{\"child_created\": false, \"child_attempts\": 2, \"version\": 1, \"child_history\": [{\"answer\": \"However venture pursuit he am mr cordial. Forming musical am hearing studied be luckily. Ourselves for determine attending how led gentleman sincerity. Valley afford uneasy joy she thrown though bed set. In me forming general prudent on country carried. Behaved an or suppose justice. Seemed whence how son rather easily and change missed. Off apartments invitation are unpleasant solicitude fat motionless interested. Hardly suffer wisdom wishes valley as an. As friendship advantages resolution it alteration stimulated he or increasing. \\r

    Now led tedious shy lasting females off. Dashwood marianne in of entrance be on wondered possible building. Wondered sociable he carriage in speedily margaret. Up devonshire of he thoroughly insensible alteration. An mr settling occasion insisted distance ladyship so. Not attention say frankness intention out dashwoods now curiosity. Stronger ecstatic as no judgment daughter speedily thoughts. Worse downs nor might she court did nay forth these. \", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}, {\"answer\": \"Delightful remarkably mr on announcing themselves entreaties favourable. About to in so terms voice at. Equal an would is found seems of. The particular friendship one sufficient terminated frequently themselves. It more shed went up is roof if loud case. Delay music in lived noise an. Beyond genius really enough passed is up. \\r

    John draw real poor on call my from. May she mrs furnished discourse extremely. Ask doubt noisy shade guest did built her him. Ignorant repeated hastened it do. Consider bachelor he yourself expenses no. Her itself active giving for expect vulgar months. Discovery commanded fat mrs remaining son she principle middleton neglected. Be miss he in post sons held. No tried is defer do money scale rooms. \", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"However venture pursuit he am mr cordial. Forming musical am hearing studied be luckily. Ourselves for determine attending how led gentleman sincerity. Valley afford uneasy joy she thrown though bed set. In me forming general prudent on country carried. Behaved an or suppose justice. Seemed whence how son rather easily and change missed. Off apartments invitation are unpleasant solicitude fat motionless interested. Hardly suffer wisdom wishes valley as an. As friendship advantages resolution it alteration stimulated he or increasing. \\r

    Now led tedious shy lasting females off. Dashwood marianne in of entrance be on wondered possible building. Wondered sociable he carriage in speedily margaret. Up devonshire of he thoroughly insensible alteration. An mr settling occasion insisted distance ladyship so. Not attention say frankness intention out dashwoods now curiosity. Stronger ecstatic as no judgment daughter speedily thoughts. Worse downs nor might she court did nay forth these. \", \"post_assessment\": \"{\\\"submission_id\\\": 1460, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5413, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}, {\"answer\": \"Delightful remarkably mr on announcing themselves entreaties favourable. About to in so terms voice at. Equal an would is found seems of. The particular friendship one sufficient terminated frequently themselves. It more shed went up is roof if loud case. Delay music in lived noise an. Beyond genius really enough passed is up. \\r

    John draw real poor on call my from. May she mrs furnished discourse extremely. Ask doubt noisy shade guest did built her him. Ignorant repeated hastened it do. Consider bachelor he yourself expenses no. Her itself active giving for expect vulgar months. Discovery commanded fat mrs remaining son she principle middleton neglected. Be miss he in post sons held. No tried is defer do money scale rooms. \", \"post_assessment\": \"{\\\"submission_id\\\": 1462, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5418, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"post_assessment\"}"] + +MOCK_INSTANCE_STATE = r"""{"ready_to_reset": false, "skip_spelling_checks": true, "current_task_number": 1, "weight": 5.0, "graceperiod": "1 day 12 hours 59 minutes 59 seconds", "graded": "True", "task_states": ["{\"child_created\": false, \"child_attempts\": 4, \"version\": 1, \"child_history\": [{\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"\", \"post_assessment\": \"[3]\", \"score\": 3}], \"max_score\": 3, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"{\\\"submission_id\\\": 3098, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3235, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"{\\\"submission_id\\\": 3099, \\\"score\\\": 3, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"to replicate the experiment , the procedure would require more detail . one piece of information that is omitted is the amount of vinegar used in the experiment . it is also important to know what temperature the experiment was kept at during the hours . finally , the procedure needs to include details about the experiment , for example if the whole sample must be submerged .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3237, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality3\\\"}\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"{\\\"submission_id\\\": 3100, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"e the mass of four different samples . pour vinegar in each of four separate , but identical , containers . place a sample of one material into one container and label . repeat with remaining samples , placing a single sample into a single container . after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . \\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3239, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"\", \"post_assessment\": \"{\\\"submission_id\\\": 3101, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"invalid essay .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3241, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}], \"max_score\": 3, \"child_state\": \"done\"}"], "attempts": "10000", "student_attempts": 0, "due": null, "state": "done", "accept_file_upload": false, "display_name": "Science Question -- Machine Assessed"}""" + +TEST_STATE_SA = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
    'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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.\", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
    'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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.\", \"post_assessment\": \"{\\\"submission_id\\\": 1461, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5414, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"post_assessment\"}"] \ No newline at end of file From b210346f628380900e6a6489db2f45d1e2456868 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 23 Aug 2013 11:17:03 -0400 Subject: [PATCH 132/244] Add in more tests --- .../combined_open_ended_modulev1.py | 5 +- .../xmodule/tests/test_combined_open_ended.py | 48 ++++++++++++++++++- .../xmodule/tests/test_util_open_ended.py | 8 +++- 3 files changed, 58 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 ab524e731a..61f63d63b3 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 @@ -178,7 +178,7 @@ class CombinedOpenEndedV1Module(): if self.current_task_number > len(self.task_states) or self.current_task_number > len(self.task_xml): self.current_task_number = min([len(self.task_states), len(self.task_xml)]) - 1 #If the length of the task xml is less than the length of the task states, state is invalid - elif len(self.task_xml) < len(self.task_states): + if len(self.task_xml) < len(self.task_states): self.current_task_number = 0 self.task_states = self.task_states[:len(self.task_xml)] #Loop through each task state and make sure it matches the xml definition @@ -197,6 +197,7 @@ class CombinedOpenEndedV1Module(): self.static_data, instance_state=t, ) + #Loop through each attempt of the task and see if it is valid. for att in task.child_history: if "post_assessment" not in att: continue @@ -212,6 +213,8 @@ class CombinedOpenEndedV1Module(): elif tag_name == "selfassessment" and not isinstance(pa, list): self.reset_task_state("Type is self assessment and post assessment is not a list.") break + #See if we can properly render the task. Will go into the exception clause below if not. + task.get_html(self.system) except Exception as err: #If one task doesn't match, the state is invalid. self.reset_task_state("Could not parse task. {0}".format(err)) 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 bba6460097..e930ed3e4a 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -22,7 +22,8 @@ from xmodule.open_ended_grading_classes.grading_service_module import GradingSer from xmodule.combined_open_ended_module import CombinedOpenEndedModule from xmodule.modulestore import Location from xmodule.tests import get_test_system, test_util_open_ended -from xmodule.tests.test_util_open_ended import MockQueryDict, DummyModulestore, TEST_STATE_SA_IN, MOCK_INSTANCE_STATE, TEST_STATE_SA +from xmodule.tests.test_util_open_ended import (MockQueryDict, DummyModulestore, TEST_STATE_SA_IN, + MOCK_INSTANCE_STATE, TEST_STATE_SA, TEST_STATE_AI, TEST_STATE_AI2, TEST_STATE_AI2_INVALID) import capa.xqueue_interface as xqueue_interface @@ -542,6 +543,51 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): self.assertEqual(score_dict['score'], 15.0) self.assertEqual(score_dict['total'], 15.0) + def ai_state(self, task_state, task_number, task_xml): + definition = {'prompt': etree.XML(self.prompt), 'rubric': etree.XML(self.rubric), + 'task_xml': task_xml} + descriptor = Mock(data=definition) + instance_state = {'task_states' : task_state, 'graded' : True} + if task_number is not None: + instance_state.update({'current_task_number' : task_number}) + combinedoe = CombinedOpenEndedV1Module(self.test_system, + self.location, + definition, + descriptor, + static_data=self.static_data, + metadata=self.metadata, + instance_state=instance_state) + return combinedoe + + def ai_state_reset(self, task_state, task_number=None): + combinedoe = self.ai_state(task_state, task_number, [self.task_xml2]) + html = combinedoe.get_html() + self.assertIsInstance(html, basestring) + + def ai_state_success(self, task_state, task_number=None): + combinedoe = self.ai_state(task_state, task_number, [self.task_xml1, self.task_xml2]) + html = combinedoe.get_html() + self.assertIsInstance(html, basestring) + score = combinedoe.get_score() + self.assertEqual(int(score['score']),2) + + def test_ai_state_reset(self): + self.ai_state_reset(TEST_STATE_AI) + + def test_ai_state2_reset(self): + self.ai_state_reset(TEST_STATE_AI2) + + def test_ai_invalid_state(self): + self.ai_state_reset(TEST_STATE_AI2_INVALID) + + def test_ai_state_rest_task_number(self): + self.ai_state_reset(TEST_STATE_AI, task_number=2) + self.ai_state_reset(TEST_STATE_AI, task_number=5) + self.ai_state_reset(TEST_STATE_AI, task_number=1) + self.ai_state_reset(TEST_STATE_AI, task_number=0) + + def test_ai_state_success(self): + self.ai_state_success(TEST_STATE_AI) class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): """ diff --git a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py index ac543959bf..5339b901b8 100644 --- a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py @@ -57,4 +57,10 @@ TEST_STATE_SA_IN = ["{\"child_created\": false, \"child_attempts\": 2, \"version MOCK_INSTANCE_STATE = r"""{"ready_to_reset": false, "skip_spelling_checks": true, "current_task_number": 1, "weight": 5.0, "graceperiod": "1 day 12 hours 59 minutes 59 seconds", "graded": "True", "task_states": ["{\"child_created\": false, \"child_attempts\": 4, \"version\": 1, \"child_history\": [{\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"\", \"post_assessment\": \"[3]\", \"score\": 3}], \"max_score\": 3, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"{\\\"submission_id\\\": 3098, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3235, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"{\\\"submission_id\\\": 3099, \\\"score\\\": 3, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"to replicate the experiment , the procedure would require more detail . one piece of information that is omitted is the amount of vinegar used in the experiment . it is also important to know what temperature the experiment was kept at during the hours . finally , the procedure needs to include details about the experiment , for example if the whole sample must be submerged .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3237, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality3\\\"}\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"{\\\"submission_id\\\": 3100, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"e the mass of four different samples . pour vinegar in each of four separate , but identical , containers . place a sample of one material into one container and label . repeat with remaining samples , placing a single sample into a single container . after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . \\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3239, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"\", \"post_assessment\": \"{\\\"submission_id\\\": 3101, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"invalid essay .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3241, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}], \"max_score\": 3, \"child_state\": \"done\"}"], "attempts": "10000", "student_attempts": 0, "due": null, "state": "done", "accept_file_upload": false, "display_name": "Science Question -- Machine Assessed"}""" -TEST_STATE_SA = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
    'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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.\", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
    'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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.\", \"post_assessment\": \"{\\\"submission_id\\\": 1461, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5414, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"post_assessment\"}"] \ No newline at end of file +TEST_STATE_SA = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
    'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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.\", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
    'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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.\", \"post_assessment\": \"{\\\"submission_id\\\": 1461, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5414, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"post_assessment\"}"] + +TEST_STATE_AI = ["{\"child_created\": false, \"child_attempts\": 2, \"version\": 1, \"child_history\": [{\"answer\": \"In libraries, there should not be censorship on materials considering that it's an individual's decision to read what they prefer. There is no appropriate standard on what makes a book offensive to a group, so it should be undetermined as to what makes a book offensive. In a public library, many children, who the books are censored for, are with their parents. Parents should make an independent choice on what they can allow their children to read. Letting society ban a book simply for the use of inappropriate materials is ridiculous. If an author spent time creating a story, it should be appreciated, and should not put on a list of no-nos. If a certain person doesn't like a book's reputation, all they have to do is not read it. Even in school systems, librarians are there to guide kids to read good books. If a child wants to read an inappropriate book, the librarian will most likely discourage him or her not to read it. In my experience, I wanted to read a book that my mother suggested to me, but as I went to the school library it turned out to be a censored book. Some parents believe children should be ignorant about offensive things written in books, but honestly many of the same ideas are exploited to them everyday on television and internet. So trying to shield your child from the bad things may be a great thing, but the efforts are usually failed attempts. It also never occurs to the people censoring the books, that some people can't afford to buy the books they want to read. The libraries, for some, are the main means for getting books. To conclude there is very little reason to ban a book from the shelves. Many of the books banned have important lessons that can be obtained through reading it. If a person doesn't like a book, the simplest thing to do is not to pick it up.\", \"post_assessment\": \"[1, 1]\", \"score\": 2}, {\"answer\": \"This is another response\", \"post_assessment\": \"[1, 1]\", \"score\": 2}], \"max_score\": 2, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"In libraries, there should not be censorship on materials considering that it's an individual's decision to read what they prefer. There is no appropriate standard on what makes a book offensive to a group, so it should be undetermined as to what makes a book offensive. In a public library, many children, who the books are censored for, are with their parents. Parents should make an independent choice on what they can allow their children to read. Letting society ban a book simply for the use of inappropriate materials is ridiculous. If an author spent time creating a story, it should be appreciated, and should not put on a list of no-nos. If a certain person doesn't like a book's reputation, all they have to do is not read it. Even in school systems, librarians are there to guide kids to read good books. If a child wants to read an inappropriate book, the librarian will most likely discourage him or her not to read it. In my experience, I wanted to read a book that my mother suggested to me, but as I went to the school library it turned out to be a censored book. Some parents believe children should be ignorant about offensive things written in books, but honestly many of the same ideas are exploited to them everyday on television and internet. So trying to shield your child from the bad things may be a great thing, but the efforts are usually failed attempts. It also never occurs to the people censoring the books, that some people can't afford to buy the books they want to read. The libraries, for some, are the main means for getting books. To conclude there is very little reason to ban a book from the shelves. Many of the books banned have important lessons that can be obtained through reading it. If a person doesn't like a book, the simplest thing to do is not to pick it up.\", \"post_assessment\": \"{\\\"submission_id\\\": 6107, \\\"score\\\": 2, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 1898718, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Writing Applications1 Language Conventions 1\\\"}\", \"score\": 2}, {\"answer\": \"This is another response\"}], \"max_score\": 2, \"child_state\": \"assessing\"}"] + +TEST_STATE_AI2 = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"This isn't a real essay, and you should give me a zero on it. \", \"post_assessment\": \"{\\\"submission_id\\\": 18446, \\\"score\\\": [0, 1, 0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"Zero it is! \\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [1944146, 1943188, 1940991], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true, true, true], \\\"rubric_xml\\\": [\\\"Writing Applications0 Language Conventions 0\\\", \\\"Writing Applications0 Language Conventions 1\\\", \\\"Writing Applications0 Language Conventions 0\\\"]}\", \"score\": 0}], \"max_score\": 2, \"child_state\": \"post_assessment\"}"] + +TEST_STATE_AI2_INVALID = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"This isn't a real essay, and you should give me a zero on it. \", \"post_assessment\": \"{\\\"submission_id\\\": 18446, \\\"score\\\": [0, 1, 0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"Zero it is! \\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [1943188, 1940991], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true, true, true], \\\"rubric_xml\\\": [\\\"Writing Applications0 Language Conventions 0\\\", \\\"Writing Applications0 Language Conventions 1\\\", \\\"Writing Applications0 Language Conventions 0\\\"]}\", \"score\": 0}], \"max_score\": 2, \"child_state\": \"post_assessment\"}"] \ No newline at end of file From e142023eedc29bb56e51d9a139f7878a777559fe Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 23 Aug 2013 11:32:38 -0400 Subject: [PATCH 133/244] Add in Studio confirmation box before editing OE problems --- .../lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee index 1f3b711804..f54600d148 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee @@ -63,6 +63,8 @@ Write a persuasive essay to a newspaper reflecting your vies on censorship in li else @createXMLEditor() + @confirmTaskRubricModification() + ### Creates the XML Editor and sets it as the current editor. If text is passed in, it will replace the text present in the HTML template. @@ -93,6 +95,8 @@ Write a persuasive essay to a newspaper reflecting your vies on censorship in li # Hide markdown-specific toolbar buttons $(@element.find('.editor-bar')).hide() + confirmTaskRubricModification: -> + return confirm("Before you edit, please note that if you alter the tasks block or the rubric block of this question after students have submitted responses, it may result in their responses and grades being deleted! Use caution when altering problems that have already been released to students.") ### Have the user confirm the one-way conversion to XML. Returns true if the user clicked OK, else false. From 473cc3624badf07fc50c5fddd091c1cba58dfc88 Mon Sep 17 00:00:00 2001 From: JonahStanley Date: Fri, 23 Aug 2013 09:02:56 -0400 Subject: [PATCH 134/244] Changed error message on failure and made test more robust Better error message --- cms/djangoapps/contentstore/features/common.py | 5 ++--- cms/djangoapps/contentstore/features/course-settings.py | 9 ++++++--- 2 files changed, 8 insertions(+), 6 deletions(-) diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 5d6fde47c8..39f28ba249 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -265,9 +265,8 @@ def type_in_codemirror(index, text): 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)) + world.browser.execute_script("$('input.file-input').css('display', 'block')") + world.browser.attach_file('file', os.path.abspath(path)) button_css = '.upload-dialog .action-upload' world.css_click(button_css) diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index 570c49a8c4..beaa1fbad4 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -168,15 +168,18 @@ def i_see_new_course_image(_step): 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) + try: + assert img['src'].endswith(expected_src) + except AssertionError as e: + e.args += ('Was looking for {}'.format(expected_src), 'Found {}'.format(img['src'])) + raise @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 + assert world.css_value(field_css) == expected_value ############### HELPER METHODS #################### From 734843c3773a763ea73a4a6fbf3e97c1eb4552ef Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 23 Aug 2013 11:42:30 -0400 Subject: [PATCH 135/244] Confirm changed to alert --- .../xmodule/xmodule/js/src/combinedopenended/edit.coffee | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee index f54600d148..89bda70a10 100644 --- a/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee +++ b/common/lib/xmodule/xmodule/js/src/combinedopenended/edit.coffee @@ -63,7 +63,7 @@ Write a persuasive essay to a newspaper reflecting your vies on censorship in li else @createXMLEditor() - @confirmTaskRubricModification() + @alertTaskRubricModification() ### Creates the XML Editor and sets it as the current editor. If text is passed in, @@ -95,8 +95,8 @@ Write a persuasive essay to a newspaper reflecting your vies on censorship in li # Hide markdown-specific toolbar buttons $(@element.find('.editor-bar')).hide() - confirmTaskRubricModification: -> - return confirm("Before you edit, please note that if you alter the tasks block or the rubric block of this question after students have submitted responses, it may result in their responses and grades being deleted! Use caution when altering problems that have already been released to students.") + alertTaskRubricModification: -> + return alert("Before you edit, please note that if you alter the tasks block or the rubric block of this question after students have submitted responses, it may result in their responses and grades being deleted! Use caution when altering problems that have already been released to students.") ### Have the user confirm the one-way conversion to XML. Returns true if the user clicked OK, else false. From 7ff03124d2425a5405ca77dd117e1f1991fe88e0 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 23 Aug 2013 12:30:18 -0400 Subject: [PATCH 136/244] pep8, pylint, and more tests --- .../combined_open_ended_modulev1.py | 10 ++-- .../xmodule/tests/test_combined_open_ended.py | 60 ++++++++++++++++--- .../xmodule/tests/test_util_open_ended.py | 6 +- 3 files changed, 63 insertions(+), 13 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 61f63d63b3..113cc43e68 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 @@ -35,8 +35,8 @@ TRUE_DICT = ["True", True, "TRUE", "true"] HUMAN_TASK_TYPE = { 'selfassessment': "Self", 'openended': "edX", - 'ml_grading.conf' : "AI", - 'peer_grading.conf' : "Peer", + 'ml_grading.conf': "AI", + 'peer_grading.conf': "Peer", } HUMAN_STATES = { @@ -379,7 +379,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.") + 'human_state': HUMAN_STATES.get(self.state, "Not started.") } return context @@ -535,14 +535,14 @@ class CombinedOpenEndedV1Module(): 'feedback_dicts': feedback_dicts, 'grader_ids': grader_ids, 'submission_ids': submission_ids, - 'success' : True + 'success': True } return last_response_dict def extract_human_name_from_task(self, task_xml): tree = etree.fromstring(task_xml) payload = tree.xpath("/openended/openendedparam/grader_payload") - if len(payload)==0: + if len(payload) == 0: task_name = "selfassessment" else: inner_payload = json.loads(payload[0].text) 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 e930ed3e4a..855ba15231 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -23,7 +23,8 @@ from xmodule.combined_open_ended_module import CombinedOpenEndedModule from xmodule.modulestore import Location from xmodule.tests import get_test_system, test_util_open_ended from xmodule.tests.test_util_open_ended import (MockQueryDict, DummyModulestore, TEST_STATE_SA_IN, - MOCK_INSTANCE_STATE, TEST_STATE_SA, TEST_STATE_AI, TEST_STATE_AI2, TEST_STATE_AI2_INVALID) + MOCK_INSTANCE_STATE, TEST_STATE_SA, TEST_STATE_AI, TEST_STATE_AI2, TEST_STATE_AI2_INVALID, + TEST_STATE_SINGLE, TEST_STATE_PE_SINGLE) import capa.xqueue_interface as xqueue_interface @@ -424,8 +425,6 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): ) def setUp(self): - # TODO: this constructor call is definitely wrong, but neither branch - # of the merge matches the module constructor. Someone (Vik?) should fix this. self.combinedoe = CombinedOpenEndedV1Module(self.test_system, self.location, self.definition, @@ -435,16 +434,25 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): instance_state=self.static_data) def test_get_tag_name(self): + """ + Test to see if the xml tag name is correct + """ name = self.combinedoe.get_tag_name("Tag") self.assertEqual(name, "t") def test_get_last_response(self): + """ + See if we can parse the last response + """ response_dict = self.combinedoe.get_last_response(0) self.assertEqual(response_dict['type'], "selfassessment") self.assertEqual(response_dict['max_score'], self.max_score) self.assertEqual(response_dict['state'], CombinedOpenEndedV1Module.INITIAL) def test_update_task_states(self): + """ + See if we can update the task states properly + """ changed = self.combinedoe.update_task_states() self.assertFalse(changed) @@ -455,6 +463,9 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): self.assertTrue(changed) def test_get_max_score(self): + """ + Try to get the max score of the problem + """ self.combinedoe.update_task_states() self.combinedoe.state = "done" self.combinedoe.is_scored = True @@ -462,24 +473,39 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): self.assertEqual(max_score, 1) def test_container_get_max_score(self): + """ + See if we can get the max score from the actual xmodule + """ #The progress view requires that this function be exposed max_score = self.combinedoe_container.max_score() self.assertEqual(max_score, None) def test_container_weight(self): + """ + Check the problem weight in the container + """ weight = self.combinedoe_container.weight self.assertEqual(weight, 1) def test_container_child_weight(self): + """ + Test the class to see if it picks up the right weight + """ weight = self.combinedoe_container.child_module.weight self.assertEqual(weight, 1) def test_get_score(self): + """ + See if scoring works + """ score_dict = self.combinedoe.get_score() self.assertEqual(score_dict['score'], 0) self.assertEqual(score_dict['total'], 1) def test_alternate_orderings(self): + """ + Try multiple ordering of definitions to see if the problem renders different steps correctly. + """ t1 = self.task_xml1 t2 = self.task_xml2 xml_to_test = [[t1], [t2], [t1, t1], [t1, t2], [t2, t2], [t2, t1], [t1, t2, t1]] @@ -515,6 +541,9 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): def test_get_score_realistic(self): + """ + Try to parse the correct score from a json instance state + """ instance_state = json.loads(MOCK_INSTANCE_STATE) rubric = """ @@ -544,10 +573,13 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): self.assertEqual(score_dict['total'], 15.0) def ai_state(self, task_state, task_number, task_xml): + """ + See if state is properly reset or left unchanged + """ definition = {'prompt': etree.XML(self.prompt), 'rubric': etree.XML(self.rubric), 'task_xml': task_xml} descriptor = Mock(data=definition) - instance_state = {'task_states' : task_state, 'graded' : True} + instance_state = {'task_states': task_state, 'graded': True} if task_number is not None: instance_state.update({'current_task_number' : task_number}) combinedoe = CombinedOpenEndedV1Module(self.test_system, @@ -560,16 +592,24 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): return combinedoe def ai_state_reset(self, task_state, task_number=None): + """ + See if state is properly reset + """ combinedoe = self.ai_state(task_state, task_number, [self.task_xml2]) html = combinedoe.get_html() self.assertIsInstance(html, basestring) - def ai_state_success(self, task_state, task_number=None): - combinedoe = self.ai_state(task_state, task_number, [self.task_xml1, self.task_xml2]) + def ai_state_success(self, task_state, task_number=None, iscore=2, tasks=None): + """ + See if state stays the same + """ + if tasks is None: + tasks = [self.task_xml1, self.task_xml2] + combinedoe = self.ai_state(task_state, task_number, tasks) html = combinedoe.get_html() self.assertIsInstance(html, basestring) score = combinedoe.get_score() - self.assertEqual(int(score['score']),2) + self.assertEqual(int(score['score']), iscore) def test_ai_state_reset(self): self.ai_state_reset(TEST_STATE_AI) @@ -589,6 +629,12 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): def test_ai_state_success(self): self.ai_state_success(TEST_STATE_AI) + def test_state_single(self): + self.ai_state_success(TEST_STATE_SINGLE, iscore=12) + + def test_state_pe_single(self): + self.ai_state_success(TEST_STATE_PE_SINGLE, iscore=0, tasks=[self.task_xml2]) + class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): """ Test the student flow in the combined open ended xmodule diff --git a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py index 5339b901b8..79b0e77f80 100644 --- a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py @@ -63,4 +63,8 @@ TEST_STATE_AI = ["{\"child_created\": false, \"child_attempts\": 2, \"version\": TEST_STATE_AI2 = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"This isn't a real essay, and you should give me a zero on it. \", \"post_assessment\": \"{\\\"submission_id\\\": 18446, \\\"score\\\": [0, 1, 0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"Zero it is! \\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [1944146, 1943188, 1940991], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true, true, true], \\\"rubric_xml\\\": [\\\"Writing Applications0 Language Conventions 0\\\", \\\"Writing Applications0 Language Conventions 1\\\", \\\"Writing Applications0 Language Conventions 0\\\"]}\", \"score\": 0}], \"max_score\": 2, \"child_state\": \"post_assessment\"}"] -TEST_STATE_AI2_INVALID = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"This isn't a real essay, and you should give me a zero on it. \", \"post_assessment\": \"{\\\"submission_id\\\": 18446, \\\"score\\\": [0, 1, 0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"Zero it is! \\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [1943188, 1940991], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true, true, true], \\\"rubric_xml\\\": [\\\"Writing Applications0 Language Conventions 0\\\", \\\"Writing Applications0 Language Conventions 1\\\", \\\"Writing Applications0 Language Conventions 0\\\"]}\", \"score\": 0}], \"max_score\": 2, \"child_state\": \"post_assessment\"}"] \ No newline at end of file +TEST_STATE_AI2_INVALID = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"This isn't a real essay, and you should give me a zero on it. \", \"post_assessment\": \"{\\\"submission_id\\\": 18446, \\\"score\\\": [0, 1, 0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"Zero it is! \\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [1943188, 1940991], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true, true, true], \\\"rubric_xml\\\": [\\\"Writing Applications0 Language Conventions 0\\\", \\\"Writing Applications0 Language Conventions 1\\\", \\\"Writing Applications0 Language Conventions 0\\\"]}\", \"score\": 0}], \"max_score\": 2, \"child_state\": \"post_assessment\"}"] + +TEST_STATE_SINGLE = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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. \", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}"] + +TEST_STATE_PE_SINGLE = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Passage its ten led hearted removal cordial. Preference any astonished unreserved mrs. Prosperous understood middletons in conviction an uncommonly do. Supposing so be resolving breakfast am or perfectly. Is drew am hill from mr. Valley by oh twenty direct me so. Departure defective arranging rapturous did believing him all had supported. Family months lasted simple set nature vulgar him. Picture for attempt joy excited ten carried manners talking how. Suspicion neglected he resolving agreement perceived at an. \\r

    Ye on properly handsome returned throwing am no whatever. In without wishing he of picture no exposed talking minutes. Curiosity continual belonging offending so explained it exquisite. Do remember to followed yourself material mr recurred carriage. High drew west we no or at john. About or given on witty event. Or sociable up material bachelor bringing landlord confined. Busy so many in hung easy find well up. So of exquisite my an explained remainder. Dashwood denoting securing be on perceive my laughing so. \\r

    Ought these are balls place mrs their times add she. Taken no great widow spoke of it small. Genius use except son esteem merely her limits. Sons park by do make on. It do oh cottage offered cottage in written. Especially of dissimilar up attachment themselves by interested boisterous. Linen mrs seems men table. Jennings dashwood to quitting marriage bachelor in. On as conviction in of appearance apartments boisterous. \", \"post_assessment\": \"{\\\"submission_id\\\": 1439, \\\"score\\\": [0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [5337], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true], \\\"rubric_xml\\\": [\\\"\\\\nIdeas\\\\n0\\\\nContent\\\\n0\\\\nOrganization\\\\n0\\\\nStyle\\\\n0\\\\nVoice\\\\n0\\\"]}\", \"score\": 0}], \"max_score\": 12, \"child_state\": \"done\"}"] \ No newline at end of file From dfb8e9f0e3b6ad0c84cda0cdbddf2ab16fc8345c Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Fri, 23 Aug 2013 13:48:59 -0400 Subject: [PATCH 137/244] Fix max score calculation --- .../combined_open_ended_modulev1.py | 8 +++----- 1 file changed, 3 insertions(+), 5 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 113cc43e68..b6b0d99456 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 @@ -839,7 +839,6 @@ class CombinedOpenEndedV1Module(): for i in xrange(0, len(self.task_states)): # For each task, extract all student scores on that task (each attempt for each task) last_response = self.get_last_response(i) - max_score = last_response.get('max_score', None) score = last_response.get('all_scores', None) if score is not None: # Convert none scores and weight scores properly @@ -858,9 +857,9 @@ class CombinedOpenEndedV1Module(): else: score = 0 - if max_score is not None: + if self._max_score is not None: # Weight the max score if it is not None - max_score *= float(weight) + max_score = self._max_score * float(weight) else: # Without a max_score, we cannot have a score! score = None @@ -880,8 +879,7 @@ class CombinedOpenEndedV1Module(): ''' max_score = None if self.check_if_done_and_scored(): - last_response = self.get_last_response(self.current_task_number) - max_score = last_response['max_score'] + max_score = self._max_score return max_score def get_progress(self): From 8cac4ebae2f7e44ab388588cb9e49a4baf4623d5 Mon Sep 17 00:00:00 2001 From: Akshay Jagadeesh Date: Fri, 16 Aug 2013 13:19:59 -0700 Subject: [PATCH 138/244] Working on sorting dropdown commentables --- lms/templates/discussion/_filter_dropdown.html | 1 + 1 file changed, 1 insertion(+) diff --git a/lms/templates/discussion/_filter_dropdown.html b/lms/templates/discussion/_filter_dropdown.html index c09f295b11..a51467eade 100644 --- a/lms/templates/discussion/_filter_dropdown.html +++ b/lms/templates/discussion/_filter_dropdown.html @@ -2,6 +2,7 @@ <%! import json %> <%def name="render_dropdown(map)"> + ${map["children"].sort()} % for child in map["children"]: % if child in map["entries"]: ${render_entry(map["entries"], child)} From e0471007d8f8d6d6bad60ee9aa6e0f492f01b214 Mon Sep 17 00:00:00 2001 From: Giulio Gratta Date: Fri, 16 Aug 2013 17:24:36 -0700 Subject: [PATCH 139/244] removed sort in HTML and instead added conditional to use subcategory titles as sort_key --- lms/djangoapps/django_comment_client/utils.py | 2 ++ lms/templates/discussion/_filter_dropdown.html | 1 - 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index c3e2708f67..ed97c43113 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -129,6 +129,8 @@ def filter_unstarted_categories(category_map): def sort_map_entries(category_map): things = [] for title, entry in category_map["entries"].items(): + if entry["sort_key"] == None: + entry["sort_key"] = title things.append((title, entry)) for title, category in category_map["subcategories"].items(): things.append((title, category)) diff --git a/lms/templates/discussion/_filter_dropdown.html b/lms/templates/discussion/_filter_dropdown.html index a51467eade..c09f295b11 100644 --- a/lms/templates/discussion/_filter_dropdown.html +++ b/lms/templates/discussion/_filter_dropdown.html @@ -2,7 +2,6 @@ <%! import json %> <%def name="render_dropdown(map)"> - ${map["children"].sort()} % for child in map["children"]: % if child in map["entries"]: ${render_entry(map["entries"], child)} From 4e7709885978df245199436f5118913d8449b961 Mon Sep 17 00:00:00 2001 From: Giulio Gratta Date: Thu, 22 Aug 2013 15:47:03 -0700 Subject: [PATCH 140/244] added course level feature flag for forum alpha sorting as well as tests --- common/lib/xmodule/xmodule/course_module.py | 6 +- .../django_comment_client/tests/test_utils.py | 119 ++++++++++++++++++ lms/djangoapps/django_comment_client/utils.py | 11 +- 3 files changed, 127 insertions(+), 9 deletions(-) diff --git a/common/lib/xmodule/xmodule/course_module.py b/common/lib/xmodule/xmodule/course_module.py index 10bcf4a4e3..c583aec3d1 100644 --- a/common/lib/xmodule/xmodule/course_module.py +++ b/common/lib/xmodule/xmodule/course_module.py @@ -198,10 +198,8 @@ class CourseFields(object): tabs = List(help="List of tabs to enable in this course", scope=Scope.settings) end_of_course_survey_url = String(help="Url for the end-of-course survey", scope=Scope.settings) discussion_blackouts = List(help="List of pairs of start/end dates for discussion blackouts", scope=Scope.settings) - discussion_topics = Dict( - help="Map of topics names to ids", - scope=Scope.settings - ) + discussion_topics = Dict(help="Map of topics names to ids", scope=Scope.settings) + discussion_sort_alpha = Boolean(scope=Scope.settings, default=False, help="Sort forum categories and subcategories alphabetically.") testcenter_info = Dict(help="Dictionary of Test Center info", scope=Scope.settings) announcement = Date(help="Date this course is announced", scope=Scope.settings) cohort_config = Dict(help="Dictionary defining cohort configuration", scope=Scope.settings) diff --git a/lms/djangoapps/django_comment_client/tests/test_utils.py b/lms/djangoapps/django_comment_client/tests/test_utils.py index 555264cb5f..14bb042d51 100644 --- a/lms/djangoapps/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/django_comment_client/tests/test_utils.py @@ -2,6 +2,7 @@ from django.test import TestCase from student.tests.factories import UserFactory, CourseEnrollmentFactory from django_comment_common.models import Role, Permission from factories import RoleFactory +from copy import deepcopy import django_comment_client.utils as utils @@ -28,6 +29,124 @@ class DictionaryTestCase(TestCase): expected = {'cats': 'meow', 'dogs': 'woof', 'lions': 'roar', 'ducks': 'quack'} self.assertEqual(utils.merge_dict(d1, d2), expected) + def test_sort(self): + d1 = { + 'entries': { + u'General': { + 'sort_key': u'General' + } + }, + 'subcategories': { + u'Tests': { + 'sort_key': u'Tests', + 'subcategories': {}, + 'entries': { + u'Quizzes': { + 'sort_key': None + }, u'All': { + 'sort_key': None + }, u'Final Exam': { + 'sort_key': None + }, + } + }, + u'Assignments': { + 'sort_key': u'Assignments', + 'subcategories': {}, + 'entries': { + u'Homework': { + 'sort_key': None + }, + u'All': { + 'sort_key': None + }, + } + } + } + } + + expected_1 = { + 'entries': { + u'General': { + 'sort_key': u'General' + } + }, + 'children': [u'Assignments', u'General', u'Tests'], + 'subcategories': { + u'Tests': { + 'sort_key': u'Tests', + 'subcategories': {}, + 'children': [u'All', u'Final Exam', u'Quizzes'], + 'entries': { + u'All': { + 'sort_key': 'All' + }, u'Final Exam': { + 'sort_key': 'Final Exam' + }, u'Quizzes': { + 'sort_key': 'Quizzes' + } + } + }, + u'Assignments': { + 'sort_key': u'Assignments', + 'subcategories': {}, + 'children': [u'All', u'Homework'], + 'entries': { + u'Homework': { + 'sort_key': 'Homework' + }, + u'All': { + 'sort_key': 'All' + }, + } + } + } + } + + expected_2 = { + 'entries': { + u'General': { + 'sort_key': u'General' + } + }, + 'children': [u'Assignments', u'General', u'Tests'], + 'subcategories': { + u'Tests': { + 'sort_key': u'Tests', + 'subcategories': {}, + 'children': [u'Quizzes', u'All', u'Final Exam'], + 'entries': { + u'Quizzes': { + 'sort_key': None + }, u'All': { + 'sort_key': None + }, u'Final Exam': { + 'sort_key': None + }, + } + }, + u'Assignments': { + 'sort_key': u'Assignments', + 'subcategories': {}, + 'children': [u'All', u'Homework'], + 'entries': { + u'Homework': { + 'sort_key': None + }, + u'All': { + 'sort_key': None + }, + } + } + } + } + + d2 = deepcopy(d1) + utils.sort_map_entries(d1, True) + utils.sort_map_entries(d2, False) + self.assertEqual(d1, expected_1) + self.assertEqual(d2, expected_2) + class AccessUtilsTestCase(TestCase): def setUp(self): diff --git a/lms/djangoapps/django_comment_client/utils.py b/lms/djangoapps/django_comment_client/utils.py index ed97c43113..9309f919d3 100644 --- a/lms/djangoapps/django_comment_client/utils.py +++ b/lms/djangoapps/django_comment_client/utils.py @@ -125,16 +125,16 @@ def filter_unstarted_categories(category_map): return result_map - -def sort_map_entries(category_map): + +def sort_map_entries(category_map, sort_alpha): things = [] for title, entry in category_map["entries"].items(): - if entry["sort_key"] == None: + if entry["sort_key"] == None and sort_alpha: entry["sort_key"] = title things.append((title, entry)) for title, category in category_map["subcategories"].items(): things.append((title, category)) - sort_map_entries(category_map["subcategories"][title]) + sort_map_entries(category_map["subcategories"][title], sort_alpha) category_map["children"] = [x[0] for x in sorted(things, key=lambda x: x[1]["sort_key"])] @@ -213,7 +213,8 @@ def initialize_discussion_info(course): category_map['entries'][topic] = {"id": entry["id"], "sort_key": entry.get("sort_key", topic), "start_date": datetime.now(UTC())} - sort_map_entries(category_map) + + sort_map_entries(category_map, course.discussion_sort_alpha) _DISCUSSIONINFO[course.id]['id_map'] = discussion_id_map _DISCUSSIONINFO[course.id]['category_map'] = category_map From 44f4e7b10a0b744bb3c166b6a22cc353a490d758 Mon Sep 17 00:00:00 2001 From: Giulio Gratta Date: Fri, 23 Aug 2013 11:31:59 -0700 Subject: [PATCH 141/244] refatoring tests and added to changelog --- CHANGELOG.rst | 3 +++ .../django_comment_client/tests/test_utils.py | 25 +++++++++++-------- 2 files changed, 17 insertions(+), 11 deletions(-) diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 749b9ef56e..47ffc2e313 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. +LMS: Added alphabetical sorting of forum categories and subcategories. +It is hidden behind a false defaulted course level flag. + Studio: Allow course authors to set their course image on the schedule and details page, with support for JPEG and PNG images. diff --git a/lms/djangoapps/django_comment_client/tests/test_utils.py b/lms/djangoapps/django_comment_client/tests/test_utils.py index 14bb042d51..efc6e6c7a3 100644 --- a/lms/djangoapps/django_comment_client/tests/test_utils.py +++ b/lms/djangoapps/django_comment_client/tests/test_utils.py @@ -2,7 +2,6 @@ from django.test import TestCase from student.tests.factories import UserFactory, CourseEnrollmentFactory from django_comment_common.models import Role, Permission from factories import RoleFactory -from copy import deepcopy import django_comment_client.utils as utils @@ -29,8 +28,10 @@ class DictionaryTestCase(TestCase): expected = {'cats': 'meow', 'dogs': 'woof', 'lions': 'roar', 'ducks': 'quack'} self.assertEqual(utils.merge_dict(d1, d2), expected) - def test_sort(self): - d1 = { + +class CategorySortTestCase(TestCase): + def setUp(self): + self.category_map = { 'entries': { u'General': { 'sort_key': u'General' @@ -64,8 +65,9 @@ class DictionaryTestCase(TestCase): } } } - - expected_1 = { + + def test_alpha_sort_true(self): + expected_true = { 'entries': { u'General': { 'sort_key': u'General' @@ -103,7 +105,11 @@ class DictionaryTestCase(TestCase): } } - expected_2 = { + utils.sort_map_entries(self.category_map, True) + self.assertEqual(self.category_map, expected_true) + + def test_alpha_sort_false(self): + expected_false = { 'entries': { u'General': { 'sort_key': u'General' @@ -141,11 +147,8 @@ class DictionaryTestCase(TestCase): } } - d2 = deepcopy(d1) - utils.sort_map_entries(d1, True) - utils.sort_map_entries(d2, False) - self.assertEqual(d1, expected_1) - self.assertEqual(d2, expected_2) + utils.sort_map_entries(self.category_map, False) + self.assertEqual(self.category_map, expected_false) class AccessUtilsTestCase(TestCase): From b6795b4e88b027e72bc45dadc29f9d04c745394b Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 14 Aug 2013 10:33:28 -0400 Subject: [PATCH 142/244] Add jquery-file-upload --- .../js/vendor/jQuery-File-Upload/README.md | 123 ++ .../blueimp-file-upload.jquery.json | 50 + .../jQuery-File-Upload/cors/postmessage.html | 75 + .../jQuery-File-Upload/cors/result.html | 24 + .../jQuery-File-Upload/css/demo-ie8.css | 21 + .../js/vendor/jQuery-File-Upload/css/demo.css | 67 + .../css/jquery.fileupload-ui-noscript.css | 27 + .../css/jquery.fileupload-ui.css | 68 + .../vendor/jQuery-File-Upload/css/style.css | 15 + .../vendor/jQuery-File-Upload/img/loading.gif | Bin 0 -> 3897 bytes .../jQuery-File-Upload/img/progressbar.gif | Bin 0 -> 3323 bytes .../js/vendor/jQuery-File-Upload/js/app.js | 101 ++ .../js/cors/jquery.postmessage-transport.js | 118 ++ .../js/cors/jquery.xdr-transport.js | 87 ++ .../js/jquery.fileupload-angular.js | 403 +++++ .../js/jquery.fileupload-audio.js | 106 ++ .../js/jquery.fileupload-image.js | 292 ++++ .../js/jquery.fileupload-jquery-ui.js | 138 ++ .../js/jquery.fileupload-process.js | 164 ++ .../js/jquery.fileupload-ui.js | 643 ++++++++ .../js/jquery.fileupload-validate.js | 117 ++ .../js/jquery.fileupload-video.js | 106 ++ .../js/jquery.fileupload.js | 1333 +++++++++++++++++ .../js/jquery.iframe-transport.js | 205 +++ .../js/vendor/jQuery-File-Upload/js/main.js | 76 + .../js/vendor/jquery.ui.widget.js | 530 +++++++ 26 files changed, 4889 insertions(+) create mode 100644 common/static/js/vendor/jQuery-File-Upload/README.md create mode 100644 common/static/js/vendor/jQuery-File-Upload/blueimp-file-upload.jquery.json create mode 100644 common/static/js/vendor/jQuery-File-Upload/cors/postmessage.html create mode 100644 common/static/js/vendor/jQuery-File-Upload/cors/result.html create mode 100644 common/static/js/vendor/jQuery-File-Upload/css/demo-ie8.css create mode 100644 common/static/js/vendor/jQuery-File-Upload/css/demo.css create mode 100644 common/static/js/vendor/jQuery-File-Upload/css/jquery.fileupload-ui-noscript.css create mode 100644 common/static/js/vendor/jQuery-File-Upload/css/jquery.fileupload-ui.css create mode 100644 common/static/js/vendor/jQuery-File-Upload/css/style.css create mode 100644 common/static/js/vendor/jQuery-File-Upload/img/loading.gif create mode 100644 common/static/js/vendor/jQuery-File-Upload/img/progressbar.gif create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/app.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/cors/jquery.postmessage-transport.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/cors/jquery.xdr-transport.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-angular.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-audio.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-image.js create mode 100755 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-jquery-ui.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-ui.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-video.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/main.js create mode 100644 common/static/js/vendor/jQuery-File-Upload/js/vendor/jquery.ui.widget.js diff --git a/common/static/js/vendor/jQuery-File-Upload/README.md b/common/static/js/vendor/jQuery-File-Upload/README.md new file mode 100644 index 0000000000..10893de80c --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/README.md @@ -0,0 +1,123 @@ +# jQuery File Upload Plugin + +## Demo +[Demo File Upload](http://blueimp.github.io/jQuery-File-Upload/) + +## Description +File Upload widget with multiple file selection, drag&drop support, progress bars, validation and preview images, audio and video for jQuery. +Supports cross-domain, chunked and resumable file uploads and client-side image resizing. Works with any server-side platform (PHP, Python, Ruby on Rails, Java, Node.js, Go etc.) that supports standard HTML form file uploads. + +## Setup +* [How to setup the plugin on your website](https://github.com/blueimp/jQuery-File-Upload/wiki/Setup) +* [How to use only the basic plugin (minimal setup guide).](https://github.com/blueimp/jQuery-File-Upload/wiki/Basic-plugin) + +## Support + +* **[Support Forum](https://groups.google.com/d/forum/jquery-fileupload)** +**Support requests** and **general discussions** about the File Upload plugin can be posted to the official +[Support Forum](https://groups.google.com/d/forum/jquery-fileupload). +If your question is not directly related to the File Upload plugin, you might have a better chance to get a reply by posting to [Stack Overflow](http://stackoverflow.com/questions/tagged/blueimp+jquery+file-upload). + +* Bugs and Feature requests +**Bugs** and **Feature requests** can be reported using the [issues tracker](https://github.com/blueimp/jQuery-File-Upload/issues). +Please read the [issue guidelines](https://github.com/blueimp/jQuery-File-Upload/blob/master/CONTRIBUTING.md) before posting. + +## Features +* **Multiple file upload:** + Allows to select multiple files at once and upload them simultaneously. +* **Drag & Drop support:** + Allows to upload files by dragging them from your desktop or filemanager and dropping them on your browser window. +* **Upload progress bar:** + Shows a progress bar indicating the upload progress for individual files and for all uploads combined. +* **Cancelable uploads:** + Individual file uploads can be canceled to stop the upload progress. +* **Resumable uploads:** + Aborted uploads can be resumed with browsers supporting the Blob API. +* **Chunked uploads:** + Large files can be uploaded in smaller chunks with browsers supporting the Blob API. +* **Client-side image resizing:** + Images can be automatically resized on client-side with browsers supporting the required JS APIs. +* **Preview images, audio and video:** + A preview of image, audio and video files can be displayed before uploading with browsers supporting the required APIs. +* **No browser plugins (e.g. Adobe Flash) required:** + The implementation is based on open standards like HTML5 and JavaScript and requires no additional browser plugins. +* **Graceful fallback for legacy browsers:** + Uploads files via XMLHttpRequests if supported and uses iframes as fallback for legacy browsers. +* **HTML file upload form fallback:** + Allows progressive enhancement by using a standard HTML file upload form as widget element. +* **Cross-site file uploads:** + Supports uploading files to a different domain with cross-site XMLHttpRequests or iframe redirects. +* **Multiple plugin instances:** + Allows to use multiple plugin instances on the same webpage. +* **Customizable and extensible:** + Provides an API to set individual options and define callBack methods for various upload events. +* **Multipart and file contents stream uploads:** + Files can be uploaded as standard "multipart/form-data" or file contents stream (HTTP PUT file upload). +* **Compatible with any server-side application platform:** + Works with any server-side platform (PHP, Python, Ruby on Rails, Java, Node.js, Go etc.) that supports standard HTML form file uploads. + +## Requirements + +### Mandatory requirements +* [jQuery](http://jquery.com/) v. 1.6+ +* [jQuery UI widget factory](http://api.jqueryui.com/jQuery.widget/) v. 1.9+ (included) +* [jQuery Iframe Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/jquery.iframe-transport.js) (included) + +The jQuery UI widget factory is a requirement for the basic File Upload plugin, but very lightweight without any other dependencies from the jQuery UI suite. + +The jQuery Iframe Transport is required for [browsers without XHR file upload support](https://github.com/blueimp/jQuery-File-Upload/wiki/Browser-support). + +### Optional requirements +* [JavaScript Templates engine](https://github.com/blueimp/JavaScript-Templates) v. 2.3.0+ +* [JavaScript Load Image library](https://github.com/blueimp/JavaScript-Load-Image) v. 1.9.1+ +* [JavaScript Canvas to Blob polyfill](https://github.com/blueimp/JavaScript-Canvas-to-Blob) v. 2.0.7+ +* [blueimp Gallery](https://github.com/blueimp/Gallery) v. 2.7.1+ +* [Bootstrap CSS framework](https://github.com/twitter/bootstrap/) v. 2.3 +* [Font Awesome icon font](http://fortawesome.github.io/Font-Awesome/) v. 3.2.1+ + +The JavaScript Templates engine is used to render the selected and uploaded files for the Basic Plus UI and jQuery UI versions. + +The JavaScript Load Image library and JavaScript Canvas to Blob polyfill are required for the image previews and resizing functionality. + +The blueimp Gallery is used to display the uploaded images in a lightbox. + +The user interface of all versions except the jQuery UI version is built with Twitter's [Bootstrap](https://github.com/twitter/bootstrap/) framework and icons from [Font Awesome](http://fortawesome.github.io/Font-Awesome/). + +### Cross-domain requirements +[Cross-domain File Uploads](https://github.com/blueimp/jQuery-File-Upload/wiki/Cross-domain-uploads) using the [Iframe Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/jquery.iframe-transport.js) require a redirect back to the origin server to retrieve the upload results. The [example implementation](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/main.js) makes use of [result.html](https://github.com/blueimp/jQuery-File-Upload/blob/master/cors/result.html) as a static redirect page for the origin server. + +The repository also includes the [jQuery XDomainRequest Transport plugin](https://github.com/blueimp/jQuery-File-Upload/blob/master/js/cors/jquery.xdr-transport.js), which enables limited cross-domain AJAX requests in Microsoft Internet Explorer 8 and 9 (IE 10 supports cross-domain XHR requests). +The XDomainRequest object allows GET and POST requests only and doesn't support file uploads. It is used on the [Demo](http://blueimp.github.io/jQuery-File-Upload/) to delete uploaded files from the cross-domain demo file upload service. + +## Browsers + +### Desktop browsers +The File Upload plugin is regularly tested with the latest browser versions and supports the following minimal versions: + +* Google Chrome +* Apple Safari 4.0+ +* Mozilla Firefox 3.0+ +* Opera 11.0+ +* Microsoft Internet Explorer 6.0+ + +### Mobile browsers +The File Upload plugin has been tested with and supports the following mobile browsers: + +* Apple Safari on iOS 6.0+ +* Google Chrome on iOS 6.0+ +* Google Chrome on Android 4.0+ +* Default Browser on Android 2.3+ +* Opera Mobile 12.0+ + +### Supported features +For a detailed overview of the features supported by each browser version please have a look at the [Extended browser support information](https://github.com/blueimp/jQuery-File-Upload/wiki/Browser-support). + +## License +Released under the [MIT license](http://www.opensource.org/licenses/MIT). + +## Donations +jQuery File Upload is free software, but you can donate to support the developer, Sebastian Tschan: + +Flattr: [![Flattr](https://api.flattr.com/button/flattr-badge-large.png)](https://flattr.com/thing/286433/jQuery-File-Upload-Plugin) + +PayPal: [![PayPal](https://www.paypalobjects.com/WEBSCR-640-20110429-1/en_US/i/btn/btn_donateCC_LG.gif)](https://www.paypal.com/cgi-bin/webscr?cmd=_s-xclick&hosted_button_id=PYWYSYP77KL54) diff --git a/common/static/js/vendor/jQuery-File-Upload/blueimp-file-upload.jquery.json b/common/static/js/vendor/jQuery-File-Upload/blueimp-file-upload.jquery.json new file mode 100644 index 0000000000..60ca2813a2 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/blueimp-file-upload.jquery.json @@ -0,0 +1,50 @@ +{ + "name": "blueimp-file-upload", + "version": "8.7.1", + "title": "jQuery File Upload", + "author": { + "name": "Sebastian Tschan", + "url": "https://blueimp.net" + }, + "licenses": [ + { + "type": "MIT", + "url": "http://www.opensource.org/licenses/MIT" + } + ], + "dependencies": { + "jquery": ">=1.6" + }, + "description": "File Upload widget with multiple file selection, drag&drop support, progress bar, validation and preview images, audio and video for jQuery. Supports cross-domain, chunked and resumable file uploads. Works with any server-side platform (Google App Engine, PHP, Python, Ruby on Rails, Java, etc.) that supports standard HTML form file uploads.", + "keywords": [ + "jquery", + "file", + "upload", + "widget", + "multiple", + "selection", + "drag", + "drop", + "progress", + "preview", + "cross-domain", + "cross-site", + "chunk", + "resume", + "gae", + "go", + "python", + "php", + "bootstrap" + ], + "homepage": "https://github.com/blueimp/jQuery-File-Upload", + "docs": "https://github.com/blueimp/jQuery-File-Upload/wiki", + "demo": "http://blueimp.github.io/jQuery-File-Upload/", + "bugs": "https://github.com/blueimp/jQuery-File-Upload/issues", + "maintainers": [ + { + "name": "Sebastian Tschan", + "url": "https://blueimp.net" + } + ] +} diff --git a/common/static/js/vendor/jQuery-File-Upload/cors/postmessage.html b/common/static/js/vendor/jQuery-File-Upload/cors/postmessage.html new file mode 100644 index 0000000000..3d1448f088 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/cors/postmessage.html @@ -0,0 +1,75 @@ + + + + + +jQuery File Upload Plugin postMessage API + + + + + + \ No newline at end of file diff --git a/common/static/js/vendor/jQuery-File-Upload/cors/result.html b/common/static/js/vendor/jQuery-File-Upload/cors/result.html new file mode 100644 index 0000000000..2251314952 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/cors/result.html @@ -0,0 +1,24 @@ + + + + + +jQuery Iframe Transport Plugin Redirect Page + + + + + diff --git a/common/static/js/vendor/jQuery-File-Upload/css/demo-ie8.css b/common/static/js/vendor/jQuery-File-Upload/css/demo-ie8.css new file mode 100644 index 0000000000..262493d08a --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/css/demo-ie8.css @@ -0,0 +1,21 @@ +@charset "UTF-8"; +/* + * jQuery File Upload Demo CSS Fixes for IE<9 1.0.0 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +.navigation { + list-style: none; + padding: 0; + margin: 1em 0; +} +.navigation li { + display: inline; + margin-right: 10px; +} diff --git a/common/static/js/vendor/jQuery-File-Upload/css/demo.css b/common/static/js/vendor/jQuery-File-Upload/css/demo.css new file mode 100644 index 0000000000..841f80d16b --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/css/demo.css @@ -0,0 +1,67 @@ +@charset "UTF-8"; +/* + * jQuery File Upload Demo CSS 1.0.2 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +body { + max-width: 750px; + margin: 0 auto; + padding: 1em; + font-family: 'Lucida Grande', 'Lucida Sans Unicode', Arial, sans-serif; + font-size: 1em; + line-height: 1.4em; + background: #222; + color: #fff; + -webkit-text-size-adjust: 100%; + -ms-text-size-adjust: 100%; +} +a { + color: orange; + text-decoration: none; +} +img { + border: 0; + vertical-align: middle; +} +h1 { + line-height: 1em; +} +blockquote { + padding: 0 0 0 15px; + margin: 0 0 20px; + border-left: 5px solid #eee; +} +table { + width: 100%; + margin: 10px 0; +} + +.fileupload-progress { + margin: 10px 0; +} +.fileupload-progress .progress-extended { + margin-top: 5px; +} +.error { + color: red; +} + +@media (min-width: 481px) { + .navigation { + list-style: none; + padding: 0; + } + .navigation li { + display: inline-block; + } + .navigation li:not(:first-child):before { + content: '| '; + } +} diff --git a/common/static/js/vendor/jQuery-File-Upload/css/jquery.fileupload-ui-noscript.css b/common/static/js/vendor/jQuery-File-Upload/css/jquery.fileupload-ui-noscript.css new file mode 100644 index 0000000000..c450485595 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/css/jquery.fileupload-ui-noscript.css @@ -0,0 +1,27 @@ +@charset "UTF-8"; +/* + * jQuery File Upload UI Plugin NoScript CSS 1.0 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2012, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +.fileinput-button input { + position: static; + opacity: 1; + filter: none; + transform: none; + font-size: inherit; + direction: inherit; +} + +.fileinput-button span, +.fileinput-button i, +.fileupload-buttonbar .delete, +.fileupload-buttonbar .toggle { + display: none; +} diff --git a/common/static/js/vendor/jQuery-File-Upload/css/jquery.fileupload-ui.css b/common/static/js/vendor/jQuery-File-Upload/css/jquery.fileupload-ui.css new file mode 100644 index 0000000000..f81b34c7b0 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/css/jquery.fileupload-ui.css @@ -0,0 +1,68 @@ +@charset "UTF-8"; +/* + * jQuery File Upload UI Plugin CSS 8.8.1 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2010, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +.fileinput-button { + position: relative; + overflow: hidden; +} +.fileinput-button input { + position: absolute; + top: 0; + right: 0; + margin: 0; + opacity: 0; + filter: alpha(opacity=0); + transform: translate(-300px, 0) scale(4); + font-size: 23px; + direction: ltr; + cursor: pointer; +} +.fileupload-buttonbar .btn, +.fileupload-buttonbar .toggle { + margin-bottom: 5px; +} +.progress-animated .progress-bar, +.progress-animated .bar { + background: url(../img/progressbar.gif) !important; + filter: none; +} +.fileupload-loading { + float: right; + width: 32px; + height: 32px; + background: url(../img/loading.gif) center no-repeat; + background-size: contain; + display: none; +} +.fileupload-processing .fileupload-loading { + display: block; +} +.files audio, +.files video { + max-width: 300px; +} + +@media (max-width: 767px) { + .fileupload-buttonbar .toggle, + .files .toggle, + .files .btn span { + display: none; + } + .files .name { + width: 80px; + word-wrap: break-word; + } + .files audio, + .files video { + max-width: 80px; + } +} diff --git a/common/static/js/vendor/jQuery-File-Upload/css/style.css b/common/static/js/vendor/jQuery-File-Upload/css/style.css new file mode 100644 index 0000000000..bdd5d58dc9 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/css/style.css @@ -0,0 +1,15 @@ +@charset "UTF-8"; +/* + * jQuery File Upload Plugin CSS Example 8.8.0 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +body { + padding-top: 60px; +} diff --git a/common/static/js/vendor/jQuery-File-Upload/img/loading.gif b/common/static/js/vendor/jQuery-File-Upload/img/loading.gif new file mode 100644 index 0000000000000000000000000000000000000000..90f28cbdbb390b095e0d619cbe8d91208798e58f GIT binary patch literal 3897 zcmZvfcR1T?8^ABwb_x$y{p1+>Yb$`dLr?0DW)y5y-57+?!PEJmyrlv?FQgU)K z^$+m(2RfoVbqYWS0G%+J=-j=drD>|8AS+KxL%o*)0)PM?>H*Mx2iHt~mnWZ=N>W+t zB|S>mk9=ZpYXc!T7UZI&`(-T$A=k$fH%{0DUBGwg!#nk?dE^E3gDApBHVTIQQFjd% z@8i*q&q?bJ^`q%$4G<}clybdVd-s{xsx+KupPg;W4bOvd7w*pJ;3oEg_PFlG;yL8+oShz**1=iDRZ*E(Q<#5R=A*XP5H_Y=1xJoCem%-&eKb6zV0ff z>legqW&{=3KP~Y8@#^3-+sNyevrSganP&X1J3*?KZrnP&@8z-DF>$5H-D+bme&k}k z=b(j;=N4)0R8Q6PZLj2pkrz)`V_M!E?dlE7mCX3opU@wz96Zurx4FmWL37=7TCuG9 z`GkMU?-=3W2u(X1pJ+1-D8$#M3IyxB%pDQB;2(M(eo?G5D~tz~6dTT3ItGfkWI&$< z&#Xo;(n_Kq+TlC@hpWm<{qK@(J8G++We#hbNi^se<6nV2;T4 zNDqriR!3dHvF711Txh1!vT{};LzV^uLH;6l)wR@$;KDJa`VOrZ+ccMJt-r043s&2t^bewdCj@xurE^v)WL95dQ z!~&h-7Yqg)+cJl7{=U2?_+E7^{JVv*AQbVh@R_RBt12dDs-#^ZEg=TA;LKR69HAv*?v1IO*LrVkl0@jm)`Yw>Ei;Cb<`Ge=JHj9g^C7+M?`w@g>lBl#q%UG z`}!%t5@M1z}?nB z*Tj60Y$FR82XGHd41y*mrUDeYh38hddS#Y*SGE@ZP#F{1I^fy8Y9@AY`0m};Z?t1t zvl@XaOzm2oTG^`5GXjVpu-2S*n4*kB%YDv4k&aM?8%y+(ZsV3)1mZz23da;)wH@7&`|Ado=<=+Ih>-Zw;?kA^kOQDkl*L3<;+? z<|M0rPu_-Pn1S;!V&9?Lji{M@0Mr#T9>Bk`lq`z3P)1&h>Ho;*au|vDvsVjp-qT0e z*UUfQ?Gpz$g9n2bA}a7zWNb7tHVzcwml}2{C{dOsk47z6B0pahT~Ju4TqIILBp68& zNmxrkQf(GrV^cF{Yg;>8XC;}Vr=*X4p!6N-twSOZPz$&PLr@%}eIZ zD~Lt1l{LgdQhk+JLo-cX3#q!jvb?Lbps#;m@ZHexhyq{?ko#d0H90e$K08CzSlrS) zWo6dl)B48d)b`HU-u~MD$9uai7`L)G>3*T{10aZCYqqL(n*#FQw0j@zj_M(+#c zyVt!MW{V+4vZ?)+0bac?NfTw2K79`dH+R{6nT57bfL{LIoi7Ag$(vz$+eju9d$7B zJG&Z{dzkyk1G(>p`qfHW#%#yxr>AUYK0KOvygWa*I`naEY4hR2MjvI{YUR`Z@fYi( z@9Pv+(V>39D#Fg`k5 zAvd8jHQhWpvV^33oSVP7D7mS*y)&b|zlvB`kzM|}?rCIuU=S7LEHVa$xY zK7bdtLDWL^SFw^20+{?ObjZr9KUx(0o0SBj_xZyoID`D^`r^?VSAjk}{spA|BzpP` z*n{4_ZGkmTM)r5(MRv|pCYSwXX2mHCy0;?C3wFJlI=Ud1imCcH_f;o6U;m-fmBwF| zxeaFV_F$)h(s28}mNsGy`ZELug@>6%MYAH2>|xIMd-hlX4DyCpx2_fIxR&9HR| zOo=QmRf1OdI|P;~oA(JKyL+Po2Z!q-^EdYxV{T>gIls8gp+tex@r9h|)?()zIoc!` z<-T`fILv04Ax z`;g5e;{OrXwNr!u98>p5O4V$kGW+Q$xy7wyq-@a{rvp{O^`YL+YM=2l%U^u_1752E zp+H?P>LBTO+=z(KcG8T2As`wFiAndgQX_?Fb7*g8g`&_orui5i9PU;=s2FX6FR#co zE~~Dn%@VI68P?So8?|?o7*sXav^B}J^pd-Y`tL^zz~d9Gy3^POy}9|$k4r+sE2(Oq zblxd%Y40fR?Z;>w9%bM7>MRCe0$W>a1Ua)1%pg!3Ef2a&@`nE+@wdq?gKL=1$&mG5 zc=xRumn_dNMN(4+^D|}e-AyHhgXp*ONwPc4Gw0}8JwtWo>9B=>)bspG45{b5-#-%j z#bmSnf0`_jYO*x%*xv*H2=qB?SKiue@ymKb_UgCALL`qak+Q!sG{OTY*|7f zF|)MHlBhu2NPE6kwyB8|f2F;hBma7LM{kKLHEAkujx^eiH``22TTe~S&x|jOJYJkz zURwXOva!82K-vG?eb}>euy=GkG2A=!`dKFQr>UIzv90~@YW71c)Ya_7S_1LJKEn=2 za9pc*k?HqfnAE_>s??ukJY)v<(7$cFuo!w*RiJ0d-5_)cb6+|EH)TS4n7hww64>2( zX`J7xEP2C2?VS3M{VJ)C5ViP4(Z`>SCWHnQ7<3K_6^4f~Mj(aUsSP$sAU+{HDVa|% zjWr{KUyB;EnHVu#ZXuo!TYQ1qV?~J(RTVWMwIb943uEgYoSL4Q{n!XxSX^4BEKja{T3;I9*k1pzv%fm_`3SgoyubBr<2{X$I3cSkh@M@_ zva>k51!i{|%nxvFY7J+Rb3l)ox#Z|V1(l_kR#-t@$lq>-DPeX*yA#-ro8yw)#5xk? z+50S$Vwd${7o-V=V1Vdg4meiIid>Ez$~Vn1NH?g!jTGE3bC@Dm6pR*Gx184p0Mz~i D^>?LD literal 0 HcmV?d00001 diff --git a/common/static/js/vendor/jQuery-File-Upload/img/progressbar.gif b/common/static/js/vendor/jQuery-File-Upload/img/progressbar.gif new file mode 100644 index 0000000000000000000000000000000000000000..fbcce6bc9abfcc7893e65ef20b3e77ee16ec37b1 GIT binary patch literal 3323 zcmcK7eNYp38VB%=tbvu|O{=v;j+Z$Bg`>zI2IVD3Ovp-%sA$2LilR;|zMv9BB_t%- zylmcIh`dQLinJh9P{0Ja6I28#3PquysCf3GB1bBEXgfV_qnx8-n07k#kNmNdnVrvm zpZz`0v#~Ih7`{0em<2RA0wa|vr`!U1uI~v zF&O+zyfdOBoHdq{xOf7$)mN>lwcHgLVd-HT}JxYe94Gv(X_H__aHV~b7Ud4Pvk@t?84W?#& z+#WmNc5cUNoapyda#-P`2+n z-sGAbb$_$TW887}6BiJKaV$E-69{23pNeG2=_;8-CM9c*OiHOp|F9Igl_JQHqf)Y3 zhR7s(mQBh5ER~A&6^|p|OQqmd+(8 zZCb1qD*K}>!|B|YVuCsS=V0!N=?-)po(^$x*kptYX9N)B^~VJ^d7-@(M1WK;9&Liw~wO?NN(E}be} z`e0?=)*y}@Xkgx6S$ZS(8d|ifzK_`x?=N$)pWk;s0Ve?`qDX{%L@X0)6guz8Adh+qh(mq90aE=iAZp(dGccGHgmlkOke19p-dfci z@1Ni#x_B;&@l8icy*4roEXN?9&vh-w;n!Fp3;~Dn4#aOVi22tbo-KY@B1*TAgMsta zMh~68EXdvgJe$<9yOgn~E8aiQNo1$l`FUCI52J)nHW8+@|McSTe{t30@NpO^cv#waY+68ew4oT={#AGoA?Je^k$f`&u}bW zOjoRA_$utq5rspZ;5HXZvqE1#D) z5*M}9jPoq=-V*<cQuuK%yr=$XZl2k``40P4ol60hjb({{!c5Rtn3Urk4?uRRFNQuFGPhZnTBMO zu{W&|u4t53w>M}v(wtDAV!oHP(QY&~_X0qupII*-&GksTPf#SZ#4eaOCrG~hUblo4 zya(R){3rfyxiH2%l{iN;xS(G)OWOx8>UV9u+Vm_iN98oT20F}U!8utl4(lm=E)S6) zN~xM^Q?U;_!Ku4seH{bquy+4OfwlV{0C?tW&XteqJmP<|V)6eK9ey(~1Sbd1rgF1{ zd@&*xkriY}MS><}4DI}o3>d2jm74@fNpg-Vf(zPY?8nj=O8o$gp_gzr3~{MEs({T( z5+EcMIErR!`wwLZHp{c_?{mEhe%dlbNH8E^nm+5|owEXTR;S2+hYWR^j zWB!qbQWumI=vg;ZQvO$8W9QeMX~YdqNb^K%y~T6@tCTOlSM-j*$OOSSZX6cRfzPb* zP-Q}8v|6Q&$hAFfGA(f5>_XzuqhFP@h(0x@%#tsExSB}v(zy6^qc>xW*XCVMBPKf` zF>gsuD~+eKuRP{tWLo#OoJ2?fR@_0f5Olf}}Khc$ZhB$$3I7S9&n z^G{G0pQnju2Md}?zmbr').prop('href', options.postMessage)[0], + target = loc.protocol + '//' + loc.host, + xhrUpload = options.xhr().upload; + return { + send: function (_, completeCallback) { + counter += 1; + var message = { + id: 'postmessage-transport-' + counter + }, + eventName = 'message.' + message.id; + iframe = $( + '' + ).bind('load', function () { + $.each(names, function (i, name) { + message[name] = options[name]; + }); + message.dataType = message.dataType.replace('postmessage ', ''); + $(window).bind(eventName, function (e) { + e = e.originalEvent; + var data = e.data, + ev; + if (e.origin === target && data.id === message.id) { + if (data.type === 'progress') { + ev = document.createEvent('Event'); + ev.initEvent(data.type, false, true); + $.extend(ev, data); + xhrUpload.dispatchEvent(ev); + } else { + completeCallback( + data.status, + data.statusText, + {postmessage: data.result}, + data.headers + ); + iframe.remove(); + $(window).unbind(eventName); + } + } + }); + iframe[0].contentWindow.postMessage( + message, + target + ); + }).appendTo(document.body); + }, + abort: function () { + if (iframe) { + iframe.remove(); + } + } + }; + } + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/cors/jquery.xdr-transport.js b/common/static/js/vendor/jQuery-File-Upload/js/cors/jquery.xdr-transport.js new file mode 100644 index 0000000000..d769f452d5 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/cors/jquery.xdr-transport.js @@ -0,0 +1,87 @@ +/* + * jQuery XDomainRequest Transport Plugin 1.1.3 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + * + * Based on Julian Aubourg's ajaxHooks xdr.js: + * https://github.com/jaubourg/ajaxHooks/ + */ + +/*jslint unparam: true */ +/*global define, window, XDomainRequest */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery'], factory); + } else { + // Browser globals: + factory(window.jQuery); + } +}(function ($) { + 'use strict'; + if (window.XDomainRequest && !$.support.cors) { + $.ajaxTransport(function (s) { + if (s.crossDomain && s.async) { + if (s.timeout) { + s.xdrTimeout = s.timeout; + delete s.timeout; + } + var xdr; + return { + send: function (headers, completeCallback) { + var addParamChar = /\?/.test(s.url) ? '&' : '?'; + function callback(status, statusText, responses, responseHeaders) { + xdr.onload = xdr.onerror = xdr.ontimeout = $.noop; + xdr = null; + completeCallback(status, statusText, responses, responseHeaders); + } + xdr = new XDomainRequest(); + // XDomainRequest only supports GET and POST: + if (s.type === 'DELETE') { + s.url = s.url + addParamChar + '_method=DELETE'; + s.type = 'POST'; + } else if (s.type === 'PUT') { + s.url = s.url + addParamChar + '_method=PUT'; + s.type = 'POST'; + } else if (s.type === 'PATCH') { + s.url = s.url + addParamChar + '_method=PATCH'; + s.type = 'POST'; + } + xdr.open(s.type, s.url); + xdr.onload = function () { + callback( + 200, + 'OK', + {text: xdr.responseText}, + 'Content-Type: ' + xdr.contentType + ); + }; + xdr.onerror = function () { + callback(404, 'Not Found'); + }; + if (s.xdrTimeout) { + xdr.ontimeout = function () { + callback(0, 'timeout'); + }; + xdr.timeout = s.xdrTimeout; + } + xdr.send((s.hasContent && s.data) || null); + }, + abort: function () { + if (xdr) { + xdr.onerror = $.noop(); + xdr.abort(); + } + } + }; + } + }); + } +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-angular.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-angular.js new file mode 100644 index 0000000000..e71f173860 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-angular.js @@ -0,0 +1,403 @@ +/* + * jQuery File Upload AngularJS Plugin 1.4.2 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true */ +/*global define, angular */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'angular', + './jquery.fileupload-image', + './jquery.fileupload-audio', + './jquery.fileupload-video', + './jquery.fileupload-validate' + ], factory); + } else { + factory(); + } +}(function () { + 'use strict'; + + angular.module('blueimp.fileupload', []) + + // The fileUpload service provides configuration options + // for the fileUpload directive and default handlers for + // File Upload events: + .provider('fileUpload', function () { + var scopeApply = function () { + var scope = angular.element(this) + .fileupload('option', 'scope')(), + $timeout = angular.injector(['ng']) + .get('$timeout'); + // Safe apply, makes sure $apply is called + // asynchronously outside of the $digest cycle: + $timeout(function () { + scope.$apply(); + }); + }, + $config; + $config = this.defaults = { + handleResponse: function (e, data) { + var files = data.result && data.result.files; + if (files) { + data.scope().replace(data.files, files); + } else if (data.errorThrown || + data.textStatus === 'error') { + data.files[0].error = data.errorThrown || + data.textStatus; + } + }, + add: function (e, data) { + var scope = data.scope(); + data.process(function () { + return scope.process(data); + }).always( + function () { + var file = data.files[0], + submit = function () { + return data.submit(); + }, + i; + for (i = 0; i < data.files.length; i += 1) { + data.files[i]._index = i; + } + file.$cancel = function () { + scope.clear(data.files); + return data.abort(); + }; + file.$state = function () { + return data.state(); + }; + file.$progress = function () { + return data.progress(); + }; + file.$response = function () { + return data.response(); + }; + if (file.$state() === 'rejected') { + file._$submit = submit; + } else { + file.$submit = submit; + } + scope.$apply(function () { + var method = scope.option('prependFiles') ? + 'unshift' : 'push'; + Array.prototype[method].apply( + scope.queue, + data.files + ); + if (file.$submit && + (scope.option('autoUpload') || + data.autoUpload) && + data.autoUpload !== false) { + file.$submit(); + } + }); + } + ); + }, + progress: function (e, data) { + data.scope().$apply(); + }, + done: function (e, data) { + var that = this; + data.scope().$apply(function () { + data.handleResponse.call(that, e, data); + }); + }, + fail: function (e, data) { + var that = this; + if (data.errorThrown === 'abort') { + return; + } + if (data.dataType && + data.dataType.indexOf('json') === data.dataType.length - 4) { + try { + data.result = angular.fromJson(data.jqXHR.responseText); + } catch (ignore) {} + } + data.scope().$apply(function () { + data.handleResponse.call(that, e, data); + }); + }, + stop: scopeApply, + processstart: scopeApply, + processstop: scopeApply, + getNumberOfFiles: function () { + return this.scope().queue.length; + }, + dataType: 'json', + prependFiles: true, + autoUpload: false + }; + this.$get = [ + function () { + return { + defaults: $config + }; + } + ]; + }) + + // Format byte numbers to readable presentations: + .provider('formatFileSizeFilter', function () { + var $config = { + // Byte units following the IEC format + // http://en.wikipedia.org/wiki/Kilobyte + units: [ + {size: 1000000000, suffix: ' GB'}, + {size: 1000000, suffix: ' MB'}, + {size: 1000, suffix: ' KB'} + ] + }; + this.defaults = $config; + this.$get = function () { + return function (bytes) { + if (!angular.isNumber(bytes)) { + return ''; + } + var unit = true, + i = 0, + prefix, + suffix; + while (unit) { + unit = $config.units[i]; + prefix = unit.prefix || ''; + suffix = unit.suffix || ''; + if (i === $config.units.length - 1 || bytes >= unit.size) { + return prefix + (bytes / unit.size).toFixed(2) + suffix; + } + i += 1; + } + }; + }; + }) + + // The FileUploadController initializes the fileupload widget and + // provides scope methods to control the File Upload functionality: + .controller('FileUploadController', [ + '$scope', '$element', '$attrs', '$window', 'fileUpload', + function ($scope, $element, $attrs, $window, fileUpload) { + var uploadMethods = { + progress: function () { + return $element.fileupload('progress'); + }, + active: function () { + return $element.fileupload('active'); + }, + option: function (option, data) { + return $element.fileupload('option', option, data); + }, + add: function (data) { + return $element.fileupload('add', data); + }, + send: function (data) { + return $element.fileupload('send', data); + }, + process: function (data) { + return $element.fileupload('process', data); + }, + processing: function (data) { + return $element.fileupload('processing', data); + } + }; + $scope.disabled = !$window.jQuery.support.fileInput; + $scope.queue = $scope.queue || []; + $scope.clear = function (files) { + var queue = this.queue, + i = queue.length, + file = files, + length = 1; + if (angular.isArray(files)) { + file = files[0]; + length = files.length; + } + while (i) { + i -= 1; + if (queue[i] === file) { + return queue.splice(i, length); + } + } + }; + $scope.replace = function (oldFiles, newFiles) { + var queue = this.queue, + file = oldFiles[0], + i, + j; + for (i = 0; i < queue.length; i += 1) { + if (queue[i] === file) { + for (j = 0; j < newFiles.length; j += 1) { + queue[i + j] = newFiles[j]; + } + return; + } + } + }; + $scope.applyOnQueue = function (method) { + var list = this.queue.slice(0), + i, + file; + for (i = 0; i < list.length; i += 1) { + file = list[i]; + if (file[method]) { + file[method](); + } + } + }; + $scope.submit = function () { + this.applyOnQueue('$submit'); + }; + $scope.cancel = function () { + this.applyOnQueue('$cancel'); + }; + // Add upload methods to the scope: + angular.extend($scope, uploadMethods); + // The fileupload widget will initialize with + // the options provided via "data-"-parameters, + // as well as those given via options object: + $element.fileupload(angular.extend( + {scope: function () { + return $scope; + }}, + fileUpload.defaults + )).on('fileuploadadd', function (e, data) { + data.scope = $scope.option('scope'); + }).on([ + 'fileuploadadd', + 'fileuploadsubmit', + 'fileuploadsend', + 'fileuploaddone', + 'fileuploadfail', + 'fileuploadalways', + 'fileuploadprogress', + 'fileuploadprogressall', + 'fileuploadstart', + 'fileuploadstop', + 'fileuploadchange', + 'fileuploadpaste', + 'fileuploaddrop', + 'fileuploaddragover', + 'fileuploadchunksend', + 'fileuploadchunkdone', + 'fileuploadchunkfail', + 'fileuploadchunkalways', + 'fileuploadprocessstart', + 'fileuploadprocess', + 'fileuploadprocessdone', + 'fileuploadprocessfail', + 'fileuploadprocessalways', + 'fileuploadprocessstop' + ].join(' '), function (e, data) { + if ($scope.$emit(e.type, data).defaultPrevented) { + e.preventDefault(); + } + }).on('remove', function () { + // Remove upload methods from the scope, + // when the widget is removed: + var method; + for (method in uploadMethods) { + if (uploadMethods.hasOwnProperty(method)) { + delete $scope[method]; + } + } + }); + // Observe option changes: + $scope.$watch( + $attrs.fileUpload, + function (newOptions) { + if (newOptions) { + $element.fileupload('option', newOptions); + } + } + ); + } + ]) + + // Provide File Upload progress feedback: + .controller('FileUploadProgressController', [ + '$scope', '$attrs', '$parse', + function ($scope, $attrs, $parse) { + var fn = $parse($attrs.fileUploadProgress), + update = function () { + var progress = fn($scope); + if (!progress || !progress.total) { + return; + } + $scope.num = Math.floor( + progress.loaded / progress.total * 100 + ); + }; + update(); + $scope.$watch( + $attrs.fileUploadProgress + '.loaded', + function (newValue, oldValue) { + if (newValue !== oldValue) { + update(); + } + } + ); + } + ]) + + // Display File Upload previews: + .controller('FileUploadPreviewController', [ + '$scope', '$element', '$attrs', '$parse', + function ($scope, $element, $attrs, $parse) { + var fn = $parse($attrs.fileUploadPreview), + file = fn($scope); + if (file.preview) { + $element.append(file.preview); + } + } + ]) + + .directive('fileUpload', function () { + return { + controller: 'FileUploadController' + }; + }) + + .directive('fileUploadProgress', function () { + return { + controller: 'FileUploadProgressController' + }; + }) + + .directive('fileUploadPreview', function () { + return { + controller: 'FileUploadPreviewController' + }; + }) + + // Enhance the HTML5 download attribute to + // allow drag&drop of files to the desktop: + .directive('download', function () { + return function (scope, elm) { + elm.on('dragstart', function (e) { + try { + e.originalEvent.dataTransfer.setData( + 'DownloadURL', + [ + 'application/octet-stream', + elm.prop('download'), + elm.prop('href') + ].join(':') + ); + } catch (ignore) {} + }); + }; + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-audio.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-audio.js new file mode 100644 index 0000000000..f59c2fa5fc --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-audio.js @@ -0,0 +1,106 @@ +/* + * jQuery File Upload Audio Preview Plugin 1.0.3 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true, regexp: true */ +/*global define, window, document */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'load-image', + './jquery.fileupload-process' + ], factory); + } else { + // Browser globals: + factory( + window.jQuery, + window.loadImage + ); + } +}(function ($, loadImage) { + 'use strict'; + + // Prepend to the default processQueue: + $.blueimp.fileupload.prototype.options.processQueue.unshift( + { + action: 'loadAudio', + // Use the action as prefix for the "@" options: + prefix: true, + fileTypes: '@', + maxFileSize: '@', + disabled: '@disableAudioPreview' + }, + { + action: 'setAudio', + name: '@audioPreviewName', + disabled: '@disableAudioPreview' + } + ); + + // The File Upload Audio Preview plugin extends the fileupload widget + // with audio preview functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + + options: { + // The regular expression for the types of audio files to load, + // matched against the file type: + loadAudioFileTypes: /^audio\/.*$/ + }, + + _audioElement: document.createElement('audio'), + + processActions: { + + // Loads the audio file given via data.files and data.index + // as audio element if the browser supports playing it. + // Accepts the options fileTypes (regular expression) + // and maxFileSize (integer) to limit the files to load: + loadAudio: function (data, options) { + if (options.disabled) { + return data; + } + var file = data.files[data.index], + url, + audio; + if (this._audioElement.canPlayType && + this._audioElement.canPlayType(file.type) && + ($.type(options.maxFileSize) !== 'number' || + file.size <= options.maxFileSize) && + (!options.fileTypes || + options.fileTypes.test(file.type))) { + url = loadImage.createObjectURL(file); + if (url) { + audio = this._audioElement.cloneNode(false); + audio.src = url; + audio.controls = true; + data.audio = audio; + return data; + } + } + return data; + }, + + // Sets the audio element as a property of the file object: + setAudio: function (data, options) { + if (data.audio && !options.disabled) { + data.files[data.index][options.name || 'preview'] = data.audio; + } + return data; + } + + } + + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-image.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-image.js new file mode 100644 index 0000000000..84474a7b3b --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-image.js @@ -0,0 +1,292 @@ +/* + * jQuery File Upload Image Preview & Resize Plugin 1.2.3 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true, regexp: true */ +/*global define, window, document, DataView, Blob, Uint8Array */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'load-image', + 'load-image-meta', + 'load-image-exif', + 'load-image-ios', + 'canvas-to-blob', + './jquery.fileupload-process' + ], factory); + } else { + // Browser globals: + factory( + window.jQuery, + window.loadImage + ); + } +}(function ($, loadImage) { + 'use strict'; + + // Prepend to the default processQueue: + $.blueimp.fileupload.prototype.options.processQueue.unshift( + { + action: 'loadImageMetaData', + disableImageHead: '@', + disableExif: '@', + disableExifThumbnail: '@', + disableExifSub: '@', + disableExifGps: '@', + disabled: '@disableImageMetaDataLoad' + }, + { + action: 'loadImage', + // Use the action as prefix for the "@" options: + prefix: true, + fileTypes: '@', + maxFileSize: '@', + noRevoke: '@', + disabled: '@disableImageLoad' + }, + { + action: 'resizeImage', + // Use "image" as prefix for the "@" options: + prefix: 'image', + maxWidth: '@', + maxHeight: '@', + minWidth: '@', + minHeight: '@', + crop: '@', + disabled: '@disableImageResize' + }, + { + action: 'saveImage', + disabled: '@disableImageResize' + }, + { + action: 'saveImageMetaData', + disabled: '@disableImageMetaDataSave' + }, + { + action: 'resizeImage', + // Use "preview" as prefix for the "@" options: + prefix: 'preview', + maxWidth: '@', + maxHeight: '@', + minWidth: '@', + minHeight: '@', + crop: '@', + orientation: '@', + thumbnail: '@', + canvas: '@', + disabled: '@disableImagePreview' + }, + { + action: 'setImage', + name: '@imagePreviewName', + disabled: '@disableImagePreview' + } + ); + + // The File Upload Resize plugin extends the fileupload widget + // with image resize functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + + options: { + // The regular expression for the types of images to load: + // matched against the file type: + loadImageFileTypes: /^image\/(gif|jpeg|png)$/, + // The maximum file size of images to load: + loadImageMaxFileSize: 10000000, // 10MB + // The maximum width of resized images: + imageMaxWidth: 1920, + // The maximum height of resized images: + imageMaxHeight: 1080, + // Define if resized images should be cropped or only scaled: + imageCrop: false, + // Disable the resize image functionality by default: + disableImageResize: true, + // The maximum width of the preview images: + previewMaxWidth: 80, + // The maximum height of the preview images: + previewMaxHeight: 80, + // Defines the preview orientation (1-8) or takes the orientation + // value from Exif data if set to true: + previewOrientation: true, + // Create the preview using the Exif data thumbnail: + previewThumbnail: true, + // Define if preview images should be cropped or only scaled: + previewCrop: false, + // Define if preview images should be resized as canvas elements: + previewCanvas: true + }, + + processActions: { + + // Loads the image given via data.files and data.index + // as img element if the browser supports canvas. + // Accepts the options fileTypes (regular expression) + // and maxFileSize (integer) to limit the files to load: + loadImage: function (data, options) { + if (options.disabled) { + return data; + } + var that = this, + file = data.files[data.index], + dfd = $.Deferred(); + if (($.type(options.maxFileSize) === 'number' && + file.size > options.maxFileSize) || + (options.fileTypes && + !options.fileTypes.test(file.type)) || + !loadImage( + file, + function (img) { + if (img.src) { + data.img = img; + } + dfd.resolveWith(that, [data]); + }, + options + )) { + return data; + } + return dfd.promise(); + }, + + // Resizes the image given as data.canvas or data.img + // and updates data.canvas or data.img with the resized image. + // Accepts the options maxWidth, maxHeight, minWidth, + // minHeight, canvas and crop: + resizeImage: function (data, options) { + if (options.disabled) { + return data; + } + var that = this, + dfd = $.Deferred(), + resolve = function (newImg) { + data[newImg.getContext ? 'canvas' : 'img'] = newImg; + dfd.resolveWith(that, [data]); + }, + thumbnail, + img, + newImg; + options = $.extend({canvas: true}, options); + if (data.exif) { + if (options.orientation === true) { + options.orientation = data.exif.get('Orientation'); + } + if (options.thumbnail) { + thumbnail = data.exif.get('Thumbnail'); + if (thumbnail) { + loadImage(thumbnail, resolve, options); + return dfd.promise(); + } + } + } + img = (options.canvas && data.canvas) || data.img; + if (img) { + newImg = loadImage.scale(img, options); + if (newImg.width !== img.width || + newImg.height !== img.height) { + resolve(newImg); + return dfd.promise(); + } + } + return data; + }, + + // Saves the processed image given as data.canvas + // inplace at data.index of data.files: + saveImage: function (data, options) { + if (!data.canvas || options.disabled) { + return data; + } + var that = this, + file = data.files[data.index], + name = file.name, + dfd = $.Deferred(), + callback = function (blob) { + if (!blob.name) { + if (file.type === blob.type) { + blob.name = file.name; + } else if (file.name) { + blob.name = file.name.replace( + /\..+$/, + '.' + blob.type.substr(6) + ); + } + } + // Store the created blob at the position + // of the original file in the files list: + data.files[data.index] = blob; + dfd.resolveWith(that, [data]); + }; + // Use canvas.mozGetAsFile directly, to retain the filename, as + // Gecko doesn't support the filename option for FormData.append: + if (data.canvas.mozGetAsFile) { + callback(data.canvas.mozGetAsFile( + (/^image\/(jpeg|png)$/.test(file.type) && name) || + ((name && name.replace(/\..+$/, '')) || + 'blob') + '.png', + file.type + )); + } else if (data.canvas.toBlob) { + data.canvas.toBlob(callback, file.type); + } else { + return data; + } + return dfd.promise(); + }, + + loadImageMetaData: function (data, options) { + if (options.disabled) { + return data; + } + var that = this, + dfd = $.Deferred(); + loadImage.parseMetaData(data.files[data.index], function (result) { + $.extend(data, result); + dfd.resolveWith(that, [data]); + }, options); + return dfd.promise(); + }, + + saveImageMetaData: function (data, options) { + if (!(data.imageHead && data.canvas && + data.canvas.toBlob && !options.disabled)) { + return data; + } + var file = data.files[data.index], + blob = new Blob([ + data.imageHead, + // Resized images always have a head size of 20 bytes, + // including the JPEG marker and a minimal JFIF header: + this._blobSlice.call(file, 20) + ], {type: file.type}); + blob.name = file.name; + data.files[data.index] = blob; + return data; + }, + + // Sets the resized version of the image as a property of the + // file object, must be called after "saveImage": + setImage: function (data, options) { + var img = data.canvas || data.img; + if (img && !options.disabled) { + data.files[data.index][options.name || 'preview'] = img; + } + return data; + } + + } + + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-jquery-ui.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-jquery-ui.js new file mode 100755 index 0000000000..05dd7a6117 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-jquery-ui.js @@ -0,0 +1,138 @@ +/* + * jQuery File Upload jQuery UI Plugin 8.7.0 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true */ +/*global define, window */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery', './jquery.fileupload-ui'], factory); + } else { + // Browser globals: + factory(window.jQuery); + } +}(function ($) { + 'use strict'; + + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + + options: { + progress: function (e, data) { + if (data.context) { + data.context.find('.progress').progressbar( + 'option', + 'value', + parseInt(data.loaded / data.total * 100, 10) + ); + } + }, + progressall: function (e, data) { + var $this = $(this); + $this.find('.fileupload-progress') + .find('.progress').progressbar( + 'option', + 'value', + parseInt(data.loaded / data.total * 100, 10) + ).end() + .find('.progress-extended').each(function () { + $(this).html( + ($this.data('blueimp-fileupload') || + $this.data('fileupload')) + ._renderExtendedProgress(data) + ); + }); + } + }, + + _renderUpload: function (func, files) { + var node = this._super(func, files), + showIconText = $(window).width() > 480; + node.find('.progress').empty().progressbar(); + node.find('.start').button({ + icons: {primary: 'ui-icon-circle-arrow-e'}, + text: showIconText + }); + node.find('.cancel').button({ + icons: {primary: 'ui-icon-cancel'}, + text: showIconText + }); + return node; + }, + + _renderDownload: function (func, files) { + var node = this._super(func, files), + showIconText = $(window).width() > 480; + node.find('.delete').button({ + icons: {primary: 'ui-icon-trash'}, + text: showIconText + }); + return node; + }, + + _transition: function (node) { + var deferred = $.Deferred(); + if (node.hasClass('fade')) { + node.fadeToggle( + this.options.transitionDuration, + this.options.transitionEasing, + function () { + deferred.resolveWith(node); + } + ); + } else { + deferred.resolveWith(node); + } + return deferred; + }, + + _create: function () { + this._super(); + this.element + .find('.fileupload-buttonbar') + .find('.fileinput-button').each(function () { + var input = $(this).find('input:file').detach(); + $(this) + .button({icons: {primary: 'ui-icon-plusthick'}}) + .append(input); + }) + .end().find('.start') + .button({icons: {primary: 'ui-icon-circle-arrow-e'}}) + .end().find('.cancel') + .button({icons: {primary: 'ui-icon-cancel'}}) + .end().find('.delete') + .button({icons: {primary: 'ui-icon-trash'}}) + .end().find('.progress').progressbar(); + }, + + _destroy: function () { + this.element + .find('.fileupload-buttonbar') + .find('.fileinput-button').each(function () { + var input = $(this).find('input:file').detach(); + $(this) + .button('destroy') + .append(input); + }) + .end().find('.start') + .button('destroy') + .end().find('.cancel') + .button('destroy') + .end().find('.delete') + .button('destroy') + .end().find('.progress').progressbar('destroy'); + this._super(); + } + + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js new file mode 100644 index 0000000000..87042c3d57 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-process.js @@ -0,0 +1,164 @@ +/* + * jQuery File Upload Processing Plugin 1.2.2 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2012, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true */ +/*global define, window */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + './jquery.fileupload' + ], factory); + } else { + // Browser globals: + factory( + window.jQuery + ); + } +}(function ($) { + 'use strict'; + + var originalAdd = $.blueimp.fileupload.prototype.options.add; + + // The File Upload Processing plugin extends the fileupload widget + // with file processing functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + + options: { + // The list of processing actions: + processQueue: [ + /* + { + action: 'log', + type: 'debug' + } + */ + ], + add: function (e, data) { + var $this = $(this); + data.process(function () { + return $this.fileupload('process', data); + }); + originalAdd.call(this, e, data); + } + }, + + processActions: { + /* + log: function (data, options) { + console[options.type]( + 'Processing "' + data.files[data.index].name + '"' + ); + } + */ + }, + + _processFile: function (data) { + var that = this, + dfd = $.Deferred().resolveWith(that, [data]), + chain = dfd.promise(); + this._trigger('process', null, data); + $.each(data.processQueue, function (i, settings) { + var func = function (data) { + return that.processActions[settings.action].call( + that, + data, + settings + ); + }; + chain = chain.pipe(func, settings.always && func); + }); + chain + .done(function () { + that._trigger('processdone', null, data); + that._trigger('processalways', null, data); + }) + .fail(function () { + that._trigger('processfail', null, data); + that._trigger('processalways', null, data); + }); + return chain; + }, + + // Replaces the settings of each processQueue item that + // are strings starting with an "@", using the remaining + // substring as key for the option map, + // e.g. "@autoUpload" is replaced with options.autoUpload: + _transformProcessQueue: function (options) { + var processQueue = []; + $.each(options.processQueue, function () { + var settings = {}, + action = this.action, + prefix = this.prefix === true ? action : this.prefix; + $.each(this, function (key, value) { + if ($.type(value) === 'string' && + value.charAt(0) === '@') { + settings[key] = options[ + value.slice(1) || (prefix ? prefix + + key.charAt(0).toUpperCase() + key.slice(1) : key) + ]; + } else { + settings[key] = value; + } + + }); + processQueue.push(settings); + }); + options.processQueue = processQueue; + }, + + // Returns the number of files currently in the processsing queue: + processing: function () { + return this._processing; + }, + + // Processes the files given as files property of the data parameter, + // returns a Promise object that allows to bind callbacks: + process: function (data) { + var that = this, + options = $.extend({}, this.options, data); + if (options.processQueue && options.processQueue.length) { + this._transformProcessQueue(options); + if (this._processing === 0) { + this._trigger('processstart'); + } + $.each(data.files, function (index) { + var opts = index ? $.extend({}, options) : options, + func = function () { + return that._processFile(opts); + }; + opts.index = index; + that._processing += 1; + that._processingQueue = that._processingQueue.pipe(func, func) + .always(function () { + that._processing -= 1; + if (that._processing === 0) { + that._trigger('processstop'); + } + }); + }); + } + return this._processingQueue; + }, + + _create: function () { + this._super(); + this._processing = 0; + this._processingQueue = $.Deferred().resolveWith(this) + .promise(); + } + + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-ui.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-ui.js new file mode 100644 index 0000000000..59381fcb63 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-ui.js @@ -0,0 +1,643 @@ +/* + * jQuery File Upload User Interface Plugin 8.8.1 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2010, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true, regexp: true */ +/*global define, window, URL, webkitURL, FileReader */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'tmpl', + './jquery.fileupload-image', + './jquery.fileupload-audio', + './jquery.fileupload-video', + './jquery.fileupload-validate' + ], factory); + } else { + // Browser globals: + factory( + window.jQuery, + window.tmpl + ); + } +}(function ($, tmpl, loadImage) { + 'use strict'; + + $.blueimp.fileupload.prototype._specialOptions.push( + 'filesContainer', + 'uploadTemplateId', + 'downloadTemplateId' + ); + + // The UI version extends the file upload widget + // and adds complete user interface interaction: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + + options: { + // By default, files added to the widget are uploaded as soon + // as the user clicks on the start buttons. To enable automatic + // uploads, set the following option to true: + autoUpload: false, + // The ID of the upload template: + uploadTemplateId: 'template-upload', + // The ID of the download template: + downloadTemplateId: 'template-download', + // The container for the list of files. If undefined, it is set to + // an element with class "files" inside of the widget element: + filesContainer: undefined, + // By default, files are appended to the files container. + // Set the following option to true, to prepend files instead: + prependFiles: false, + // The expected data type of the upload response, sets the dataType + // option of the $.ajax upload requests: + dataType: 'json', + + // Function returning the current number of files, + // used by the maxNumberOfFiles validation: + getNumberOfFiles: function () { + return this.filesContainer.children().length; + }, + + // Callback to retrieve the list of files from the server response: + getFilesFromResponse: function (data) { + if (data.result && $.isArray(data.result.files)) { + return data.result.files; + } + return []; + }, + + // The add callback is invoked as soon as files are added to the fileupload + // widget (via file input selection, drag & drop or add API call). + // See the basic file upload widget for more information: + add: function (e, data) { + var $this = $(this), + that = $this.data('blueimp-fileupload') || + $this.data('fileupload'), + options = that.options, + files = data.files; + data.process(function () { + return $this.fileupload('process', data); + }).always(function () { + data.context = that._renderUpload(files).data('data', data); + that._renderPreviews(data); + options.filesContainer[ + options.prependFiles ? 'prepend' : 'append' + ](data.context); + that._forceReflow(data.context); + that._transition(data.context).done( + function () { + if ((that._trigger('added', e, data) !== false) && + (options.autoUpload || data.autoUpload) && + data.autoUpload !== false && !data.files.error) { + data.submit(); + } + } + ); + }); + }, + // Callback for the start of each file upload request: + send: function (e, data) { + var that = $(this).data('blueimp-fileupload') || + $(this).data('fileupload'); + if (data.context && data.dataType && + data.dataType.substr(0, 6) === 'iframe') { + // Iframe Transport does not support progress events. + // In lack of an indeterminate progress bar, we set + // the progress to 100%, showing the full animated bar: + data.context + .find('.progress').addClass( + !$.support.transition && 'progress-animated' + ) + .attr('aria-valuenow', 100) + .children().first().css( + 'width', + '100%' + ); + } + return that._trigger('sent', e, data); + }, + // Callback for successful uploads: + done: function (e, data) { + var that = $(this).data('blueimp-fileupload') || + $(this).data('fileupload'), + getFilesFromResponse = data.getFilesFromResponse || + that.options.getFilesFromResponse, + files = getFilesFromResponse(data), + template, + deferred; + if (data.context) { + data.context.each(function (index) { + var file = files[index] || + {error: 'Empty file upload result'}; + deferred = that._addFinishedDeferreds(); + that._transition($(this)).done( + function () { + var node = $(this); + template = that._renderDownload([file]) + .replaceAll(node); + that._forceReflow(template); + that._transition(template).done( + function () { + data.context = $(this); + that._trigger('completed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + } + ); + } + ); + }); + } else { + template = that._renderDownload(files)[ + that.options.prependFiles ? 'prependTo' : 'appendTo' + ](that.options.filesContainer); + that._forceReflow(template); + deferred = that._addFinishedDeferreds(); + that._transition(template).done( + function () { + data.context = $(this); + that._trigger('completed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + } + ); + } + }, + // Callback for failed (abort or error) uploads: + fail: function (e, data) { + var that = $(this).data('blueimp-fileupload') || + $(this).data('fileupload'), + template, + deferred; + if (data.context) { + data.context.each(function (index) { + if (data.errorThrown !== 'abort') { + var file = data.files[index]; + file.error = file.error || data.errorThrown || + true; + deferred = that._addFinishedDeferreds(); + that._transition($(this)).done( + function () { + var node = $(this); + template = that._renderDownload([file]) + .replaceAll(node); + that._forceReflow(template); + that._transition(template).done( + function () { + data.context = $(this); + that._trigger('failed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + } + ); + } + ); + } else { + deferred = that._addFinishedDeferreds(); + that._transition($(this)).done( + function () { + $(this).remove(); + that._trigger('failed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + } + ); + } + }); + } else if (data.errorThrown !== 'abort') { + data.context = that._renderUpload(data.files)[ + that.options.prependFiles ? 'prependTo' : 'appendTo' + ](that.options.filesContainer) + .data('data', data); + that._forceReflow(data.context); + deferred = that._addFinishedDeferreds(); + that._transition(data.context).done( + function () { + data.context = $(this); + that._trigger('failed', e, data); + that._trigger('finished', e, data); + deferred.resolve(); + } + ); + } else { + that._trigger('failed', e, data); + that._trigger('finished', e, data); + that._addFinishedDeferreds().resolve(); + } + }, + // Callback for upload progress events: + progress: function (e, data) { + if (data.context) { + var progress = Math.floor(data.loaded / data.total * 100); + data.context.find('.progress') + .attr('aria-valuenow', progress) + .children().first().css( + 'width', + progress + '%' + ); + } + }, + // Callback for global upload progress events: + progressall: function (e, data) { + var $this = $(this), + progress = Math.floor(data.loaded / data.total * 100), + globalProgressNode = $this.find('.fileupload-progress'), + extendedProgressNode = globalProgressNode + .find('.progress-extended'); + if (extendedProgressNode.length) { + extendedProgressNode.html( + ($this.data('blueimp-fileupload') || $this.data('fileupload')) + ._renderExtendedProgress(data) + ); + } + globalProgressNode + .find('.progress') + .attr('aria-valuenow', progress) + .children().first().css( + 'width', + progress + '%' + ); + }, + // Callback for uploads start, equivalent to the global ajaxStart event: + start: function (e) { + var that = $(this).data('blueimp-fileupload') || + $(this).data('fileupload'); + that._resetFinishedDeferreds(); + that._transition($(this).find('.fileupload-progress')).done( + function () { + that._trigger('started', e); + } + ); + }, + // Callback for uploads stop, equivalent to the global ajaxStop event: + stop: function (e) { + var that = $(this).data('blueimp-fileupload') || + $(this).data('fileupload'), + deferred = that._addFinishedDeferreds(); + $.when.apply($, that._getFinishedDeferreds()) + .done(function () { + that._trigger('stopped', e); + }); + that._transition($(this).find('.fileupload-progress')).done( + function () { + $(this).find('.progress') + .attr('aria-valuenow', '0') + .children().first().css('width', '0%'); + $(this).find('.progress-extended').html(' '); + deferred.resolve(); + } + ); + }, + processstart: function () { + $(this).addClass('fileupload-processing'); + }, + processstop: function () { + $(this).removeClass('fileupload-processing'); + }, + // Callback for file deletion: + destroy: function (e, data) { + var that = $(this).data('blueimp-fileupload') || + $(this).data('fileupload'), + removeNode = function () { + that._transition(data.context).done( + function () { + $(this).remove(); + that._trigger('destroyed', e, data); + } + ); + }; + if (data.url) { + $.ajax(data).done(removeNode); + } else { + removeNode(); + } + } + }, + + _resetFinishedDeferreds: function () { + this._finishedUploads = []; + }, + + _addFinishedDeferreds: function (deferred) { + if (!deferred) { + deferred = $.Deferred(); + } + this._finishedUploads.push(deferred); + return deferred; + }, + + _getFinishedDeferreds: function () { + return this._finishedUploads; + }, + + // Link handler, that allows to download files + // by drag & drop of the links to the desktop: + _enableDragToDesktop: function () { + var link = $(this), + url = link.prop('href'), + name = link.prop('download'), + type = 'application/octet-stream'; + link.bind('dragstart', function (e) { + try { + e.originalEvent.dataTransfer.setData( + 'DownloadURL', + [type, name, url].join(':') + ); + } catch (ignore) {} + }); + }, + + _formatFileSize: function (bytes) { + if (typeof bytes !== 'number') { + return ''; + } + if (bytes >= 1000000000) { + return (bytes / 1000000000).toFixed(2) + ' GB'; + } + if (bytes >= 1000000) { + return (bytes / 1000000).toFixed(2) + ' MB'; + } + return (bytes / 1000).toFixed(2) + ' KB'; + }, + + _formatBitrate: function (bits) { + if (typeof bits !== 'number') { + return ''; + } + if (bits >= 1000000000) { + return (bits / 1000000000).toFixed(2) + ' Gbit/s'; + } + if (bits >= 1000000) { + return (bits / 1000000).toFixed(2) + ' Mbit/s'; + } + if (bits >= 1000) { + return (bits / 1000).toFixed(2) + ' kbit/s'; + } + return bits.toFixed(2) + ' bit/s'; + }, + + _formatTime: function (seconds) { + var date = new Date(seconds * 1000), + days = Math.floor(seconds / 86400); + days = days ? days + 'd ' : ''; + return days + + ('0' + date.getUTCHours()).slice(-2) + ':' + + ('0' + date.getUTCMinutes()).slice(-2) + ':' + + ('0' + date.getUTCSeconds()).slice(-2); + }, + + _formatPercentage: function (floatValue) { + return (floatValue * 100).toFixed(2) + ' %'; + }, + + _renderExtendedProgress: function (data) { + return this._formatBitrate(data.bitrate) + ' | ' + + this._formatTime( + (data.total - data.loaded) * 8 / data.bitrate + ) + ' | ' + + this._formatPercentage( + data.loaded / data.total + ) + ' | ' + + this._formatFileSize(data.loaded) + ' / ' + + this._formatFileSize(data.total); + }, + + _renderTemplate: function (func, files) { + if (!func) { + return $(); + } + var result = func({ + files: files, + formatFileSize: this._formatFileSize, + options: this.options + }); + if (result instanceof $) { + return result; + } + return $(this.options.templatesContainer).html(result).children(); + }, + + _renderPreviews: function (data) { + data.context.find('.preview').each(function (index, elm) { + $(elm).append(data.files[index].preview); + }); + }, + + _renderUpload: function (files) { + return this._renderTemplate( + this.options.uploadTemplate, + files + ); + }, + + _renderDownload: function (files) { + return this._renderTemplate( + this.options.downloadTemplate, + files + ).find('a[download]').each(this._enableDragToDesktop).end(); + }, + + _startHandler: function (e) { + e.preventDefault(); + var button = $(e.currentTarget), + template = button.closest('.template-upload'), + data = template.data('data'); + if (data && data.submit && !data.jqXHR && data.submit()) { + button.prop('disabled', true); + } + }, + + _cancelHandler: function (e) { + e.preventDefault(); + var template = $(e.currentTarget).closest('.template-upload'), + data = template.data('data') || {}; + if (!data.jqXHR) { + data.errorThrown = 'abort'; + this._trigger('fail', e, data); + } else { + data.jqXHR.abort(); + } + }, + + _deleteHandler: function (e) { + e.preventDefault(); + var button = $(e.currentTarget); + this._trigger('destroy', e, $.extend({ + context: button.closest('.template-download'), + type: 'DELETE' + }, button.data())); + }, + + _forceReflow: function (node) { + return $.support.transition && node.length && + node[0].offsetWidth; + }, + + _transition: function (node) { + var dfd = $.Deferred(); + if ($.support.transition && node.hasClass('fade') && node.is(':visible')) { + node.bind( + $.support.transition.end, + function (e) { + // Make sure we don't respond to other transitions events + // in the container element, e.g. from button elements: + if (e.target === node[0]) { + node.unbind($.support.transition.end); + dfd.resolveWith(node); + } + } + ).toggleClass('in'); + } else { + node.toggleClass('in'); + dfd.resolveWith(node); + } + return dfd; + }, + + _initButtonBarEventHandlers: function () { + var fileUploadButtonBar = this.element.find('.fileupload-buttonbar'), + filesList = this.options.filesContainer; + this._on(fileUploadButtonBar.find('.start'), { + click: function (e) { + e.preventDefault(); + filesList.find('.start').click(); + } + }); + this._on(fileUploadButtonBar.find('.cancel'), { + click: function (e) { + e.preventDefault(); + filesList.find('.cancel').click(); + } + }); + this._on(fileUploadButtonBar.find('.delete'), { + click: function (e) { + e.preventDefault(); + filesList.find('.toggle:checked') + .closest('.template-download') + .find('.delete').click(); + fileUploadButtonBar.find('.toggle') + .prop('checked', false); + } + }); + this._on(fileUploadButtonBar.find('.toggle'), { + change: function (e) { + filesList.find('.toggle').prop( + 'checked', + $(e.currentTarget).is(':checked') + ); + } + }); + }, + + _destroyButtonBarEventHandlers: function () { + this._off( + this.element.find('.fileupload-buttonbar') + .find('.start, .cancel, .delete'), + 'click' + ); + this._off( + this.element.find('.fileupload-buttonbar .toggle'), + 'change.' + ); + }, + + _initEventHandlers: function () { + this._super(); + this._on(this.options.filesContainer, { + 'click .start': this._startHandler, + 'click .cancel': this._cancelHandler, + 'click .delete': this._deleteHandler + }); + this._initButtonBarEventHandlers(); + }, + + _destroyEventHandlers: function () { + this._destroyButtonBarEventHandlers(); + this._off(this.options.filesContainer, 'click'); + this._super(); + }, + + _enableFileInputButton: function () { + this.element.find('.fileinput-button input') + .prop('disabled', false) + .parent().removeClass('disabled'); + }, + + _disableFileInputButton: function () { + this.element.find('.fileinput-button input') + .prop('disabled', true) + .parent().addClass('disabled'); + }, + + _initTemplates: function () { + var options = this.options; + options.templatesContainer = this.document[0].createElement( + options.filesContainer.prop('nodeName') + ); + if (tmpl) { + if (options.uploadTemplateId) { + options.uploadTemplate = tmpl(options.uploadTemplateId); + } + if (options.downloadTemplateId) { + options.downloadTemplate = tmpl(options.downloadTemplateId); + } + } + }, + + _initFilesContainer: function () { + var options = this.options; + if (options.filesContainer === undefined) { + options.filesContainer = this.element.find('.files'); + } else if (!(options.filesContainer instanceof $)) { + options.filesContainer = $(options.filesContainer); + } + }, + + _initSpecialOptions: function () { + this._super(); + this._initFilesContainer(); + this._initTemplates(); + }, + + _create: function () { + this._super(); + this._resetFinishedDeferreds(); + if (!$.support.fileInput) { + this._disableFileInputButton(); + } + }, + + enable: function () { + var wasDisabled = false; + if (this.options.disabled) { + wasDisabled = true; + } + this._super(); + if (wasDisabled) { + this.element.find('input, button').prop('disabled', false); + this._enableFileInputButton(); + } + }, + + disable: function () { + if (!this.options.disabled) { + this.element.find('input, button').prop('disabled', true); + this._disableFileInputButton(); + } + this._super(); + } + + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate.js new file mode 100644 index 0000000000..ee1c2f2ed4 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-validate.js @@ -0,0 +1,117 @@ +/* + * jQuery File Upload Validation Plugin 1.1.1 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true, regexp: true */ +/*global define, window */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + './jquery.fileupload-process' + ], factory); + } else { + // Browser globals: + factory( + window.jQuery + ); + } +}(function ($) { + 'use strict'; + + // Append to the default processQueue: + $.blueimp.fileupload.prototype.options.processQueue.push( + { + action: 'validate', + // Always trigger this action, + // even if the previous action was rejected: + always: true, + // Options taken from the global options map: + acceptFileTypes: '@', + maxFileSize: '@', + minFileSize: '@', + maxNumberOfFiles: '@', + disabled: '@disableValidation' + } + ); + + // The File Upload Validation plugin extends the fileupload widget + // with file validation functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + + options: { + /* + // The regular expression for allowed file types, matches + // against either file type or file name: + acceptFileTypes: /(\.|\/)(gif|jpe?g|png)$/i, + // The maximum allowed file size in bytes: + maxFileSize: 10000000, // 10 MB + // The minimum allowed file size in bytes: + minFileSize: undefined, // No minimal file size + // The limit of files to be uploaded: + maxNumberOfFiles: 10, + */ + + // Function returning the current number of files, + // has to be overriden for maxNumberOfFiles validation: + getNumberOfFiles: $.noop, + + // Error and info messages: + messages: { + maxNumberOfFiles: 'Maximum number of files exceeded', + acceptFileTypes: 'File type not allowed', + maxFileSize: 'File is too large', + minFileSize: 'File is too small' + } + }, + + processActions: { + + validate: function (data, options) { + if (options.disabled) { + return data; + } + var dfd = $.Deferred(), + settings = this.options, + file = data.files[data.index]; + if ($.type(options.maxNumberOfFiles) === 'number' && + (settings.getNumberOfFiles() || 0) + data.files.length > + options.maxNumberOfFiles) { + file.error = settings.i18n('maxNumberOfFiles'); + } else if (options.acceptFileTypes && + !(options.acceptFileTypes.test(file.type) || + options.acceptFileTypes.test(file.name))) { + file.error = settings.i18n('acceptFileTypes'); + } else if (options.maxFileSize && file.size > + options.maxFileSize) { + file.error = settings.i18n('maxFileSize'); + } else if ($.type(file.size) === 'number' && + file.size < options.minFileSize) { + file.error = settings.i18n('minFileSize'); + } else { + delete file.error; + } + if (file.error || data.files.error) { + data.files.error = true; + dfd.rejectWith(this, [data]); + } else { + dfd.resolveWith(this, [data]); + } + return dfd.promise(); + } + + } + + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-video.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-video.js new file mode 100644 index 0000000000..c8b1019367 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload-video.js @@ -0,0 +1,106 @@ +/* + * jQuery File Upload Video Preview Plugin 1.0.3 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true, regexp: true */ +/*global define, window, document */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'load-image', + './jquery.fileupload-process' + ], factory); + } else { + // Browser globals: + factory( + window.jQuery, + window.loadImage + ); + } +}(function ($, loadImage) { + 'use strict'; + + // Prepend to the default processQueue: + $.blueimp.fileupload.prototype.options.processQueue.unshift( + { + action: 'loadVideo', + // Use the action as prefix for the "@" options: + prefix: true, + fileTypes: '@', + maxFileSize: '@', + disabled: '@disableVideoPreview' + }, + { + action: 'setVideo', + name: '@videoPreviewName', + disabled: '@disableVideoPreview' + } + ); + + // The File Upload Video Preview plugin extends the fileupload widget + // with video preview functionality: + $.widget('blueimp.fileupload', $.blueimp.fileupload, { + + options: { + // The regular expression for the types of video files to load, + // matched against the file type: + loadVideoFileTypes: /^video\/.*$/ + }, + + _videoElement: document.createElement('video'), + + processActions: { + + // Loads the video file given via data.files and data.index + // as video element if the browser supports playing it. + // Accepts the options fileTypes (regular expression) + // and maxFileSize (integer) to limit the files to load: + loadVideo: function (data, options) { + if (options.disabled) { + return data; + } + var file = data.files[data.index], + url, + video; + if (this._videoElement.canPlayType && + this._videoElement.canPlayType(file.type) && + ($.type(options.maxFileSize) !== 'number' || + file.size <= options.maxFileSize) && + (!options.fileTypes || + options.fileTypes.test(file.type))) { + url = loadImage.createObjectURL(file); + if (url) { + video = this._videoElement.cloneNode(false); + video.src = url; + video.controls = true; + data.video = video; + return data; + } + } + return data; + }, + + // Sets the video element as a property of the file object: + setVideo: function (data, options) { + if (data.video && !options.disabled) { + data.files[data.index][options.name || 'preview'] = data.video; + } + return data; + } + + } + + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js new file mode 100644 index 0000000000..8755c082c3 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.fileupload.js @@ -0,0 +1,1333 @@ +/* + * jQuery File Upload Plugin 5.32.2 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2010, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, unparam: true, regexp: true */ +/*global define, window, document, location, File, Blob, FormData */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define([ + 'jquery', + 'jquery.ui.widget' + ], factory); + } else { + // Browser globals: + factory(window.jQuery); + } +}(function ($) { + 'use strict'; + + // Detect file input support, based on + // http://viljamis.com/blog/2012/file-upload-support-on-mobile/ + $.support.fileInput = !(new RegExp( + // Handle devices which give false positives for the feature detection: + '(Android (1\\.[0156]|2\\.[01]))' + + '|(Windows Phone (OS 7|8\\.0))|(XBLWP)|(ZuneWP)|(WPDesktop)' + + '|(w(eb)?OSBrowser)|(webOS)' + + '|(Kindle/(1\\.0|2\\.[05]|3\\.0))' + ).test(window.navigator.userAgent) || + // Feature detection for all other devices: + $('').prop('disabled')); + + // The FileReader API is not actually used, but works as feature detection, + // as e.g. Safari supports XHR file uploads via the FormData API, + // but not non-multipart XHR file uploads: + $.support.xhrFileUpload = !!(window.XMLHttpRequestUpload && window.FileReader); + $.support.xhrFormDataFileUpload = !!window.FormData; + + // Detect support for Blob slicing (required for chunked uploads): + $.support.blobSlice = window.Blob && (Blob.prototype.slice || + Blob.prototype.webkitSlice || Blob.prototype.mozSlice); + + // The fileupload widget listens for change events on file input fields defined + // via fileInput setting and paste or drop events of the given dropZone. + // In addition to the default jQuery Widget methods, the fileupload widget + // exposes the "add" and "send" methods, to add or directly send files using + // the fileupload API. + // By default, files added via file input selection, paste, drag & drop or + // "add" method are uploaded immediately, but it is possible to override + // the "add" callback option to queue file uploads. + $.widget('blueimp.fileupload', { + + options: { + // The drop target element(s), by the default the complete document. + // Set to null to disable drag & drop support: + dropZone: $(document), + // The paste target element(s), by the default the complete document. + // Set to null to disable paste support: + pasteZone: $(document), + // The file input field(s), that are listened to for change events. + // If undefined, it is set to the file input fields inside + // of the widget element on plugin initialization. + // Set to null to disable the change listener. + fileInput: undefined, + // By default, the file input field is replaced with a clone after + // each input field change event. This is required for iframe transport + // queues and allows change events to be fired for the same file + // selection, but can be disabled by setting the following option to false: + replaceFileInput: true, + // The parameter name for the file form data (the request argument name). + // If undefined or empty, the name property of the file input field is + // used, or "files[]" if the file input name property is also empty, + // can be a string or an array of strings: + paramName: undefined, + // By default, each file of a selection is uploaded using an individual + // request for XHR type uploads. Set to false to upload file + // selections in one request each: + singleFileUploads: true, + // To limit the number of files uploaded with one XHR request, + // set the following option to an integer greater than 0: + limitMultiFileUploads: undefined, + // Set the following option to true to issue all file upload requests + // in a sequential order: + sequentialUploads: false, + // To limit the number of concurrent uploads, + // set the following option to an integer greater than 0: + limitConcurrentUploads: undefined, + // Set the following option to true to force iframe transport uploads: + forceIframeTransport: false, + // Set the following option to the location of a redirect url on the + // origin server, for cross-domain iframe transport uploads: + redirect: undefined, + // The parameter name for the redirect url, sent as part of the form + // data and set to 'redirect' if this option is empty: + redirectParamName: undefined, + // Set the following option to the location of a postMessage window, + // to enable postMessage transport uploads: + postMessage: undefined, + // By default, XHR file uploads are sent as multipart/form-data. + // The iframe transport is always using multipart/form-data. + // Set to false to enable non-multipart XHR uploads: + multipart: true, + // To upload large files in smaller chunks, set the following option + // to a preferred maximum chunk size. If set to 0, null or undefined, + // or the browser does not support the required Blob API, files will + // be uploaded as a whole. + maxChunkSize: undefined, + // When a non-multipart upload or a chunked multipart upload has been + // aborted, this option can be used to resume the upload by setting + // it to the size of the already uploaded bytes. This option is most + // useful when modifying the options object inside of the "add" or + // "send" callbacks, as the options are cloned for each file upload. + uploadedBytes: undefined, + // By default, failed (abort or error) file uploads are removed from the + // global progress calculation. Set the following option to false to + // prevent recalculating the global progress data: + recalculateProgress: true, + // Interval in milliseconds to calculate and trigger progress events: + progressInterval: 100, + // Interval in milliseconds to calculate progress bitrate: + bitrateInterval: 500, + // By default, uploads are started automatically when adding files: + autoUpload: true, + + // Error and info messages: + messages: { + uploadedBytes: 'Uploaded bytes exceed file size' + }, + + // Translation function, gets the message key to be translated + // and an object with context specific data as arguments: + i18n: function (message, context) { + message = this.messages[message] || message.toString(); + if (context) { + $.each(context, function (key, value) { + message = message.replace('{' + key + '}', value); + }); + } + return message; + }, + + // Additional form data to be sent along with the file uploads can be set + // using this option, which accepts an array of objects with name and + // value properties, a function returning such an array, a FormData + // object (for XHR file uploads), or a simple object. + // The form of the first fileInput is given as parameter to the function: + formData: function (form) { + return form.serializeArray(); + }, + + // The add callback is invoked as soon as files are added to the fileupload + // widget (via file input selection, drag & drop, paste or add API call). + // If the singleFileUploads option is enabled, this callback will be + // called once for each file in the selection for XHR file uploads, else + // once for each file selection. + // + // The upload starts when the submit method is invoked on the data parameter. + // The data object contains a files property holding the added files + // and allows you to override plugin options as well as define ajax settings. + // + // Listeners for this callback can also be bound the following way: + // .bind('fileuploadadd', func); + // + // data.submit() returns a Promise object and allows to attach additional + // handlers using jQuery's Deferred callbacks: + // data.submit().done(func).fail(func).always(func); + add: function (e, data) { + if (data.autoUpload || (data.autoUpload !== false && + $(this).fileupload('option', 'autoUpload'))) { + data.process().done(function () { + data.submit(); + }); + } + }, + + // Other callbacks: + + // Callback for the submit event of each file upload: + // submit: function (e, data) {}, // .bind('fileuploadsubmit', func); + + // Callback for the start of each file upload request: + // send: function (e, data) {}, // .bind('fileuploadsend', func); + + // Callback for successful uploads: + // done: function (e, data) {}, // .bind('fileuploaddone', func); + + // Callback for failed (abort or error) uploads: + // fail: function (e, data) {}, // .bind('fileuploadfail', func); + + // Callback for completed (success, abort or error) requests: + // always: function (e, data) {}, // .bind('fileuploadalways', func); + + // Callback for upload progress events: + // progress: function (e, data) {}, // .bind('fileuploadprogress', func); + + // Callback for global upload progress events: + // progressall: function (e, data) {}, // .bind('fileuploadprogressall', func); + + // Callback for uploads start, equivalent to the global ajaxStart event: + // start: function (e) {}, // .bind('fileuploadstart', func); + + // Callback for uploads stop, equivalent to the global ajaxStop event: + // stop: function (e) {}, // .bind('fileuploadstop', func); + + // Callback for change events of the fileInput(s): + // change: function (e, data) {}, // .bind('fileuploadchange', func); + + // Callback for paste events to the pasteZone(s): + // paste: function (e, data) {}, // .bind('fileuploadpaste', func); + + // Callback for drop events of the dropZone(s): + // drop: function (e, data) {}, // .bind('fileuploaddrop', func); + + // Callback for dragover events of the dropZone(s): + // dragover: function (e) {}, // .bind('fileuploaddragover', func); + + // Callback for the start of each chunk upload request: + // chunksend: function (e, data) {}, // .bind('fileuploadchunksend', func); + + // Callback for successful chunk uploads: + // chunkdone: function (e, data) {}, // .bind('fileuploadchunkdone', func); + + // Callback for failed (abort or error) chunk uploads: + // chunkfail: function (e, data) {}, // .bind('fileuploadchunkfail', func); + + // Callback for completed (success, abort or error) chunk upload requests: + // chunkalways: function (e, data) {}, // .bind('fileuploadchunkalways', func); + + // The plugin options are used as settings object for the ajax calls. + // The following are jQuery ajax settings required for the file uploads: + processData: false, + contentType: false, + cache: false + }, + + // A list of options that require reinitializing event listeners and/or + // special initialization code: + _specialOptions: [ + 'fileInput', + 'dropZone', + 'pasteZone', + 'multipart', + 'forceIframeTransport' + ], + + _blobSlice: $.support.blobSlice && function () { + var slice = this.slice || this.webkitSlice || this.mozSlice; + return slice.apply(this, arguments); + }, + + _BitrateTimer: function () { + this.timestamp = ((Date.now) ? Date.now() : (new Date()).getTime()); + this.loaded = 0; + this.bitrate = 0; + this.getBitrate = function (now, loaded, interval) { + var timeDiff = now - this.timestamp; + if (!this.bitrate || !interval || timeDiff > interval) { + this.bitrate = (loaded - this.loaded) * (1000 / timeDiff) * 8; + this.loaded = loaded; + this.timestamp = now; + } + return this.bitrate; + }; + }, + + _isXHRUpload: function (options) { + return !options.forceIframeTransport && + ((!options.multipart && $.support.xhrFileUpload) || + $.support.xhrFormDataFileUpload); + }, + + _getFormData: function (options) { + var formData; + if (typeof options.formData === 'function') { + return options.formData(options.form); + } + if ($.isArray(options.formData)) { + return options.formData; + } + if ($.type(options.formData) === 'object') { + formData = []; + $.each(options.formData, function (name, value) { + formData.push({name: name, value: value}); + }); + return formData; + } + return []; + }, + + _getTotal: function (files) { + var total = 0; + $.each(files, function (index, file) { + total += file.size || 1; + }); + return total; + }, + + _initProgressObject: function (obj) { + var progress = { + loaded: 0, + total: 0, + bitrate: 0 + }; + if (obj._progress) { + $.extend(obj._progress, progress); + } else { + obj._progress = progress; + } + }, + + _initResponseObject: function (obj) { + var prop; + if (obj._response) { + for (prop in obj._response) { + if (obj._response.hasOwnProperty(prop)) { + delete obj._response[prop]; + } + } + } else { + obj._response = {}; + } + }, + + _onProgress: function (e, data) { + if (e.lengthComputable) { + var now = ((Date.now) ? Date.now() : (new Date()).getTime()), + loaded; + if (data._time && data.progressInterval && + (now - data._time < data.progressInterval) && + e.loaded !== e.total) { + return; + } + data._time = now; + loaded = Math.floor( + e.loaded / e.total * (data.chunkSize || data._progress.total) + ) + (data.uploadedBytes || 0); + // Add the difference from the previously loaded state + // to the global loaded counter: + this._progress.loaded += (loaded - data._progress.loaded); + this._progress.bitrate = this._bitrateTimer.getBitrate( + now, + this._progress.loaded, + data.bitrateInterval + ); + data._progress.loaded = data.loaded = loaded; + data._progress.bitrate = data.bitrate = data._bitrateTimer.getBitrate( + now, + loaded, + data.bitrateInterval + ); + // Trigger a custom progress event with a total data property set + // to the file size(s) of the current upload and a loaded data + // property calculated accordingly: + this._trigger('progress', e, data); + // Trigger a global progress event for all current file uploads, + // including ajax calls queued for sequential file uploads: + this._trigger('progressall', e, this._progress); + } + }, + + _initProgressListener: function (options) { + var that = this, + xhr = options.xhr ? options.xhr() : $.ajaxSettings.xhr(); + // Accesss to the native XHR object is required to add event listeners + // for the upload progress event: + if (xhr.upload) { + $(xhr.upload).bind('progress', function (e) { + var oe = e.originalEvent; + // Make sure the progress event properties get copied over: + e.lengthComputable = oe.lengthComputable; + e.loaded = oe.loaded; + e.total = oe.total; + that._onProgress(e, options); + }); + options.xhr = function () { + return xhr; + }; + } + }, + + _isInstanceOf: function (type, obj) { + // Cross-frame instanceof check + return Object.prototype.toString.call(obj) === '[object ' + type + ']'; + }, + + _initXHRData: function (options) { + var that = this, + formData, + file = options.files[0], + // Ignore non-multipart setting if not supported: + multipart = options.multipart || !$.support.xhrFileUpload, + paramName = options.paramName[0]; + options.headers = options.headers || {}; + if (options.contentRange) { + options.headers['Content-Range'] = options.contentRange; + } + if (!multipart || options.blob || !this._isInstanceOf('File', file)) { + options.headers['Content-Disposition'] = 'attachment; filename="' + + encodeURI(file.name) + '"'; + } + if (!multipart) { + options.contentType = file.type; + options.data = options.blob || file; + } else if ($.support.xhrFormDataFileUpload) { + if (options.postMessage) { + // window.postMessage does not allow sending FormData + // objects, so we just add the File/Blob objects to + // the formData array and let the postMessage window + // create the FormData object out of this array: + formData = this._getFormData(options); + if (options.blob) { + formData.push({ + name: paramName, + value: options.blob + }); + } else { + $.each(options.files, function (index, file) { + formData.push({ + name: options.paramName[index] || paramName, + value: file + }); + }); + } + } else { + if (that._isInstanceOf('FormData', options.formData)) { + formData = options.formData; + } else { + formData = new FormData(); + $.each(this._getFormData(options), function (index, field) { + formData.append(field.name, field.value); + }); + } + if (options.blob) { + formData.append(paramName, options.blob, file.name); + } else { + $.each(options.files, function (index, file) { + // This check allows the tests to run with + // dummy objects: + if (that._isInstanceOf('File', file) || + that._isInstanceOf('Blob', file)) { + formData.append( + options.paramName[index] || paramName, + file, + file.name + ); + } + }); + } + } + options.data = formData; + } + // Blob reference is not needed anymore, free memory: + options.blob = null; + }, + + _initIframeSettings: function (options) { + var targetHost = $('').prop('href', options.url).prop('host'); + // Setting the dataType to iframe enables the iframe transport: + options.dataType = 'iframe ' + (options.dataType || ''); + // The iframe transport accepts a serialized array as form data: + options.formData = this._getFormData(options); + // Add redirect url to form data on cross-domain uploads: + if (options.redirect && targetHost && targetHost !== location.host) { + options.formData.push({ + name: options.redirectParamName || 'redirect', + value: options.redirect + }); + } + }, + + _initDataSettings: function (options) { + if (this._isXHRUpload(options)) { + if (!this._chunkedUpload(options, true)) { + if (!options.data) { + this._initXHRData(options); + } + this._initProgressListener(options); + } + if (options.postMessage) { + // Setting the dataType to postmessage enables the + // postMessage transport: + options.dataType = 'postmessage ' + (options.dataType || ''); + } + } else { + this._initIframeSettings(options); + } + }, + + _getParamName: function (options) { + var fileInput = $(options.fileInput), + paramName = options.paramName; + if (!paramName) { + paramName = []; + fileInput.each(function () { + var input = $(this), + name = input.prop('name') || 'files[]', + i = (input.prop('files') || [1]).length; + while (i) { + paramName.push(name); + i -= 1; + } + }); + if (!paramName.length) { + paramName = [fileInput.prop('name') || 'files[]']; + } + } else if (!$.isArray(paramName)) { + paramName = [paramName]; + } + return paramName; + }, + + _initFormSettings: function (options) { + // Retrieve missing options from the input field and the + // associated form, if available: + if (!options.form || !options.form.length) { + options.form = $(options.fileInput.prop('form')); + // If the given file input doesn't have an associated form, + // use the default widget file input's form: + if (!options.form.length) { + options.form = $(this.options.fileInput.prop('form')); + } + } + options.paramName = this._getParamName(options); + if (!options.url) { + options.url = options.form.prop('action') || location.href; + } + // The HTTP request method must be "POST" or "PUT": + options.type = (options.type || options.form.prop('method') || '') + .toUpperCase(); + if (options.type !== 'POST' && options.type !== 'PUT' && + options.type !== 'PATCH') { + options.type = 'POST'; + } + if (!options.formAcceptCharset) { + options.formAcceptCharset = options.form.attr('accept-charset'); + } + }, + + _getAJAXSettings: function (data) { + var options = $.extend({}, this.options, data); + this._initFormSettings(options); + this._initDataSettings(options); + return options; + }, + + // jQuery 1.6 doesn't provide .state(), + // while jQuery 1.8+ removed .isRejected() and .isResolved(): + _getDeferredState: function (deferred) { + if (deferred.state) { + return deferred.state(); + } + if (deferred.isResolved()) { + return 'resolved'; + } + if (deferred.isRejected()) { + return 'rejected'; + } + return 'pending'; + }, + + // Maps jqXHR callbacks to the equivalent + // methods of the given Promise object: + _enhancePromise: function (promise) { + promise.success = promise.done; + promise.error = promise.fail; + promise.complete = promise.always; + return promise; + }, + + // Creates and returns a Promise object enhanced with + // the jqXHR methods abort, success, error and complete: + _getXHRPromise: function (resolveOrReject, context, args) { + var dfd = $.Deferred(), + promise = dfd.promise(); + context = context || this.options.context || promise; + if (resolveOrReject === true) { + dfd.resolveWith(context, args); + } else if (resolveOrReject === false) { + dfd.rejectWith(context, args); + } + promise.abort = dfd.promise; + return this._enhancePromise(promise); + }, + + // Adds convenience methods to the data callback argument: + _addConvenienceMethods: function (e, data) { + var that = this, + getPromise = function (data) { + return $.Deferred().resolveWith(that, [data]).promise(); + }; + data.process = function (resolveFunc, rejectFunc) { + if (resolveFunc || rejectFunc) { + data._processQueue = this._processQueue = + (this._processQueue || getPromise(this)) + .pipe(resolveFunc, rejectFunc); + } + return this._processQueue || getPromise(this); + }; + data.submit = function () { + if (this.state() !== 'pending') { + data.jqXHR = this.jqXHR = + (that._trigger('submit', e, this) !== false) && + that._onSend(e, this); + } + return this.jqXHR || that._getXHRPromise(); + }; + data.abort = function () { + if (this.jqXHR) { + return this.jqXHR.abort(); + } + return that._getXHRPromise(); + }; + data.state = function () { + if (this.jqXHR) { + return that._getDeferredState(this.jqXHR); + } + if (this._processQueue) { + return that._getDeferredState(this._processQueue); + } + }; + data.progress = function () { + return this._progress; + }; + data.response = function () { + return this._response; + }; + }, + + // Parses the Range header from the server response + // and returns the uploaded bytes: + _getUploadedBytes: function (jqXHR) { + var range = jqXHR.getResponseHeader('Range'), + parts = range && range.split('-'), + upperBytesPos = parts && parts.length > 1 && + parseInt(parts[1], 10); + return upperBytesPos && upperBytesPos + 1; + }, + + // Uploads a file in multiple, sequential requests + // by splitting the file up in multiple blob chunks. + // If the second parameter is true, only tests if the file + // should be uploaded in chunks, but does not invoke any + // upload requests: + _chunkedUpload: function (options, testOnly) { + options.uploadedBytes = options.uploadedBytes || 0; + var that = this, + file = options.files[0], + fs = file.size, + ub = options.uploadedBytes, + mcs = options.maxChunkSize || fs, + slice = this._blobSlice, + dfd = $.Deferred(), + promise = dfd.promise(), + jqXHR, + upload; + if (!(this._isXHRUpload(options) && slice && (ub || mcs < fs)) || + options.data) { + return false; + } + if (testOnly) { + return true; + } + if (ub >= fs) { + file.error = options.i18n('uploadedBytes'); + return this._getXHRPromise( + false, + options.context, + [null, 'error', file.error] + ); + } + // The chunk upload method: + upload = function () { + // Clone the options object for each chunk upload: + var o = $.extend({}, options), + currentLoaded = o._progress.loaded; + o.blob = slice.call( + file, + ub, + ub + mcs, + file.type + ); + // Store the current chunk size, as the blob itself + // will be dereferenced after data processing: + o.chunkSize = o.blob.size; + // Expose the chunk bytes position range: + o.contentRange = 'bytes ' + ub + '-' + + (ub + o.chunkSize - 1) + '/' + fs; + // Process the upload data (the blob and potential form data): + that._initXHRData(o); + // Add progress listeners for this chunk upload: + that._initProgressListener(o); + jqXHR = ((that._trigger('chunksend', null, o) !== false && $.ajax(o)) || + that._getXHRPromise(false, o.context)) + .done(function (result, textStatus, jqXHR) { + ub = that._getUploadedBytes(jqXHR) || + (ub + o.chunkSize); + // Create a progress event if no final progress event + // with loaded equaling total has been triggered + // for this chunk: + if (currentLoaded + o.chunkSize - o._progress.loaded) { + that._onProgress($.Event('progress', { + lengthComputable: true, + loaded: ub - o.uploadedBytes, + total: ub - o.uploadedBytes + }), o); + } + options.uploadedBytes = o.uploadedBytes = ub; + o.result = result; + o.textStatus = textStatus; + o.jqXHR = jqXHR; + that._trigger('chunkdone', null, o); + that._trigger('chunkalways', null, o); + if (ub < fs) { + // File upload not yet complete, + // continue with the next chunk: + upload(); + } else { + dfd.resolveWith( + o.context, + [result, textStatus, jqXHR] + ); + } + }) + .fail(function (jqXHR, textStatus, errorThrown) { + o.jqXHR = jqXHR; + o.textStatus = textStatus; + o.errorThrown = errorThrown; + that._trigger('chunkfail', null, o); + that._trigger('chunkalways', null, o); + dfd.rejectWith( + o.context, + [jqXHR, textStatus, errorThrown] + ); + }); + }; + this._enhancePromise(promise); + promise.abort = function () { + return jqXHR.abort(); + }; + upload(); + return promise; + }, + + _beforeSend: function (e, data) { + if (this._active === 0) { + // the start callback is triggered when an upload starts + // and no other uploads are currently running, + // equivalent to the global ajaxStart event: + this._trigger('start'); + // Set timer for global bitrate progress calculation: + this._bitrateTimer = new this._BitrateTimer(); + // Reset the global progress values: + this._progress.loaded = this._progress.total = 0; + this._progress.bitrate = 0; + } + // Make sure the container objects for the .response() and + // .progress() methods on the data object are available + // and reset to their initial state: + this._initResponseObject(data); + this._initProgressObject(data); + data._progress.loaded = data.loaded = data.uploadedBytes || 0; + data._progress.total = data.total = this._getTotal(data.files) || 1; + data._progress.bitrate = data.bitrate = 0; + this._active += 1; + // Initialize the global progress values: + this._progress.loaded += data.loaded; + this._progress.total += data.total; + }, + + _onDone: function (result, textStatus, jqXHR, options) { + var total = options._progress.total, + response = options._response; + if (options._progress.loaded < total) { + // Create a progress event if no final progress event + // with loaded equaling total has been triggered: + this._onProgress($.Event('progress', { + lengthComputable: true, + loaded: total, + total: total + }), options); + } + response.result = options.result = result; + response.textStatus = options.textStatus = textStatus; + response.jqXHR = options.jqXHR = jqXHR; + this._trigger('done', null, options); + }, + + _onFail: function (jqXHR, textStatus, errorThrown, options) { + var response = options._response; + if (options.recalculateProgress) { + // Remove the failed (error or abort) file upload from + // the global progress calculation: + this._progress.loaded -= options._progress.loaded; + this._progress.total -= options._progress.total; + } + response.jqXHR = options.jqXHR = jqXHR; + response.textStatus = options.textStatus = textStatus; + response.errorThrown = options.errorThrown = errorThrown; + this._trigger('fail', null, options); + }, + + _onAlways: function (jqXHRorResult, textStatus, jqXHRorError, options) { + // jqXHRorResult, textStatus and jqXHRorError are added to the + // options object via done and fail callbacks + this._trigger('always', null, options); + }, + + _onSend: function (e, data) { + if (!data.submit) { + this._addConvenienceMethods(e, data); + } + var that = this, + jqXHR, + aborted, + slot, + pipe, + options = that._getAJAXSettings(data), + send = function () { + that._sending += 1; + // Set timer for bitrate progress calculation: + options._bitrateTimer = new that._BitrateTimer(); + jqXHR = jqXHR || ( + ((aborted || that._trigger('send', e, options) === false) && + that._getXHRPromise(false, options.context, aborted)) || + that._chunkedUpload(options) || $.ajax(options) + ).done(function (result, textStatus, jqXHR) { + that._onDone(result, textStatus, jqXHR, options); + }).fail(function (jqXHR, textStatus, errorThrown) { + that._onFail(jqXHR, textStatus, errorThrown, options); + }).always(function (jqXHRorResult, textStatus, jqXHRorError) { + that._onAlways( + jqXHRorResult, + textStatus, + jqXHRorError, + options + ); + that._sending -= 1; + that._active -= 1; + if (options.limitConcurrentUploads && + options.limitConcurrentUploads > that._sending) { + // Start the next queued upload, + // that has not been aborted: + var nextSlot = that._slots.shift(); + while (nextSlot) { + if (that._getDeferredState(nextSlot) === 'pending') { + nextSlot.resolve(); + break; + } + nextSlot = that._slots.shift(); + } + } + if (that._active === 0) { + // The stop callback is triggered when all uploads have + // been completed, equivalent to the global ajaxStop event: + that._trigger('stop'); + } + }); + return jqXHR; + }; + this._beforeSend(e, options); + if (this.options.sequentialUploads || + (this.options.limitConcurrentUploads && + this.options.limitConcurrentUploads <= this._sending)) { + if (this.options.limitConcurrentUploads > 1) { + slot = $.Deferred(); + this._slots.push(slot); + pipe = slot.pipe(send); + } else { + this._sequence = this._sequence.pipe(send, send); + pipe = this._sequence; + } + // Return the piped Promise object, enhanced with an abort method, + // which is delegated to the jqXHR object of the current upload, + // and jqXHR callbacks mapped to the equivalent Promise methods: + pipe.abort = function () { + aborted = [undefined, 'abort', 'abort']; + if (!jqXHR) { + if (slot) { + slot.rejectWith(options.context, aborted); + } + return send(); + } + return jqXHR.abort(); + }; + return this._enhancePromise(pipe); + } + return send(); + }, + + _onAdd: function (e, data) { + var that = this, + result = true, + options = $.extend({}, this.options, data), + limit = options.limitMultiFileUploads, + paramName = this._getParamName(options), + paramNameSet, + paramNameSlice, + fileSet, + i; + if (!(options.singleFileUploads || limit) || + !this._isXHRUpload(options)) { + fileSet = [data.files]; + paramNameSet = [paramName]; + } else if (!options.singleFileUploads && limit) { + fileSet = []; + paramNameSet = []; + for (i = 0; i < data.files.length; i += limit) { + fileSet.push(data.files.slice(i, i + limit)); + paramNameSlice = paramName.slice(i, i + limit); + if (!paramNameSlice.length) { + paramNameSlice = paramName; + } + paramNameSet.push(paramNameSlice); + } + } else { + paramNameSet = paramName; + } + data.originalFiles = data.files; + $.each(fileSet || data.files, function (index, element) { + var newData = $.extend({}, data); + newData.files = fileSet ? element : [element]; + newData.paramName = paramNameSet[index]; + that._initResponseObject(newData); + that._initProgressObject(newData); + that._addConvenienceMethods(e, newData); + result = that._trigger('add', e, newData); + return result; + }); + return result; + }, + + _replaceFileInput: function (input) { + var inputClone = input.clone(true); + $('
    ').append(inputClone)[0].reset(); + // Detaching allows to insert the fileInput on another form + // without loosing the file input value: + input.after(inputClone).detach(); + // Avoid memory leaks with the detached file input: + $.cleanData(input.unbind('remove')); + // Replace the original file input element in the fileInput + // elements set with the clone, which has been copied including + // event handlers: + this.options.fileInput = this.options.fileInput.map(function (i, el) { + if (el === input[0]) { + return inputClone[0]; + } + return el; + }); + // If the widget has been initialized on the file input itself, + // override this.element with the file input clone: + if (input[0] === this.element[0]) { + this.element = inputClone; + } + }, + + _handleFileTreeEntry: function (entry, path) { + var that = this, + dfd = $.Deferred(), + errorHandler = function (e) { + if (e && !e.entry) { + e.entry = entry; + } + // Since $.when returns immediately if one + // Deferred is rejected, we use resolve instead. + // This allows valid files and invalid items + // to be returned together in one set: + dfd.resolve([e]); + }, + dirReader; + path = path || ''; + if (entry.isFile) { + if (entry._file) { + // Workaround for Chrome bug #149735 + entry._file.relativePath = path; + dfd.resolve(entry._file); + } else { + entry.file(function (file) { + file.relativePath = path; + dfd.resolve(file); + }, errorHandler); + } + } else if (entry.isDirectory) { + dirReader = entry.createReader(); + dirReader.readEntries(function (entries) { + that._handleFileTreeEntries( + entries, + path + entry.name + '/' + ).done(function (files) { + dfd.resolve(files); + }).fail(errorHandler); + }, errorHandler); + } else { + // Return an empy list for file system items + // other than files or directories: + dfd.resolve([]); + } + return dfd.promise(); + }, + + _handleFileTreeEntries: function (entries, path) { + var that = this; + return $.when.apply( + $, + $.map(entries, function (entry) { + return that._handleFileTreeEntry(entry, path); + }) + ).pipe(function () { + return Array.prototype.concat.apply( + [], + arguments + ); + }); + }, + + _getDroppedFiles: function (dataTransfer) { + dataTransfer = dataTransfer || {}; + var items = dataTransfer.items; + if (items && items.length && (items[0].webkitGetAsEntry || + items[0].getAsEntry)) { + return this._handleFileTreeEntries( + $.map(items, function (item) { + var entry; + if (item.webkitGetAsEntry) { + entry = item.webkitGetAsEntry(); + if (entry) { + // Workaround for Chrome bug #149735: + entry._file = item.getAsFile(); + } + return entry; + } + return item.getAsEntry(); + }) + ); + } + return $.Deferred().resolve( + $.makeArray(dataTransfer.files) + ).promise(); + }, + + _getSingleFileInputFiles: function (fileInput) { + fileInput = $(fileInput); + var entries = fileInput.prop('webkitEntries') || + fileInput.prop('entries'), + files, + value; + if (entries && entries.length) { + return this._handleFileTreeEntries(entries); + } + files = $.makeArray(fileInput.prop('files')); + if (!files.length) { + value = fileInput.prop('value'); + if (!value) { + return $.Deferred().resolve([]).promise(); + } + // If the files property is not available, the browser does not + // support the File API and we add a pseudo File object with + // the input value as name with path information removed: + files = [{name: value.replace(/^.*\\/, '')}]; + } else if (files[0].name === undefined && files[0].fileName) { + // File normalization for Safari 4 and Firefox 3: + $.each(files, function (index, file) { + file.name = file.fileName; + file.size = file.fileSize; + }); + } + return $.Deferred().resolve(files).promise(); + }, + + _getFileInputFiles: function (fileInput) { + if (!(fileInput instanceof $) || fileInput.length === 1) { + return this._getSingleFileInputFiles(fileInput); + } + return $.when.apply( + $, + $.map(fileInput, this._getSingleFileInputFiles) + ).pipe(function () { + return Array.prototype.concat.apply( + [], + arguments + ); + }); + }, + + _onChange: function (e) { + var that = this, + data = { + fileInput: $(e.target), + form: $(e.target.form) + }; + this._getFileInputFiles(data.fileInput).always(function (files) { + data.files = files; + if (that.options.replaceFileInput) { + that._replaceFileInput(data.fileInput); + } + if (that._trigger('change', e, data) !== false) { + that._onAdd(e, data); + } + }); + }, + + _onPaste: function (e) { + var items = e.originalEvent && e.originalEvent.clipboardData && + e.originalEvent.clipboardData.items, + data = {files: []}; + if (items && items.length) { + $.each(items, function (index, item) { + var file = item.getAsFile && item.getAsFile(); + if (file) { + data.files.push(file); + } + }); + if (this._trigger('paste', e, data) === false || + this._onAdd(e, data) === false) { + return false; + } + } + }, + + _onDrop: function (e) { + e.dataTransfer = e.originalEvent && e.originalEvent.dataTransfer; + var that = this, + dataTransfer = e.dataTransfer, + data = {}; + if (dataTransfer && dataTransfer.files && dataTransfer.files.length) { + e.preventDefault(); + this._getDroppedFiles(dataTransfer).always(function (files) { + data.files = files; + if (that._trigger('drop', e, data) !== false) { + that._onAdd(e, data); + } + }); + } + }, + + _onDragOver: function (e) { + e.dataTransfer = e.originalEvent && e.originalEvent.dataTransfer; + var dataTransfer = e.dataTransfer; + if (dataTransfer) { + if (this._trigger('dragover', e) === false) { + return false; + } + if ($.inArray('Files', dataTransfer.types) !== -1) { + dataTransfer.dropEffect = 'copy'; + e.preventDefault(); + } + } + }, + + _initEventHandlers: function () { + if (this._isXHRUpload(this.options)) { + this._on(this.options.dropZone, { + dragover: this._onDragOver, + drop: this._onDrop + }); + this._on(this.options.pasteZone, { + paste: this._onPaste + }); + } + if ($.support.fileInput) { + this._on(this.options.fileInput, { + change: this._onChange + }); + } + }, + + _destroyEventHandlers: function () { + this._off(this.options.dropZone, 'dragover drop'); + this._off(this.options.pasteZone, 'paste'); + this._off(this.options.fileInput, 'change'); + }, + + _setOption: function (key, value) { + var reinit = $.inArray(key, this._specialOptions) !== -1; + if (reinit) { + this._destroyEventHandlers(); + } + this._super(key, value); + if (reinit) { + this._initSpecialOptions(); + this._initEventHandlers(); + } + }, + + _initSpecialOptions: function () { + var options = this.options; + if (options.fileInput === undefined) { + options.fileInput = this.element.is('input[type="file"]') ? + this.element : this.element.find('input[type="file"]'); + } else if (!(options.fileInput instanceof $)) { + options.fileInput = $(options.fileInput); + } + if (!(options.dropZone instanceof $)) { + options.dropZone = $(options.dropZone); + } + if (!(options.pasteZone instanceof $)) { + options.pasteZone = $(options.pasteZone); + } + }, + + _getRegExp: function (str) { + var parts = str.split('/'), + modifiers = parts.pop(); + parts.shift(); + return new RegExp(parts.join('/'), modifiers); + }, + + _isRegExpOption: function (key, value) { + return key !== 'url' && $.type(value) === 'string' && + /^\/.*\/[igm]{0,3}$/.test(value); + }, + + _initDataAttributes: function () { + var that = this, + options = this.options; + // Initialize options set via HTML5 data-attributes: + $.each( + $(this.element[0].cloneNode(false)).data(), + function (key, value) { + if (that._isRegExpOption(key, value)) { + value = that._getRegExp(value); + } + options[key] = value; + } + ); + }, + + _create: function () { + this._initDataAttributes(); + this._initSpecialOptions(); + this._slots = []; + this._sequence = this._getXHRPromise(true); + this._sending = this._active = 0; + this._initProgressObject(this); + this._initEventHandlers(); + }, + + // This method is exposed to the widget API and allows to query + // the number of active uploads: + active: function () { + return this._active; + }, + + // This method is exposed to the widget API and allows to query + // the widget upload progress. + // It returns an object with loaded, total and bitrate properties + // for the running uploads: + progress: function () { + return this._progress; + }, + + // This method is exposed to the widget API and allows adding files + // using the fileupload API. The data parameter accepts an object which + // must have a files property and can contain additional options: + // .fileupload('add', {files: filesList}); + add: function (data) { + var that = this; + if (!data || this.options.disabled) { + return; + } + if (data.fileInput && !data.files) { + this._getFileInputFiles(data.fileInput).always(function (files) { + data.files = files; + that._onAdd(null, data); + }); + } else { + data.files = $.makeArray(data.files); + this._onAdd(null, data); + } + }, + + // This method is exposed to the widget API and allows sending files + // using the fileupload API. The data parameter accepts an object which + // must have a files or fileInput property and can contain additional options: + // .fileupload('send', {files: filesList}); + // The method returns a Promise object for the file upload call. + send: function (data) { + if (data && !this.options.disabled) { + if (data.fileInput && !data.files) { + var that = this, + dfd = $.Deferred(), + promise = dfd.promise(), + jqXHR, + aborted; + promise.abort = function () { + aborted = true; + if (jqXHR) { + return jqXHR.abort(); + } + dfd.reject(null, 'abort', 'abort'); + return promise; + }; + this._getFileInputFiles(data.fileInput).always( + function (files) { + if (aborted) { + return; + } + if (!files.length) { + dfd.reject(); + return; + } + data.files = files; + jqXHR = that._onSend(null, data).then( + function (result, textStatus, jqXHR) { + dfd.resolve(result, textStatus, jqXHR); + }, + function (jqXHR, textStatus, errorThrown) { + dfd.reject(jqXHR, textStatus, errorThrown); + } + ); + } + ); + return this._enhancePromise(promise); + } + data.files = $.makeArray(data.files); + if (data.files.length) { + return this._onSend(null, data); + } + } + return this._getXHRPromise(false, data && data.context); + } + + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js b/common/static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js new file mode 100644 index 0000000000..073c5fbe70 --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/jquery.iframe-transport.js @@ -0,0 +1,205 @@ +/* + * jQuery Iframe Transport Plugin 1.7 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2011, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint unparam: true, nomen: true */ +/*global define, window, document */ + +(function (factory) { + 'use strict'; + if (typeof define === 'function' && define.amd) { + // Register as an anonymous AMD module: + define(['jquery'], factory); + } else { + // Browser globals: + factory(window.jQuery); + } +}(function ($) { + 'use strict'; + + // Helper variable to create unique names for the transport iframes: + var counter = 0; + + // The iframe transport accepts three additional options: + // options.fileInput: a jQuery collection of file input fields + // options.paramName: the parameter name for the file form data, + // overrides the name property of the file input field(s), + // can be a string or an array of strings. + // options.formData: an array of objects with name and value properties, + // equivalent to the return data of .serializeArray(), e.g.: + // [{name: 'a', value: 1}, {name: 'b', value: 2}] + $.ajaxTransport('iframe', function (options) { + if (options.async) { + var form, + iframe, + addParamChar; + return { + send: function (_, completeCallback) { + form = $('
    '); + form.attr('accept-charset', options.formAcceptCharset); + addParamChar = /\?/.test(options.url) ? '&' : '?'; + // XDomainRequest only supports GET and POST: + if (options.type === 'DELETE') { + options.url = options.url + addParamChar + '_method=DELETE'; + options.type = 'POST'; + } else if (options.type === 'PUT') { + options.url = options.url + addParamChar + '_method=PUT'; + options.type = 'POST'; + } else if (options.type === 'PATCH') { + options.url = options.url + addParamChar + '_method=PATCH'; + options.type = 'POST'; + } + // javascript:false as initial iframe src + // prevents warning popups on HTTPS in IE6. + // IE versions below IE8 cannot set the name property of + // elements that have already been added to the DOM, + // so we set the name along with the iframe HTML markup: + counter += 1; + iframe = $( + '' + ).bind('load', function () { + var fileInputClones, + paramNames = $.isArray(options.paramName) ? + options.paramName : [options.paramName]; + iframe + .unbind('load') + .bind('load', function () { + var response; + // Wrap in a try/catch block to catch exceptions thrown + // when trying to access cross-domain iframe contents: + try { + response = iframe.contents(); + // Google Chrome and Firefox do not throw an + // exception when calling iframe.contents() on + // cross-domain requests, so we unify the response: + if (!response.length || !response[0].firstChild) { + throw new Error(); + } + } catch (e) { + response = undefined; + } + // The complete callback returns the + // iframe content document as response object: + completeCallback( + 200, + 'success', + {'iframe': response} + ); + // Fix for IE endless progress bar activity bug + // (happens on form submits to iframe targets): + $('') + .appendTo(form); + window.setTimeout(function () { + // Removing the form in a setTimeout call + // allows Chrome's developer tools to display + // the response result + form.remove(); + }, 0); + }); + form + .prop('target', iframe.prop('name')) + .prop('action', options.url) + .prop('method', options.type); + if (options.formData) { + $.each(options.formData, function (index, field) { + $('') + .prop('name', field.name) + .val(field.value) + .appendTo(form); + }); + } + if (options.fileInput && options.fileInput.length && + options.type === 'POST') { + fileInputClones = options.fileInput.clone(); + // Insert a clone for each file input field: + options.fileInput.after(function (index) { + return fileInputClones[index]; + }); + if (options.paramName) { + options.fileInput.each(function (index) { + $(this).prop( + 'name', + paramNames[index] || options.paramName + ); + }); + } + // Appending the file input fields to the hidden form + // removes them from their original location: + form + .append(options.fileInput) + .prop('enctype', 'multipart/form-data') + // enctype must be set as encoding for IE: + .prop('encoding', 'multipart/form-data'); + } + form.submit(); + // Insert the file input fields at their original location + // by replacing the clones with the originals: + if (fileInputClones && fileInputClones.length) { + options.fileInput.each(function (index, input) { + var clone = $(fileInputClones[index]); + $(input).prop('name', clone.prop('name')); + clone.replaceWith(input); + }); + } + }); + form.append(iframe).appendTo(document.body); + }, + abort: function () { + if (iframe) { + // javascript:false as iframe src aborts the request + // and prevents warning popups on HTTPS in IE6. + // concat is used to avoid the "Script URL" JSLint error: + iframe + .unbind('load') + .prop('src', 'javascript'.concat(':false;')); + } + if (form) { + form.remove(); + } + } + }; + } + }); + + // The iframe transport returns the iframe content document as response. + // The following adds converters from iframe to text, json, html, xml + // and script. + // Please note that the Content-Type for JSON responses has to be text/plain + // or text/html, if the browser doesn't include application/json in the + // Accept header, else IE will show a download dialog. + // The Content-Type for XML responses on the other hand has to be always + // application/xml or text/xml, so IE properly parses the XML response. + // See also + // https://github.com/blueimp/jQuery-File-Upload/wiki/Setup#content-type-negotiation + $.ajaxSetup({ + converters: { + 'iframe text': function (iframe) { + return iframe && $(iframe[0].body).text(); + }, + 'iframe json': function (iframe) { + return iframe && $.parseJSON($(iframe[0].body).text()); + }, + 'iframe html': function (iframe) { + return iframe && $(iframe[0].body).html(); + }, + 'iframe xml': function (iframe) { + var xmlDoc = iframe && iframe[0]; + return xmlDoc && $.isXMLDoc(xmlDoc) ? xmlDoc : + $.parseXML((xmlDoc.XMLDocument && xmlDoc.XMLDocument.xml) || + $(xmlDoc.body).html()); + }, + 'iframe script': function (iframe) { + return iframe && $.globalEval($(iframe[0].body).text()); + } + } + }); + +})); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/main.js b/common/static/js/vendor/jQuery-File-Upload/js/main.js new file mode 100644 index 0000000000..00f3c79c7b --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/main.js @@ -0,0 +1,76 @@ +/* + * jQuery File Upload Plugin JS Example 8.3.0 + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2010, Sebastian Tschan + * https://blueimp.net + * + * Licensed under the MIT license: + * http://www.opensource.org/licenses/MIT + */ + +/*jslint nomen: true, regexp: true */ +/*global $, window, blueimp */ + +$(function () { + 'use strict'; + + // Initialize the jQuery File Upload widget: + $('#fileupload').fileupload({ + // Uncomment the following to send cross-domain cookies: + //xhrFields: {withCredentials: true}, + url: 'server/php/' + }); + + // Enable iframe cross-domain access via redirect option: + $('#fileupload').fileupload( + 'option', + 'redirect', + window.location.href.replace( + /\/[^\/]*$/, + '/cors/result.html?%s' + ) + ); + + if (window.location.hostname === 'blueimp.github.io') { + // Demo settings: + $('#fileupload').fileupload('option', { + url: '//jquery-file-upload.appspot.com/', + // Enable image resizing, except for Android and Opera, + // which actually support image resizing, but fail to + // send Blob objects via XHR requests: + disableImageResize: /Android(?!.*Chrome)|Opera/ + .test(window.navigator.userAgent), + maxFileSize: 5000000, + acceptFileTypes: /(\.|\/)(gif|jpe?g|png)$/i + }); + // Upload server status check for browsers with CORS support: + if ($.support.cors) { + $.ajax({ + url: '//jquery-file-upload.appspot.com/', + type: 'HEAD' + }).fail(function () { + $('') + .text('Upload server currently unavailable - ' + + new Date()) + .appendTo('#fileupload'); + }); + } + } else { + // Load existing files: + $('#fileupload').addClass('fileupload-processing'); + $.ajax({ + // Uncomment the following to send cross-domain cookies: + //xhrFields: {withCredentials: true}, + url: $('#fileupload').fileupload('option', 'url'), + dataType: 'json', + context: $('#fileupload')[0] + }).always(function () { + $(this).removeClass('fileupload-processing'); + }).done(function (result) { + $(this).fileupload('option', 'done') + .call(this, null, {result: result}); + }); + } + +}); diff --git a/common/static/js/vendor/jQuery-File-Upload/js/vendor/jquery.ui.widget.js b/common/static/js/vendor/jQuery-File-Upload/js/vendor/jquery.ui.widget.js new file mode 100644 index 0000000000..2d370893ad --- /dev/null +++ b/common/static/js/vendor/jQuery-File-Upload/js/vendor/jquery.ui.widget.js @@ -0,0 +1,530 @@ +/* + * jQuery UI Widget 1.10.3+amd + * https://github.com/blueimp/jQuery-File-Upload + * + * Copyright 2013 jQuery Foundation and other contributors + * Released under the MIT license. + * http://jquery.org/license + * + * http://api.jqueryui.com/jQuery.widget/ + */ + +(function (factory) { + if (typeof define === "function" && define.amd) { + // Register as an anonymous AMD module: + define(["jquery"], factory); + } else { + // Browser globals: + factory(jQuery); + } +}(function( $, undefined ) { + +var uuid = 0, + slice = Array.prototype.slice, + _cleanData = $.cleanData; +$.cleanData = function( elems ) { + for ( var i = 0, elem; (elem = elems[i]) != null; i++ ) { + try { + $( elem ).triggerHandler( "remove" ); + // http://bugs.jquery.com/ticket/8235 + } catch( e ) {} + } + _cleanData( elems ); +}; + +$.widget = function( name, base, prototype ) { + var fullName, existingConstructor, constructor, basePrototype, + // proxiedPrototype allows the provided prototype to remain unmodified + // so that it can be used as a mixin for multiple widgets (#8876) + proxiedPrototype = {}, + namespace = name.split( "." )[ 0 ]; + + name = name.split( "." )[ 1 ]; + fullName = namespace + "-" + name; + + if ( !prototype ) { + prototype = base; + base = $.Widget; + } + + // create selector for plugin + $.expr[ ":" ][ fullName.toLowerCase() ] = function( elem ) { + return !!$.data( elem, fullName ); + }; + + $[ namespace ] = $[ namespace ] || {}; + existingConstructor = $[ namespace ][ name ]; + constructor = $[ namespace ][ name ] = function( options, element ) { + // allow instantiation without "new" keyword + if ( !this._createWidget ) { + return new constructor( options, element ); + } + + // allow instantiation without initializing for simple inheritance + // must use "new" keyword (the code above always passes args) + if ( arguments.length ) { + this._createWidget( options, element ); + } + }; + // extend with the existing constructor to carry over any static properties + $.extend( constructor, existingConstructor, { + version: prototype.version, + // copy the object used to create the prototype in case we need to + // redefine the widget later + _proto: $.extend( {}, prototype ), + // track widgets that inherit from this widget in case this widget is + // redefined after a widget inherits from it + _childConstructors: [] + }); + + basePrototype = new base(); + // we need to make the options hash a property directly on the new instance + // otherwise we'll modify the options hash on the prototype that we're + // inheriting from + basePrototype.options = $.widget.extend( {}, basePrototype.options ); + $.each( prototype, function( prop, value ) { + if ( !$.isFunction( value ) ) { + proxiedPrototype[ prop ] = value; + return; + } + proxiedPrototype[ prop ] = (function() { + var _super = function() { + return base.prototype[ prop ].apply( this, arguments ); + }, + _superApply = function( args ) { + return base.prototype[ prop ].apply( this, args ); + }; + return function() { + var __super = this._super, + __superApply = this._superApply, + returnValue; + + this._super = _super; + this._superApply = _superApply; + + returnValue = value.apply( this, arguments ); + + this._super = __super; + this._superApply = __superApply; + + return returnValue; + }; + })(); + }); + constructor.prototype = $.widget.extend( basePrototype, { + // TODO: remove support for widgetEventPrefix + // always use the name + a colon as the prefix, e.g., draggable:start + // don't prefix for widgets that aren't DOM-based + widgetEventPrefix: existingConstructor ? basePrototype.widgetEventPrefix : name + }, proxiedPrototype, { + constructor: constructor, + namespace: namespace, + widgetName: name, + widgetFullName: fullName + }); + + // If this widget is being redefined then we need to find all widgets that + // are inheriting from it and redefine all of them so that they inherit from + // the new version of this widget. We're essentially trying to replace one + // level in the prototype chain. + if ( existingConstructor ) { + $.each( existingConstructor._childConstructors, function( i, child ) { + var childPrototype = child.prototype; + + // redefine the child widget using the same prototype that was + // originally used, but inherit from the new version of the base + $.widget( childPrototype.namespace + "." + childPrototype.widgetName, constructor, child._proto ); + }); + // remove the list of existing child constructors from the old constructor + // so the old child constructors can be garbage collected + delete existingConstructor._childConstructors; + } else { + base._childConstructors.push( constructor ); + } + + $.widget.bridge( name, constructor ); +}; + +$.widget.extend = function( target ) { + var input = slice.call( arguments, 1 ), + inputIndex = 0, + inputLength = input.length, + key, + value; + for ( ; inputIndex < inputLength; inputIndex++ ) { + for ( key in input[ inputIndex ] ) { + value = input[ inputIndex ][ key ]; + if ( input[ inputIndex ].hasOwnProperty( key ) && value !== undefined ) { + // Clone objects + if ( $.isPlainObject( value ) ) { + target[ key ] = $.isPlainObject( target[ key ] ) ? + $.widget.extend( {}, target[ key ], value ) : + // Don't extend strings, arrays, etc. with objects + $.widget.extend( {}, value ); + // Copy everything else by reference + } else { + target[ key ] = value; + } + } + } + } + return target; +}; + +$.widget.bridge = function( name, object ) { + var fullName = object.prototype.widgetFullName || name; + $.fn[ name ] = function( options ) { + var isMethodCall = typeof options === "string", + args = slice.call( arguments, 1 ), + returnValue = this; + + // allow multiple hashes to be passed on init + options = !isMethodCall && args.length ? + $.widget.extend.apply( null, [ options ].concat(args) ) : + options; + + if ( isMethodCall ) { + this.each(function() { + var methodValue, + instance = $.data( this, fullName ); + if ( !instance ) { + return $.error( "cannot call methods on " + name + " prior to initialization; " + + "attempted to call method '" + options + "'" ); + } + if ( !$.isFunction( instance[options] ) || options.charAt( 0 ) === "_" ) { + return $.error( "no such method '" + options + "' for " + name + " widget instance" ); + } + methodValue = instance[ options ].apply( instance, args ); + if ( methodValue !== instance && methodValue !== undefined ) { + returnValue = methodValue && methodValue.jquery ? + returnValue.pushStack( methodValue.get() ) : + methodValue; + return false; + } + }); + } else { + this.each(function() { + var instance = $.data( this, fullName ); + if ( instance ) { + instance.option( options || {} )._init(); + } else { + $.data( this, fullName, new object( options, this ) ); + } + }); + } + + return returnValue; + }; +}; + +$.Widget = function( /* options, element */ ) {}; +$.Widget._childConstructors = []; + +$.Widget.prototype = { + widgetName: "widget", + widgetEventPrefix: "", + defaultElement: "
    ", + options: { + disabled: false, + + // callbacks + create: null + }, + _createWidget: function( options, element ) { + element = $( element || this.defaultElement || this )[ 0 ]; + this.element = $( element ); + this.uuid = uuid++; + this.eventNamespace = "." + this.widgetName + this.uuid; + this.options = $.widget.extend( {}, + this.options, + this._getCreateOptions(), + options ); + + this.bindings = $(); + this.hoverable = $(); + this.focusable = $(); + + if ( element !== this ) { + $.data( element, this.widgetFullName, this ); + this._on( true, this.element, { + remove: function( event ) { + if ( event.target === element ) { + this.destroy(); + } + } + }); + this.document = $( element.style ? + // element within the document + element.ownerDocument : + // element is window or document + element.document || element ); + this.window = $( this.document[0].defaultView || this.document[0].parentWindow ); + } + + this._create(); + this._trigger( "create", null, this._getCreateEventData() ); + this._init(); + }, + _getCreateOptions: $.noop, + _getCreateEventData: $.noop, + _create: $.noop, + _init: $.noop, + + destroy: function() { + this._destroy(); + // we can probably remove the unbind calls in 2.0 + // all event bindings should go through this._on() + this.element + .unbind( this.eventNamespace ) + // 1.9 BC for #7810 + // TODO remove dual storage + .removeData( this.widgetName ) + .removeData( this.widgetFullName ) + // support: jquery <1.6.3 + // http://bugs.jquery.com/ticket/9413 + .removeData( $.camelCase( this.widgetFullName ) ); + this.widget() + .unbind( this.eventNamespace ) + .removeAttr( "aria-disabled" ) + .removeClass( + this.widgetFullName + "-disabled " + + "ui-state-disabled" ); + + // clean up events and states + this.bindings.unbind( this.eventNamespace ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); + }, + _destroy: $.noop, + + widget: function() { + return this.element; + }, + + option: function( key, value ) { + var options = key, + parts, + curOption, + i; + + if ( arguments.length === 0 ) { + // don't return a reference to the internal hash + return $.widget.extend( {}, this.options ); + } + + if ( typeof key === "string" ) { + // handle nested keys, e.g., "foo.bar" => { foo: { bar: ___ } } + options = {}; + parts = key.split( "." ); + key = parts.shift(); + if ( parts.length ) { + curOption = options[ key ] = $.widget.extend( {}, this.options[ key ] ); + for ( i = 0; i < parts.length - 1; i++ ) { + curOption[ parts[ i ] ] = curOption[ parts[ i ] ] || {}; + curOption = curOption[ parts[ i ] ]; + } + key = parts.pop(); + if ( value === undefined ) { + return curOption[ key ] === undefined ? null : curOption[ key ]; + } + curOption[ key ] = value; + } else { + if ( value === undefined ) { + return this.options[ key ] === undefined ? null : this.options[ key ]; + } + options[ key ] = value; + } + } + + this._setOptions( options ); + + return this; + }, + _setOptions: function( options ) { + var key; + + for ( key in options ) { + this._setOption( key, options[ key ] ); + } + + return this; + }, + _setOption: function( key, value ) { + this.options[ key ] = value; + + if ( key === "disabled" ) { + this.widget() + .toggleClass( this.widgetFullName + "-disabled ui-state-disabled", !!value ) + .attr( "aria-disabled", value ); + this.hoverable.removeClass( "ui-state-hover" ); + this.focusable.removeClass( "ui-state-focus" ); + } + + return this; + }, + + enable: function() { + return this._setOption( "disabled", false ); + }, + disable: function() { + return this._setOption( "disabled", true ); + }, + + _on: function( suppressDisabledCheck, element, handlers ) { + var delegateElement, + instance = this; + + // no suppressDisabledCheck flag, shuffle arguments + if ( typeof suppressDisabledCheck !== "boolean" ) { + handlers = element; + element = suppressDisabledCheck; + suppressDisabledCheck = false; + } + + // no element argument, shuffle and use this.element + if ( !handlers ) { + handlers = element; + element = this.element; + delegateElement = this.widget(); + } else { + // accept selectors, DOM elements + element = delegateElement = $( element ); + this.bindings = this.bindings.add( element ); + } + + $.each( handlers, function( event, handler ) { + function handlerProxy() { + // allow widgets to customize the disabled handling + // - disabled as an array instead of boolean + // - disabled class as method for disabling individual parts + if ( !suppressDisabledCheck && + ( instance.options.disabled === true || + $( this ).hasClass( "ui-state-disabled" ) ) ) { + return; + } + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + + // copy the guid so direct unbinding works + if ( typeof handler !== "string" ) { + handlerProxy.guid = handler.guid = + handler.guid || handlerProxy.guid || $.guid++; + } + + var match = event.match( /^(\w+)\s*(.*)$/ ), + eventName = match[1] + instance.eventNamespace, + selector = match[2]; + if ( selector ) { + delegateElement.delegate( selector, eventName, handlerProxy ); + } else { + element.bind( eventName, handlerProxy ); + } + }); + }, + + _off: function( element, eventName ) { + eventName = (eventName || "").split( " " ).join( this.eventNamespace + " " ) + this.eventNamespace; + element.unbind( eventName ).undelegate( eventName ); + }, + + _delay: function( handler, delay ) { + function handlerProxy() { + return ( typeof handler === "string" ? instance[ handler ] : handler ) + .apply( instance, arguments ); + } + var instance = this; + return setTimeout( handlerProxy, delay || 0 ); + }, + + _hoverable: function( element ) { + this.hoverable = this.hoverable.add( element ); + this._on( element, { + mouseenter: function( event ) { + $( event.currentTarget ).addClass( "ui-state-hover" ); + }, + mouseleave: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-hover" ); + } + }); + }, + + _focusable: function( element ) { + this.focusable = this.focusable.add( element ); + this._on( element, { + focusin: function( event ) { + $( event.currentTarget ).addClass( "ui-state-focus" ); + }, + focusout: function( event ) { + $( event.currentTarget ).removeClass( "ui-state-focus" ); + } + }); + }, + + _trigger: function( type, event, data ) { + var prop, orig, + callback = this.options[ type ]; + + data = data || {}; + event = $.Event( event ); + event.type = ( type === this.widgetEventPrefix ? + type : + this.widgetEventPrefix + type ).toLowerCase(); + // the original event may come from any element + // so we need to reset the target on the new event + event.target = this.element[ 0 ]; + + // copy original event properties over to the new event + orig = event.originalEvent; + if ( orig ) { + for ( prop in orig ) { + if ( !( prop in event ) ) { + event[ prop ] = orig[ prop ]; + } + } + } + + this.element.trigger( event, data ); + return !( $.isFunction( callback ) && + callback.apply( this.element[0], [ event ].concat( data ) ) === false || + event.isDefaultPrevented() ); + } +}; + +$.each( { show: "fadeIn", hide: "fadeOut" }, function( method, defaultEffect ) { + $.Widget.prototype[ "_" + method ] = function( element, options, callback ) { + if ( typeof options === "string" ) { + options = { effect: options }; + } + var hasOptions, + effectName = !options ? + method : + options === true || typeof options === "number" ? + defaultEffect : + options.effect || defaultEffect; + options = options || {}; + if ( typeof options === "number" ) { + options = { duration: options }; + } + hasOptions = !$.isEmptyObject( options ); + options.complete = callback; + if ( options.delay ) { + element.delay( options.delay ); + } + if ( hasOptions && $.effects && $.effects.effect[ effectName ] ) { + element[ method ]( options ); + } else if ( effectName !== method && element[ effectName ] ) { + element[ effectName ]( options.duration, options.easing, callback ); + } else { + element.queue(function( next ) { + $( this )[ method ](); + if ( callback ) { + callback.call( element[ 0 ] ); + } + next(); + }); + } + }; +}); + +})); From 50934d4d45a63767b7f0ffea81b40ad09bc5206c Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 14 Aug 2013 10:34:30 -0400 Subject: [PATCH 143/244] Make client-side use jquery-file-upload --- cms/templates/import.html | 61 +++++++++++++++++++++++++-------------- 1 file changed, 39 insertions(+), 22 deletions(-) diff --git a/cms/templates/import.html b/cms/templates/import.html index 33bb7326c3..49612ea87d 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -25,13 +25,15 @@

    ${_("File uploads must be gzipped tar files (.tar.gz) containing, at a minimum, a {filename} file.").format(filename='course.xml')}

    ${_("Please note that if your course has any problems with auto-generated {nodename} nodes, re-importing your course could cause the loss of student data associated with those problems.").format(nodename='url_name')}

    -
    +

    ${_("Course to import:")}

    ${_("Choose File")}

    ${_("change")}

    - - + + +
    0%
    @@ -43,6 +45,9 @@ <%block name="jsextra"> + + + From 3aaa2868c05254c9b77f8f7b8ab47bb15422b691 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 14 Aug 2013 10:35:07 -0400 Subject: [PATCH 144/244] Process uploaded chunks. Also adds lockfile for asynchronous status updates. --- cms/djangoapps/contentstore/views/assets.py | 130 ++++++++++++++------ 1 file changed, 92 insertions(+), 38 deletions(-) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 74cb94a354..9edee33728 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 +import re from functools import partial from tempfile import mkdtemp from path import path @@ -38,6 +39,9 @@ from util.json_request import JsonResponse __all__ = ['asset_index', 'upload_asset', 'import_course', 'generate_export_course', 'export_course'] +MAX_UP_LENGTH = 20000352 + +CONTENT_RE = re.compile(r"(?P\d{1,11})-(?P\d{1,11})/(?P\d{1,11})") def assets_to_json_dict(assets): """ @@ -265,70 +269,120 @@ def remove_asset(request, org, course, name): @login_required def import_course(request, org, course, name): """ - This method will handle a POST request to upload and import a .tar.gz file into a specified course + This method will handle a POST request to upload and import a .tar.gz file + into a specified course """ location = get_location_and_verify_access(request, org, course, name) - if request.method in ('POST', 'PUT'): - filename = request.FILES['course-data'].name - - if not filename.endswith('.tar.gz'): - return HttpResponse(json.dumps({'ErrMsg': 'We only support uploading a .tar.gz file.'})) + if request.method == 'POST': data_root = path(settings.GITHUB_REPO_ROOT) - course_subdir = "{0}-{1}-{2}".format(org, course, name) course_dir = data_root / course_subdir + + filename = request.FILES['course-data'].name + if not filename.endswith('.tar.gz'): + return HttpResponse(json.dumps({ + 'ErrMsg': 'We only support uploading a .tar.gz file.' + })) + temp_filepath = course_dir / filename + if not course_dir.isdir(): os.mkdir(course_dir) - temp_filepath = course_dir / filename - logging.debug('importing course to {0}'.format(temp_filepath)) + # Get upload chunks byte ranges + matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"]) + content_range = matches.groupdict() + # stream out the uploaded files in chunks to disk - temp_file = open(temp_filepath, 'wb+') + if int(content_range['start']) == 0: + temp_file = open(temp_filepath, 'wb+') + else: + temp_file = open(temp_filepath, 'ab+') + for chunk in request.FILES['course-data'].chunks(): temp_file.write(chunk) temp_file.close() - tar_file = tarfile.open(temp_filepath) - tar_file.extractall(course_dir + '/') + size = os.path.getsize(temp_filepath) - # find the 'course.xml' file - dirpath = None - for dirpath, _dirnames, filenames in os.walk(course_dir): - for filename in filenames: - if filename == 'course.xml': + if int(content_range['stop']) != int(content_range['end']) - 1: + # More chunks coming + return JsonResponse({ + "files": [{ + "name": filename, + "size": size, + "deleteUrl": "", + "deleteType": "", + "url": reverse('import_course', kwargs={ + 'org': location.org, + 'course': location.course, + 'name': location.name + }), + "thumbnailUrl": "" + }] + }) + + else: #This was the last chunk. + + # 'Lock' with status info. + lock_filepath = data_root / (filename + ".lock") + + with open(lock_filepath, 'w+') as lf: + lf.write("Extracting") + + tar_file = tarfile.open(temp_filepath) + tar_file.extractall(course_dir + '/') + + with open(lock_filepath, 'w+') as lf: + lf.write("Verifying") + + # find the 'course.xml' file + dirpath = None + for dirpath, _dirnames, filenames in os.walk(course_dir): + for fname in filenames: + if fname == 'course.xml': + break + if fname == 'course.xml': break - if filename == 'course.xml': - break - if filename != 'course.xml': - return HttpResponse(json.dumps({'ErrMsg': 'Could not find the course.xml file in the package.'})) + if fname != 'course.xml': + return HttpResponse(json.dumps({ + 'ErrMsg': 'Could not find the course.xml file in the package.' + })) - logging.debug('found course.xml at {0}'.format(dirpath)) + logging.debug('found course.xml at {0}'.format(dirpath)) - if dirpath != course_dir: - for fname in os.listdir(dirpath): - shutil.move(dirpath / fname, course_dir) + if dirpath != course_dir: + for fname in os.listdir(dirpath): + shutil.move(dirpath / fname, course_dir) - _module_store, course_items = import_from_xml(modulestore('direct'), settings.GITHUB_REPO_ROOT, - [course_subdir], load_error_modules=False, - static_content_store=contentstore(), - target_location_namespace=location, - draft_store=modulestore()) + _module_store, course_items = import_from_xml( + modulestore('direct'), + settings.GITHUB_REPO_ROOT, + [course_subdir], + load_error_modules=False, + static_content_store=contentstore(), + target_location_namespace=location, + draft_store=modulestore() + ) - # we can blow this away when we're done importing. - shutil.rmtree(course_dir) + # we can blow this away when we're done importing. + shutil.rmtree(course_dir) - logging.debug('new course at {0}'.format(course_items[0].location)) + logging.debug('new course at {0}'.format(course_items[0].location)) - create_all_course_groups(request.user, course_items[0].location) + with open(lock_filepath, 'w') as lf: + lf.write("Updating course") - logging.debug('created all course groups at {0}'.format(course_items[0].location)) + create_all_course_groups(request.user, course_items[0].location) + logging.debug('created all course groups at {0}'.format(course_items[0].location)) - return HttpResponse(json.dumps({'Status': 'OK'})) + os.remove(lock_filepath) + + return HttpResponse(json.dumps({'Status': 'OK'})) else: course_module = modulestore().get_item(location) @@ -346,8 +400,8 @@ def import_course(request, org, course, name): @login_required def generate_export_course(request, org, course, name): """ - This method will serialize out a course to a .tar.gz file which contains a XML-based representation of - the course + This method will serialize out a course to a .tar.gz file which contains a + XML-based representation of the course """ location = get_location_and_verify_access(request, org, course, name) course_module = modulestore().get_instance(location.course_id, location) From 798d07a8d991d53fbd096385ef7093cb9690080a Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 14 Aug 2013 10:37:57 -0400 Subject: [PATCH 145/244] Fix confusing message --- cms/templates/import.html | 1 - 1 file changed, 1 deletion(-) diff --git a/cms/templates/import.html b/cms/templates/import.html index 49612ea87d..abfb1a2009 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -83,7 +83,6 @@ $('#fileupload').fileupload({ }, done: function(e, data){ bar.hide(); - percent.html("Unpacking files..."); alert('${_("Your import was successful.")}'); window.location = '${successful_import_redirect_url}'; }, From 513b6cab3ee2ae855a2202eb61815cedf18b64a8 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Wed, 14 Aug 2013 11:28:43 -0400 Subject: [PATCH 146/244] Small review fixes. Comments, httpresponse -> jsonresponse; make sure errors get relayed. --- cms/djangoapps/contentstore/views/assets.py | 49 ++++++++++++--------- cms/templates/import.html | 12 +++-- 2 files changed, 35 insertions(+), 26 deletions(-) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 9edee33728..0876a7e470 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -39,8 +39,9 @@ from util.json_request import JsonResponse __all__ = ['asset_index', 'upload_asset', 'import_course', 'generate_export_course', 'export_course'] -MAX_UP_LENGTH = 20000352 +MAX_UP_LENGTH = 20000352 # Max chunk size +# Regex to capture Content-Range header ranges. CONTENT_RE = re.compile(r"(?P\d{1,11})-(?P\d{1,11})/(?P\d{1,11})") def assets_to_json_dict(assets): @@ -282,9 +283,10 @@ def import_course(request, org, course, name): filename = request.FILES['course-data'].name if not filename.endswith('.tar.gz'): - return HttpResponse(json.dumps({ - 'ErrMsg': 'We only support uploading a .tar.gz file.' - })) + return JsonResponse( + { 'ErrMsg': 'We only support uploading a .tar.gz file.' }, + status=415 + ) temp_filepath = course_dir / filename if not course_dir.isdir(): @@ -293,18 +295,21 @@ def import_course(request, org, course, name): logging.debug('importing course to {0}'.format(temp_filepath)) # Get upload chunks byte ranges - matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"]) - content_range = matches.groupdict() + try: + matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"]) + content_range = matches.groupdict() + except KeyError: + content_range = {'start': 0, 'stop': 9, 'end': 10} # stream out the uploaded files in chunks to disk if int(content_range['start']) == 0: - temp_file = open(temp_filepath, 'wb+') + mode = "wb+" else: - temp_file = open(temp_filepath, 'ab+') + mode = "ab+" - for chunk in request.FILES['course-data'].chunks(): - temp_file.write(chunk) - temp_file.close() + with open(temp_filepath, mode) as temp_file: + for chunk in request.FILES['course-data'].chunks(): + temp_file.write(chunk) size = os.path.getsize(temp_filepath) @@ -341,17 +346,17 @@ def import_course(request, org, course, name): # find the 'course.xml' file dirpath = None - for dirpath, _dirnames, filenames in os.walk(course_dir): - for fname in filenames: - if fname == 'course.xml': - break - if fname == 'course.xml': - break - if fname != 'course.xml': - return HttpResponse(json.dumps({ - 'ErrMsg': 'Could not find the course.xml file in the package.' - })) + coursexmls = ((d, f) for d, _, f in os.walk(course_dir) + if f.count('course.xml') > 0) + + try: + (dirpath, fname) = coursexmls.next() + except StopIteration: + return JsonResponse( + {'ErrMsg': 'Could not find the course.xml file in the package.' }, + status=415 + ) logging.debug('found course.xml at {0}'.format(dirpath)) @@ -382,7 +387,7 @@ def import_course(request, org, course, name): os.remove(lock_filepath) - return HttpResponse(json.dumps({'Status': 'OK'})) + return JsonResponse({'Status': 'OK'}) else: course_module = modulestore().get_item(location) diff --git a/cms/templates/import.html b/cms/templates/import.html index abfb1a2009..26ebc24493 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -71,7 +71,14 @@ $('#fileupload').fileupload({ add: function(e, data) { submitBtn.show().click(function(e){ e.preventDefault(); - data.submit(); + data.submit().complete(function(result, textStatus, xhr) { + if (result.status != 200) { + alert('${_("Your import has failed.")}\n\n' + + JSON.parse(result.responseText)["ErrMsg"]); + submitBtn.show(); + bar.hide(); + } + }); }); }, @@ -86,9 +93,6 @@ $('#fileupload').fileupload({ alert('${_("Your import was successful.")}'); window.location = '${successful_import_redirect_url}'; }, - fail: function(e, data) { - alert('${_("Your import has failed.")}\n\n'); - }, }); From 7666aad0427f1750fc39135e24c0c712b6a9ba7d Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Thu, 15 Aug 2013 14:09:19 -0400 Subject: [PATCH 147/244] Check download integrity --- cms/djangoapps/contentstore/views/assets.py | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 0876a7e470..2ca55148fd 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -298,7 +298,7 @@ def import_course(request, org, course, name): try: matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"]) content_range = matches.groupdict() - except KeyError: + except KeyError: # Single chunk - no Content-Range header content_range = {'start': 0, 'stop': 9, 'end': 10} # stream out the uploaded files in chunks to disk @@ -306,6 +306,21 @@ def import_course(request, org, course, name): mode = "wb+" else: mode = "ab+" + size = os.path.getsize(temp_filepath) + # Check to make sure we haven't missed a chunk + # This shouldn't happen, even if different instances are handling + # the same session, but it's always better to catch errors earlier. + if size != int(content_range['start']): + log.warning( + "Reported range %s does not match size downloaded so far %s", + size, + content_range['start'] + ) + return JsonResponse( + { 'ErrMsg': 'File upload corrupted. Please try again' }, + status=409 + ) + with open(temp_filepath, mode) as temp_file: for chunk in request.FILES['course-data'].chunks(): @@ -347,7 +362,7 @@ def import_course(request, org, course, name): # find the 'course.xml' file dirpath = None - coursexmls = ((d, f) for d, _, f in os.walk(course_dir) + coursexmls = ((d, f) for d, _, f in os.walk(course_dir) if f.count('course.xml') > 0) try: From 993b92bc47e11b9b55db65e080bdb30df57cf956 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Thu, 15 Aug 2013 18:45:39 -0400 Subject: [PATCH 148/244] Add import tests --- .../contentstore/tests/test_assets.py | 77 +++++++++++++++++++ 1 file changed, 77 insertions(+) diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py index 2f158cfda6..9add306d1d 100644 --- a/cms/djangoapps/contentstore/tests/test_assets.py +++ b/cms/djangoapps/contentstore/tests/test_assets.py @@ -2,7 +2,12 @@ Unit tests for the asset upload endpoint. """ +import os import json +import shutil +import tarfile +import tempfile +from subprocess import call from datetime import datetime from io import BytesIO from pytz import UTC @@ -70,6 +75,78 @@ class UploadTestCase(CourseTestCase): resp = self.client.get(self.url) self.assertEquals(resp.status_code, 405) +class ImportTestCase(CourseTestCase): + """ + Unit tests for importing a course + """ + + def setUp(self): + super(ImportTestCase, self).setUp() + self.url = reverse("import_course", kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + }) + self.content_dir = tempfile.mkdtemp() + + def touch(name): + """ Equivalent to shell's 'touch'""" + with file(name, 'a'): + os.utime(name, None) + + # Create tar test files + good_dir = tempfile.mkdtemp(dir=self.content_dir) + os.makedirs(os.path.join(good_dir, "course")) + with open(os.path.join(good_dir, "course.xml") , "w+") as f: + f.write('') + + with open(os.path.join(good_dir, "course", "2013_Spring.xml"), "w+") as f: + f.write('') + + + self.good_tar = os.path.join(self.content_dir, "good.tar.gz") + with tarfile.open(self.good_tar, "w:gz") as gtar: + gtar.add(good_dir) + + bad_dir = tempfile.mkdtemp(dir=self.content_dir) + touch(os.path.join(bad_dir, "bad.xml")) + self.bad_tar = os.path.join(self.content_dir, "bad.tar.gz") + with tarfile.open(self.bad_tar, "w:gz") as btar: + btar.add(bad_dir) + + def tearDown(self): + shutil.rmtree(self.content_dir) + + def test_no_coursexml(self): + """ + Check that the response for a tar.gz import without a course.xml is + correct. + """ + with open(self.bad_tar) as btar: + resp = self.client.post( + self.url, + { + "name": self.bad_tar, + "course-data": [btar] + }) + self.assertEquals(resp.status_code, 415) + + def test_with_coursexml(self): + """ + Check that the response for a tar.gz import with a course.xml is + correct. + """ + with open(self.good_tar) as gtar: + resp = self.client.post( + self.url, + { + "name": self.good_tar, + "course-data": [gtar] + }) + self.assert2XX(resp.status_code) + + + class AssetsToJsonTestCase(TestCase): """ From d991595ecb8e75382a43220d228d1d628101fd40 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Fri, 16 Aug 2013 13:57:02 -0400 Subject: [PATCH 149/244] Split import-export into new file --- .../contentstore/tests/test_assets.py | 72 ---- .../contentstore/tests/test_import_export.py | 85 +++++ cms/djangoapps/contentstore/views/__init__.py | 1 + cms/djangoapps/contentstore/views/assets.py | 248 +------------- .../contentstore/views/import_export.py | 309 ++++++++++++++++++ 5 files changed, 396 insertions(+), 319 deletions(-) create mode 100644 cms/djangoapps/contentstore/tests/test_import_export.py create mode 100644 cms/djangoapps/contentstore/views/import_export.py diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py index 9add306d1d..9bde503b4d 100644 --- a/cms/djangoapps/contentstore/tests/test_assets.py +++ b/cms/djangoapps/contentstore/tests/test_assets.py @@ -75,78 +75,6 @@ class UploadTestCase(CourseTestCase): resp = self.client.get(self.url) self.assertEquals(resp.status_code, 405) -class ImportTestCase(CourseTestCase): - """ - Unit tests for importing a course - """ - - def setUp(self): - super(ImportTestCase, self).setUp() - self.url = reverse("import_course", kwargs={ - 'org': self.course.location.org, - 'course': self.course.location.course, - 'name': self.course.location.name, - }) - self.content_dir = tempfile.mkdtemp() - - def touch(name): - """ Equivalent to shell's 'touch'""" - with file(name, 'a'): - os.utime(name, None) - - # Create tar test files - good_dir = tempfile.mkdtemp(dir=self.content_dir) - os.makedirs(os.path.join(good_dir, "course")) - with open(os.path.join(good_dir, "course.xml") , "w+") as f: - f.write('') - - with open(os.path.join(good_dir, "course", "2013_Spring.xml"), "w+") as f: - f.write('') - - - self.good_tar = os.path.join(self.content_dir, "good.tar.gz") - with tarfile.open(self.good_tar, "w:gz") as gtar: - gtar.add(good_dir) - - bad_dir = tempfile.mkdtemp(dir=self.content_dir) - touch(os.path.join(bad_dir, "bad.xml")) - self.bad_tar = os.path.join(self.content_dir, "bad.tar.gz") - with tarfile.open(self.bad_tar, "w:gz") as btar: - btar.add(bad_dir) - - def tearDown(self): - shutil.rmtree(self.content_dir) - - def test_no_coursexml(self): - """ - Check that the response for a tar.gz import without a course.xml is - correct. - """ - with open(self.bad_tar) as btar: - resp = self.client.post( - self.url, - { - "name": self.bad_tar, - "course-data": [btar] - }) - self.assertEquals(resp.status_code, 415) - - def test_with_coursexml(self): - """ - Check that the response for a tar.gz import with a course.xml is - correct. - """ - with open(self.good_tar) as gtar: - resp = self.client.post( - self.url, - { - "name": self.good_tar, - "course-data": [gtar] - }) - self.assert2XX(resp.status_code) - - - class AssetsToJsonTestCase(TestCase): """ diff --git a/cms/djangoapps/contentstore/tests/test_import_export.py b/cms/djangoapps/contentstore/tests/test_import_export.py new file mode 100644 index 0000000000..d09f3a9715 --- /dev/null +++ b/cms/djangoapps/contentstore/tests/test_import_export.py @@ -0,0 +1,85 @@ +""" +Unit tests for course import and export +""" +import os +import shutil +import tarfile +import tempfile +from .utils import CourseTestCase +from django.core.urlresolvers import reverse + +from xmodule.modulestore import Location +from contentstore.views import import_export + + +class ImportTestCase(CourseTestCase): + """ + Unit tests for importing a course + """ + + def setUp(self): + super(ImportTestCase, self).setUp() + self.url = reverse("import_course", kwargs={ + 'org': self.course.location.org, + 'course': self.course.location.course, + 'name': self.course.location.name, + }) + self.content_dir = tempfile.mkdtemp() + + def touch(name): + """ Equivalent to shell's 'touch'""" + with file(name, 'a'): + os.utime(name, None) + + # Create tar test files ----------------------------------------------- + # OK course: + good_dir = tempfile.mkdtemp(dir=self.content_dir) + os.makedirs(os.path.join(good_dir, "course")) + with open(os.path.join(good_dir, "course.xml") , "w+") as f: + f.write('') + + with open(os.path.join(good_dir, "course", "2013_Spring.xml"), "w+") as f: + f.write('') + + self.good_tar = os.path.join(self.content_dir, "good.tar.gz") + with tarfile.open(self.good_tar, "w:gz") as gtar: + gtar.add(good_dir) + + # Bad course (no 'course.xml' file): + bad_dir = tempfile.mkdtemp(dir=self.content_dir) + touch(os.path.join(bad_dir, "bad.xml")) + self.bad_tar = os.path.join(self.content_dir, "bad.tar.gz") + with tarfile.open(self.bad_tar, "w:gz") as btar: + btar.add(bad_dir) + + def tearDown(self): + shutil.rmtree(self.content_dir) + + def test_no_coursexml(self): + """ + Check that the response for a tar.gz import without a course.xml is + correct. + """ + with open(self.bad_tar) as btar: + resp = self.client.post( + self.url, + { + "name": self.bad_tar, + "course-data": [btar] + }) + self.assertEquals(resp.status_code, 415) + + def test_with_coursexml(self): + """ + Check that the response for a tar.gz import with a course.xml is + correct. + """ + with open(self.good_tar) as gtar: + resp = self.client.post( + self.url, + { + "name": self.good_tar, + "course-data": [gtar] + }) + self.assert2XX(resp.status_code) + diff --git a/cms/djangoapps/contentstore/views/__init__.py b/cms/djangoapps/contentstore/views/__init__.py index 197c54ff36..10f6fb79a7 100644 --- a/cms/djangoapps/contentstore/views/__init__.py +++ b/cms/djangoapps/contentstore/views/__init__.py @@ -10,6 +10,7 @@ from .component import * from .course import * from .error import * from .item import * +from .import_export import * from .preview import * from .public import * from .user import * diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 2ca55148fd..23f855d33c 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -36,8 +36,7 @@ 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'] MAX_UP_LENGTH = 20000352 # Max chunk size @@ -265,248 +264,3 @@ def remove_asset(request, org, course, name): return HttpResponse() -@ensure_csrf_cookie -@require_http_methods(("GET", "POST", "PUT")) -@login_required -def import_course(request, org, course, name): - """ - This method will handle a POST request to upload and import a .tar.gz file - into a specified course - """ - location = get_location_and_verify_access(request, org, course, name) - - if request.method == 'POST': - - data_root = path(settings.GITHUB_REPO_ROOT) - course_subdir = "{0}-{1}-{2}".format(org, course, name) - course_dir = data_root / course_subdir - - filename = request.FILES['course-data'].name - if not filename.endswith('.tar.gz'): - return JsonResponse( - { 'ErrMsg': 'We only support uploading a .tar.gz file.' }, - status=415 - ) - temp_filepath = course_dir / filename - - if not course_dir.isdir(): - os.mkdir(course_dir) - - logging.debug('importing course to {0}'.format(temp_filepath)) - - # Get upload chunks byte ranges - try: - matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"]) - content_range = matches.groupdict() - except KeyError: # Single chunk - no Content-Range header - content_range = {'start': 0, 'stop': 9, 'end': 10} - - # stream out the uploaded files in chunks to disk - if int(content_range['start']) == 0: - mode = "wb+" - else: - mode = "ab+" - size = os.path.getsize(temp_filepath) - # Check to make sure we haven't missed a chunk - # This shouldn't happen, even if different instances are handling - # the same session, but it's always better to catch errors earlier. - if size != int(content_range['start']): - log.warning( - "Reported range %s does not match size downloaded so far %s", - size, - content_range['start'] - ) - return JsonResponse( - { 'ErrMsg': 'File upload corrupted. Please try again' }, - status=409 - ) - - - with open(temp_filepath, mode) as temp_file: - for chunk in request.FILES['course-data'].chunks(): - temp_file.write(chunk) - - size = os.path.getsize(temp_filepath) - - if int(content_range['stop']) != int(content_range['end']) - 1: - # More chunks coming - return JsonResponse({ - "files": [{ - "name": filename, - "size": size, - "deleteUrl": "", - "deleteType": "", - "url": reverse('import_course', kwargs={ - 'org': location.org, - 'course': location.course, - 'name': location.name - }), - "thumbnailUrl": "" - }] - }) - - else: #This was the last chunk. - - # 'Lock' with status info. - lock_filepath = data_root / (filename + ".lock") - - with open(lock_filepath, 'w+') as lf: - lf.write("Extracting") - - tar_file = tarfile.open(temp_filepath) - tar_file.extractall(course_dir + '/') - - with open(lock_filepath, 'w+') as lf: - lf.write("Verifying") - - # find the 'course.xml' file - dirpath = None - - coursexmls = ((d, f) for d, _, f in os.walk(course_dir) - if f.count('course.xml') > 0) - - try: - (dirpath, fname) = coursexmls.next() - except StopIteration: - return JsonResponse( - {'ErrMsg': 'Could not find the course.xml file in the package.' }, - status=415 - ) - - logging.debug('found course.xml at {0}'.format(dirpath)) - - if dirpath != course_dir: - for fname in os.listdir(dirpath): - shutil.move(dirpath / fname, course_dir) - - _module_store, course_items = import_from_xml( - modulestore('direct'), - settings.GITHUB_REPO_ROOT, - [course_subdir], - load_error_modules=False, - static_content_store=contentstore(), - target_location_namespace=location, - draft_store=modulestore() - ) - - # we can blow this away when we're done importing. - shutil.rmtree(course_dir) - - logging.debug('new course at {0}'.format(course_items[0].location)) - - with open(lock_filepath, 'w') as lf: - lf.write("Updating course") - - create_all_course_groups(request.user, course_items[0].location) - logging.debug('created all course groups at {0}'.format(course_items[0].location)) - - os.remove(lock_filepath) - - return JsonResponse({'Status': 'OK'}) - else: - course_module = modulestore().get_item(location) - - return render_to_response('import.html', { - 'context_course': course_module, - 'successful_import_redirect_url': reverse('course_index', kwargs={ - 'org': location.org, - 'course': location.course, - 'name': location.name, - }) - }) - - -@ensure_csrf_cookie -@login_required -def generate_export_course(request, org, course, name): - """ - This method will serialize out a course to a .tar.gz file which contains a - XML-based representation of the course - """ - location = get_location_and_verify_access(request, org, course, name) - course_module = modulestore().get_instance(location.course_id, location) - loc = Location(location) - export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") - - root_dir = path(mkdtemp()) - - 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 - try: - failed_item = modulestore().get_instance(course_module.location.course_id, e.location) - parent_locs = modulestore().get_parent_locations(failed_item.location, course_module.location.course_id) - - if len(parent_locs) > 0: - parent = modulestore().get_item(parent_locs[0]) - if parent.location.category == 'vertical': - unit = parent - except: - # if we have a nested exception, then we'll show the more generic error message - pass - - return render_to_response('export.html', { - 'context_course': course_module, - 'successful_import_redirect_url': '', - 'in_err': True, - 'raw_err_msg': str(e), - 'failed_module': failed_item, - 'unit': unit, - 'edit_unit_url': reverse('edit_unit', kwargs={ - 'location': parent.location - }) if parent else '', - 'course_home_url': reverse('course_index', kwargs={ - 'org': org, - 'course': course, - 'name': 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': '', - 'in_err': True, - 'unit': None, - 'raw_err_msg': str(e), - 'course_home_url': reverse('course_index', kwargs={ - 'org': org, - 'course': course, - 'name': name - }) - }) - - logging.debug('tar file being generated at {0}'.format(export_file.name)) - tar_file = tarfile.open(name=export_file.name, mode='w:gz') - tar_file.add(root_dir / name, arcname=name) - tar_file.close() - - # remove temp dir - shutil.rmtree(root_dir / name) - - wrapper = FileWrapper(export_file) - response = HttpResponse(wrapper, content_type='application/x-tgz') - response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name) - response['Content-Length'] = os.path.getsize(export_file.name) - return response - - -@ensure_csrf_cookie -@login_required -def export_course(request, org, course, name): - """ - This method serves up the 'Export Course' page - """ - location = get_location_and_verify_access(request, org, course, name) - - course_module = modulestore().get_item(location) - - return render_to_response('export.html', { - 'context_course': course_module, - 'successful_import_redirect_url': '' - }) diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py new file mode 100644 index 0000000000..83bcdd402f --- /dev/null +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -0,0 +1,309 @@ +""" +These views handle all actions in Studio related to import and exporting of courses +""" +import logging +import os +import tarfile +import shutil +import re +from tempfile import mkdtemp +from path import path + +from django.conf import settings +from django.http import HttpResponse +from django.contrib.auth.decorators import login_required +from django_future.csrf import ensure_csrf_cookie +from django.core.urlresolvers import reverse +from django.core.servers.basehttp import FileWrapper +from django.core.files.temp import NamedTemporaryFile +from django.views.decorators.http import require_http_methods + +from mitxmako.shortcuts import render_to_response +from cache_toolbox.core import del_cached_content +from auth.authz import create_all_course_groups + +from xmodule.modulestore.xml_importer import import_from_xml +from xmodule.contentstore.django import contentstore +from xmodule.modulestore.xml_exporter import export_to_xml +from xmodule.modulestore.django import modulestore +from xmodule.modulestore import Location +from xmodule.exceptions import SerializationError + +from .access import get_location_and_verify_access +from util.json_request import JsonResponse + + +__all__ = ['import_course', 'generate_export_course', 'export_course'] + +log = logging.getLogger(__name__) + + +MAX_UP_LENGTH = 20000352 # Max chunk size for uploads + +# Regex to capture Content-Range header ranges. +CONTENT_RE = re.compile(r"(?P\d{1,11})-(?P\d{1,11})/(?P\d{1,11})") + + +@ensure_csrf_cookie +@require_http_methods(("GET", "POST", "PUT")) +@login_required +def import_course(request, org, course, name): + """ + This method will handle a POST request to upload and import a .tar.gz file + into a specified course + """ + location = get_location_and_verify_access(request, org, course, name) + + if request.method == 'POST': + + data_root = path(settings.GITHUB_REPO_ROOT) + course_subdir = "{0}-{1}-{2}".format(org, course, name) + course_dir = data_root / course_subdir + + filename = request.FILES['course-data'].name + if not filename.endswith('.tar.gz'): + return JsonResponse( + { 'ErrMsg': 'We only support uploading a .tar.gz file.' }, + status=415 + ) + temp_filepath = course_dir / filename + + if not course_dir.isdir(): + os.mkdir(course_dir) + + logging.debug('importing course to {0}'.format(temp_filepath)) + + # Get upload chunks byte ranges + try: + matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"]) + content_range = matches.groupdict() + except KeyError: # Single chunk - no Content-Range header + content_range = {'start': 0, 'stop': 9, 'end': 10} + + # stream out the uploaded files in chunks to disk + if int(content_range['start']) == 0: + mode = "wb+" + else: + mode = "ab+" + size = os.path.getsize(temp_filepath) + # Check to make sure we haven't missed a chunk + # This shouldn't happen, even if different instances are handling + # the same session, but it's always better to catch errors earlier. + if size != int(content_range['start']): + log.warning( + "Reported range %s does not match size downloaded so far %s", + size, + content_range['start'] + ) + return JsonResponse( + { 'ErrMsg': 'File upload corrupted. Please try again' }, + status=409 + ) + + + with open(temp_filepath, mode) as temp_file: + for chunk in request.FILES['course-data'].chunks(): + temp_file.write(chunk) + + size = os.path.getsize(temp_filepath) + + if int(content_range['stop']) != int(content_range['end']) - 1: + # More chunks coming + return JsonResponse({ + "files": [{ + "name": filename, + "size": size, + "deleteUrl": "", + "deleteType": "", + "url": reverse('import_course', kwargs={ + 'org': location.org, + 'course': location.course, + 'name': location.name + }), + "thumbnailUrl": "" + }] + }) + + else: # This was the last chunk. + + # 'Lock' with status info. + lock_filepath = data_root / (filename + ".lock") + + with open(lock_filepath, 'w+') as lf: + lf.write("Extracting") + + tar_file = tarfile.open(temp_filepath) + tar_file.extractall(course_dir + '/') + + with open(lock_filepath, 'w+') as lf: + lf.write("Verifying") + + # find the 'course.xml' file + dirpath = None + + def get_all_files(directory): + """ + For each file in the directory, yield a 2-tuple of (file-name, + directory-path) + """ + for dirpath, _dirnames, filenames in os.walk(directory): + for filename in filenames: + yield (filename, dirpath) + + def get_dir_for_fname(directory, filename): + """ + Returns the dirpath for the first file found in the directory + with the given name. If there is no file in the directory with + the specified name, return None. + """ + for fname, dirpath in get_all_files(directory): + if fname == filename: + return dirpath + return None + + fname = "course.xml" + + dirpath = get_dir_for_fname(course_dir, fname) + + if not dirpath: + return JsonResponse( + {'ErrMsg': 'Could not find the course.xml file in the package.' }, + status=415 + ) + + logging.debug('found course.xml at {0}'.format(dirpath)) + + if dirpath != course_dir: + for fname in os.listdir(dirpath): + shutil.move(dirpath / fname, course_dir) + + _module_store, course_items = import_from_xml( + modulestore('direct'), + settings.GITHUB_REPO_ROOT, + [course_subdir], + load_error_modules=False, + static_content_store=contentstore(), + target_location_namespace=location, + draft_store=modulestore() + ) + + # we can blow this away when we're done importing. + shutil.rmtree(course_dir) + + logging.debug('new course at {0}'.format(course_items[0].location)) + + with open(lock_filepath, 'w') as lf: + lf.write("Updating course") + + create_all_course_groups(request.user, course_items[0].location) + logging.debug('created all course groups at {0}'.format(course_items[0].location)) + + os.remove(lock_filepath) + + return JsonResponse({'Status': 'OK'}) + else: + course_module = modulestore().get_item(location) + + return render_to_response('import.html', { + 'context_course': course_module, + 'successful_import_redirect_url': reverse('course_index', kwargs={ + 'org': location.org, + 'course': location.course, + 'name': location.name, + }) + }) + + +@ensure_csrf_cookie +@login_required +def generate_export_course(request, org, course, name): + """ + This method will serialize out a course to a .tar.gz file which contains a + XML-based representation of the course + """ + location = get_location_and_verify_access(request, org, course, name) + course_module = modulestore().get_instance(location.course_id, location) + loc = Location(location) + export_file = NamedTemporaryFile(prefix=name + '.', suffix=".tar.gz") + + root_dir = path(mkdtemp()) + + 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 + try: + failed_item = modulestore().get_instance(course_module.location.course_id, e.location) + parent_locs = modulestore().get_parent_locations(failed_item.location, course_module.location.course_id) + + if len(parent_locs) > 0: + parent = modulestore().get_item(parent_locs[0]) + if parent.location.category == 'vertical': + unit = parent + except: + # if we have a nested exception, then we'll show the more generic error message + pass + + return render_to_response('export.html', { + 'context_course': course_module, + 'successful_import_redirect_url': '', + 'in_err': True, + 'raw_err_msg': str(e), + 'failed_module': failed_item, + 'unit': unit, + 'edit_unit_url': reverse('edit_unit', kwargs={ + 'location': parent.location + }) if parent else '', + 'course_home_url': reverse('course_index', kwargs={ + 'org': org, + 'course': course, + 'name': 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': '', + 'in_err': True, + 'unit': None, + 'raw_err_msg': str(e), + 'course_home_url': reverse('course_index', kwargs={ + 'org': org, + 'course': course, + 'name': name + }) + }) + + logging.debug('tar file being generated at {0}'.format(export_file.name)) + tar_file = tarfile.open(name=export_file.name, mode='w:gz') + tar_file.add(root_dir / name, arcname=name) + tar_file.close() + + # remove temp dir + shutil.rmtree(root_dir / name) + + wrapper = FileWrapper(export_file) + response = HttpResponse(wrapper, content_type='application/x-tgz') + response['Content-Disposition'] = 'attachment; filename=%s' % os.path.basename(export_file.name) + response['Content-Length'] = os.path.getsize(export_file.name) + return response + + +@ensure_csrf_cookie +@login_required +def export_course(request, org, course, name): + """ + This method serves up the 'Export Course' page + """ + location = get_location_and_verify_access(request, org, course, name) + + course_module = modulestore().get_item(location) + + return render_to_response('export.html', { + 'context_course': course_module, + 'successful_import_redirect_url': '' + }) From eaa4b3ef997cc185494c033acb0ddc8f20a16555 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 19 Aug 2013 11:23:58 -0400 Subject: [PATCH 150/244] Pep8 and pylint fixes --- .../contentstore/tests/test_assets.py | 5 ---- cms/djangoapps/contentstore/views/assets.py | 2 -- .../contentstore/views/import_export.py | 25 +++++++++---------- 3 files changed, 12 insertions(+), 20 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_assets.py b/cms/djangoapps/contentstore/tests/test_assets.py index 9bde503b4d..2f158cfda6 100644 --- a/cms/djangoapps/contentstore/tests/test_assets.py +++ b/cms/djangoapps/contentstore/tests/test_assets.py @@ -2,12 +2,7 @@ Unit tests for the asset upload endpoint. """ -import os import json -import shutil -import tarfile -import tempfile -from subprocess import call from datetime import datetime from io import BytesIO from pytz import UTC diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index 23f855d33c..f8483d9338 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -38,8 +38,6 @@ from util.json_request import JsonResponse __all__ = ['asset_index', 'upload_asset'] -MAX_UP_LENGTH = 20000352 # Max chunk size - # Regex to capture Content-Range header ranges. CONTENT_RE = re.compile(r"(?P\d{1,11})-(?P\d{1,11})/(?P\d{1,11})") diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index 83bcdd402f..37d9ed4ed3 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -63,7 +63,7 @@ def import_course(request, org, course, name): filename = request.FILES['course-data'].name if not filename.endswith('.tar.gz'): return JsonResponse( - { 'ErrMsg': 'We only support uploading a .tar.gz file.' }, + {'ErrMsg': 'We only support uploading a .tar.gz file.'}, status=415 ) temp_filepath = course_dir / filename @@ -96,11 +96,10 @@ def import_course(request, org, course, name): content_range['start'] ) return JsonResponse( - { 'ErrMsg': 'File upload corrupted. Please try again' }, + {'ErrMsg': 'File upload corrupted. Please try again'}, status=409 ) - with open(temp_filepath, mode) as temp_file: for chunk in request.FILES['course-data'].chunks(): temp_file.write(chunk) @@ -121,8 +120,8 @@ def import_course(request, org, course, name): 'name': location.name }), "thumbnailUrl": "" - }] - }) + }] + }) else: # This was the last chunk. @@ -167,7 +166,7 @@ def import_course(request, org, course, name): if not dirpath: return JsonResponse( - {'ErrMsg': 'Could not find the course.xml file in the package.' }, + {'ErrMsg': 'Could not find the course.xml file in the package.'}, status=415 ) @@ -178,13 +177,13 @@ def import_course(request, org, course, name): shutil.move(dirpath / fname, course_dir) _module_store, course_items = import_from_xml( - modulestore('direct'), - settings.GITHUB_REPO_ROOT, - [course_subdir], - load_error_modules=False, - static_content_store=contentstore(), - target_location_namespace=location, - draft_store=modulestore() + modulestore('direct'), + settings.GITHUB_REPO_ROOT, + [course_subdir], + load_error_modules=False, + static_content_store=contentstore(), + target_location_namespace=location, + draft_store=modulestore() ) # we can blow this away when we're done importing. From ef0828fc66e43f5fc3c0f2c6badc2a67e649194e Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Mon, 19 Aug 2013 13:57:01 -0400 Subject: [PATCH 151/244] Behave properly on non-tar-gz files --- cms/templates/import.html | 52 ++++++++++++++++++++++++++++++--------- 1 file changed, 41 insertions(+), 11 deletions(-) diff --git a/cms/templates/import.html b/cms/templates/import.html index 26ebc24493..be09e3f6d7 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -60,7 +60,6 @@ var submitBtn = $('.submit-button'); $('#fileupload').fileupload({ - dataType: 'json', type: 'POST', @@ -68,18 +67,35 @@ $('#fileupload').fileupload({ autoUpload: false, + options: { + acceptFileTypes: /(\.|\/)tar\.gz$/i , + processQueue: [{ + action: 'validate', + acceptFileTypes: '@' + }] + }, + add: function(e, data) { - submitBtn.show().click(function(e){ - e.preventDefault(); - data.submit().complete(function(result, textStatus, xhr) { - if (result.status != 200) { - alert('${_("Your import has failed.")}\n\n' + - JSON.parse(result.responseText)["ErrMsg"]); - submitBtn.show(); - bar.hide(); - } + var file = data.files[0]; + if (file.type == "application/x-gzip") { + submitBtn.click(function(e){ + e.preventDefault(); + data.submit().complete(function(result, textStatus, xhr) { + if (result.status != 200) { + alert('${_("Your import has failed.")}\n\n' + + JSON.parse(result.responseText)["ErrMsg"]); + submitBtn.show(); + bar.hide(); + } else { + bar.hide() + alert('${_("Your import was successful.")}'); + window.location = '${successful_import_redirect_url}'; + } + }); }); - }); + } else { + data.files = []; + } }, progressall: function(e, data){ @@ -93,6 +109,20 @@ $('#fileupload').fileupload({ alert('${_("Your import was successful.")}'); window.location = '${successful_import_redirect_url}'; }, + processActions: { + validate: function(data, options) { + var dfdata = $.Deferred(), + file = data.files[data.index]; + if (!options.acceptFileTypes.test(file.type)) { + file.error = 'Invalid file type: must be a tar.gz file!'; + dfdata.rejectWith(this, [data]); + } else { + dfdata.resolveWith(this, [data]); + } + return dfdata.promise(); + } + } + }); From 8a8bcf289f2417034e53596aff82e7214611f133 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Tue, 20 Aug 2013 11:11:38 -0400 Subject: [PATCH 152/244] remove repeated warnings --- cms/templates/import.html | 31 ++++--------------------------- 1 file changed, 4 insertions(+), 27 deletions(-) diff --git a/cms/templates/import.html b/cms/templates/import.html index be09e3f6d7..d39ee30b11 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -67,29 +67,18 @@ $('#fileupload').fileupload({ autoUpload: false, - options: { - acceptFileTypes: /(\.|\/)tar\.gz$/i , - processQueue: [{ - action: 'validate', - acceptFileTypes: '@' - }] - }, - add: function(e, data) { + submitBtn.unbind('click'); var file = data.files[0]; if (file.type == "application/x-gzip") { submitBtn.click(function(e){ e.preventDefault(); + submitBtn.hide(); data.submit().complete(function(result, textStatus, xhr) { if (result.status != 200) { - alert('${_("Your import has failed.")}\n\n' + - JSON.parse(result.responseText)["ErrMsg"]); + alert('${_("Your import has failed.")}\n\n' + JSON.parse(result.responseText)["ErrMsg"]); submitBtn.show(); bar.hide(); - } else { - bar.hide() - alert('${_("Your import was successful.")}'); - window.location = '${successful_import_redirect_url}'; } }); }); @@ -109,19 +98,7 @@ $('#fileupload').fileupload({ alert('${_("Your import was successful.")}'); window.location = '${successful_import_redirect_url}'; }, - processActions: { - validate: function(data, options) { - var dfdata = $.Deferred(), - file = data.files[data.index]; - if (!options.acceptFileTypes.test(file.type)) { - file.error = 'Invalid file type: must be a tar.gz file!'; - dfdata.rejectWith(this, [data]); - } else { - dfdata.resolveWith(this, [data]); - } - return dfdata.promise(); - } - } + sequentialUploads: true From fcd11d93d5c107a1dece67f47b377ec2adcbe34c Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Thu, 22 Aug 2013 14:04:23 -0400 Subject: [PATCH 153/244] Handle nginx 499s and double requests --- cms/djangoapps/contentstore/views/import_export.py | 10 +++++++--- cms/static/sass/views/_import.scss | 5 +++++ cms/templates/import.html | 7 +++++++ 3 files changed, 19 insertions(+), 3 deletions(-) diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index 37d9ed4ed3..ab359862b1 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -89,16 +89,20 @@ def import_course(request, org, course, name): # Check to make sure we haven't missed a chunk # This shouldn't happen, even if different instances are handling # the same session, but it's always better to catch errors earlier. - if size != int(content_range['start']): + if size < int(content_range['start']): log.warning( "Reported range %s does not match size downloaded so far %s", - size, - content_range['start'] + content_range['start'], + size ) return JsonResponse( {'ErrMsg': 'File upload corrupted. Please try again'}, status=409 ) + # The last request sometimes comes twice. This happens because + # nginx sends a 499 error code when the response takes too long. + elif size > int(content_range['stop']) and size == int(content_range['end']): + return JsonResponse({'ImportStatus': 1}) with open(temp_filepath, mode) as temp_file: for chunk in request.FILES['course-data'].chunks(): diff --git a/cms/static/sass/views/_import.scss b/cms/static/sass/views/_import.scss index 1f9c62d917..e9701c14cf 100644 --- a/cms/static/sass/views/_import.scss +++ b/cms/static/sass/views/_import.scss @@ -57,6 +57,11 @@ body.course.import { color: $error-red; } + .status-block { + display: none; + font-size: 13px; + } + .choose-file-button { @include blue-button; padding: 10px 50px 11px; diff --git a/cms/templates/import.html b/cms/templates/import.html index d39ee30b11..a5c6b9f412 100644 --- a/cms/templates/import.html +++ b/cms/templates/import.html @@ -34,6 +34,7 @@ +

    Unpacking...

    0%
    @@ -55,6 +56,7 @@ var bar = $('.progress-bar'); var fill = $('.progress-fill'); var percent = $('.percent'); var status = $('#status'); +var statusBlock = $('.status-block'); var submitBtn = $('.submit-button'); @@ -79,6 +81,11 @@ $('#fileupload').fileupload({ alert('${_("Your import has failed.")}\n\n' + JSON.parse(result.responseText)["ErrMsg"]); submitBtn.show(); bar.hide(); + } else { + if (result.responseText["ImportStatus"] == 1) { + bar.hide(); + statusBlock.show(); + } } }); }); From a28b02ae2cfaa33ed10ad54690513c8add33a2cd Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Thu, 22 Aug 2013 15:48:52 -0400 Subject: [PATCH 154/244] Pep8 and pylint fixes --- .../contentstore/tests/test_import_export.py | 17 +++++------ .../contentstore/views/import_export.py | 29 +++++++++---------- 2 files changed, 22 insertions(+), 24 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_import_export.py b/cms/djangoapps/contentstore/tests/test_import_export.py index d09f3a9715..dbc131b207 100644 --- a/cms/djangoapps/contentstore/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/tests/test_import_export.py @@ -32,10 +32,10 @@ class ImportTestCase(CourseTestCase): os.utime(name, None) # Create tar test files ----------------------------------------------- - # OK course: + # OK course: good_dir = tempfile.mkdtemp(dir=self.content_dir) os.makedirs(os.path.join(good_dir, "course")) - with open(os.path.join(good_dir, "course.xml") , "w+") as f: + with open(os.path.join(good_dir, "course.xml"), "w+") as f: f.write('') with open(os.path.join(good_dir, "course", "2013_Spring.xml"), "w+") as f: @@ -44,7 +44,7 @@ class ImportTestCase(CourseTestCase): self.good_tar = os.path.join(self.content_dir, "good.tar.gz") with tarfile.open(self.good_tar, "w:gz") as gtar: gtar.add(good_dir) - + # Bad course (no 'course.xml' file): bad_dir = tempfile.mkdtemp(dir=self.content_dir) touch(os.path.join(bad_dir, "bad.xml")) @@ -62,11 +62,11 @@ class ImportTestCase(CourseTestCase): """ with open(self.bad_tar) as btar: resp = self.client.post( - self.url, - { - "name": self.bad_tar, - "course-data": [btar] - }) + self.url, + { + "name": self.bad_tar, + "course-data": [btar] + }) self.assertEquals(resp.status_code, 415) def test_with_coursexml(self): @@ -82,4 +82,3 @@ class ImportTestCase(CourseTestCase): "course-data": [gtar] }) self.assert2XX(resp.status_code) - diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index ab359862b1..b88b7d59b4 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -19,7 +19,6 @@ from django.core.files.temp import NamedTemporaryFile from django.views.decorators.http import require_http_methods from mitxmako.shortcuts import render_to_response -from cache_toolbox.core import del_cached_content from auth.authz import create_all_course_groups from xmodule.modulestore.xml_importer import import_from_xml @@ -38,7 +37,7 @@ __all__ = ['import_course', 'generate_export_course', 'export_course'] log = logging.getLogger(__name__) -MAX_UP_LENGTH = 20000352 # Max chunk size for uploads +MAX_UP_LENGTH = 20000352 # Max chunk size for uploads # Regex to capture Content-Range header ranges. CONTENT_RE = re.compile(r"(?P\d{1,11})-(?P\d{1,11})/(?P\d{1,11})") @@ -113,18 +112,18 @@ def import_course(request, org, course, name): if int(content_range['stop']) != int(content_range['end']) - 1: # More chunks coming return JsonResponse({ - "files": [{ - "name": filename, - "size": size, - "deleteUrl": "", - "deleteType": "", - "url": reverse('import_course', kwargs={ - 'org': location.org, - 'course': location.course, - 'name': location.name - }), - "thumbnailUrl": "" - }] + "files": [{ + "name": filename, + "size": size, + "deleteUrl": "", + "deleteType": "", + "url": reverse('import_course', kwargs={ + 'org': location.org, + 'course': location.course, + 'name': location.name + }), + "thumbnailUrl": "" + }] }) else: # This was the last chunk. @@ -147,7 +146,7 @@ def import_course(request, org, course, name): def get_all_files(directory): """ For each file in the directory, yield a 2-tuple of (file-name, - directory-path) + directory-path) """ for dirpath, _dirnames, filenames in os.walk(directory): for filename in filenames: From 509a8614590486a149370bc8dcf564741e59a188 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Thu, 22 Aug 2013 17:31:46 -0400 Subject: [PATCH 155/244] Clear contentstore Signed-off-by: Julian Arni --- .../contentstore/tests/test_import_export.py | 14 ++++++++++++-- 1 file changed, 12 insertions(+), 2 deletions(-) diff --git a/cms/djangoapps/contentstore/tests/test_import_export.py b/cms/djangoapps/contentstore/tests/test_import_export.py index dbc131b207..72c56ac0c4 100644 --- a/cms/djangoapps/contentstore/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/tests/test_import_export.py @@ -5,13 +5,21 @@ import os import shutil import tarfile import tempfile +import copy +from uuid import uuid4 +from pymongo import MongoClient + from .utils import CourseTestCase from django.core.urlresolvers import reverse +from django.test.utils import override_settings +from django.conf import settings -from xmodule.modulestore import Location -from contentstore.views import import_export +from xmodule.contentstore.django import _CONTENTSTORE +TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) +TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) class ImportTestCase(CourseTestCase): """ Unit tests for importing a course @@ -54,6 +62,8 @@ class ImportTestCase(CourseTestCase): def tearDown(self): shutil.rmtree(self.content_dir) + MongoClient().drop_database(TEST_DATA_CONTENTSTORE['OPTIONS']['db']) + _CONTENTSTORE.clear() def test_no_coursexml(self): """ From 03b140d538801754ff5f18f7fa32fb1bdce70261 Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Thu, 22 Aug 2013 20:26:13 -0400 Subject: [PATCH 156/244] Review fixes --- cms/djangoapps/contentstore/views/assets.py | 3 - .../contentstore/views/import_export.py | 142 ++++++++++-------- 2 files changed, 78 insertions(+), 67 deletions(-) diff --git a/cms/djangoapps/contentstore/views/assets.py b/cms/djangoapps/contentstore/views/assets.py index f8483d9338..00cf33a328 100644 --- a/cms/djangoapps/contentstore/views/assets.py +++ b/cms/djangoapps/contentstore/views/assets.py @@ -38,9 +38,6 @@ from util.json_request import JsonResponse __all__ = ['asset_index', 'upload_asset'] -# Regex to capture Content-Range header ranges. -CONTENT_RE = re.compile(r"(?P\d{1,11})-(?P\d{1,11})/(?P\d{1,11})") - def assets_to_json_dict(assets): """ Transform the results of a contentstore query into something appropriate diff --git a/cms/djangoapps/contentstore/views/import_export.py b/cms/djangoapps/contentstore/views/import_export.py index b88b7d59b4..5830e07a52 100644 --- a/cms/djangoapps/contentstore/views/import_export.py +++ b/cms/djangoapps/contentstore/views/import_export.py @@ -1,5 +1,6 @@ """ -These views handle all actions in Studio related to import and exporting of courses +These views handle all actions in Studio related to import and exporting of +courses """ import logging import os @@ -8,6 +9,7 @@ import shutil import re from tempfile import mkdtemp from path import path +from contextlib import contextmanager from django.conf import settings from django.http import HttpResponse @@ -37,8 +39,6 @@ __all__ = ['import_course', 'generate_export_course', 'export_course'] log = logging.getLogger(__name__) -MAX_UP_LENGTH = 20000352 # Max chunk size for uploads - # Regex to capture Content-Range header ranges. CONTENT_RE = re.compile(r"(?P\d{1,11})-(?P\d{1,11})/(?P\d{1,11})") @@ -53,6 +53,20 @@ def import_course(request, org, course, name): """ location = get_location_and_verify_access(request, org, course, name) + @contextmanager + def wfile(filename, dirname): + """ + A with-context that creates `filename` on entry and removes it on exit. + `filename` is truncted on creation. Additionally removes dirname on + exit. + """ + open("file", "w").close() + try: + yield filename + finally: + os.remove(filename) + shutil.rmtree(dirname) + if request.method == 'POST': data_root = path(settings.GITHUB_REPO_ROOT) @@ -76,8 +90,9 @@ def import_course(request, org, course, name): try: matches = CONTENT_RE.search(request.META["HTTP_CONTENT_RANGE"]) content_range = matches.groupdict() - except KeyError: # Single chunk - no Content-Range header - content_range = {'start': 0, 'stop': 9, 'end': 10} + except KeyError: # Single chunk + # no Content-Range header, so make one that will work + content_range = {'start': 0, 'stop': 1, 'end': 2} # stream out the uploaded files in chunks to disk if int(content_range['start']) == 0: @@ -129,78 +144,77 @@ def import_course(request, org, course, name): else: # This was the last chunk. # 'Lock' with status info. - lock_filepath = data_root / (filename + ".lock") + status_file = data_root / (course + filename + ".lock") - with open(lock_filepath, 'w+') as lf: - lf.write("Extracting") + # Do everything from now on in a with-context, to be sure we've + # properly cleaned up. + with wfile(status_file, course_dir): - tar_file = tarfile.open(temp_filepath) - tar_file.extractall(course_dir + '/') + with open(status_file, 'w+') as sf: + sf.write("Extracting") - with open(lock_filepath, 'w+') as lf: - lf.write("Verifying") + tar_file = tarfile.open(temp_filepath) + tar_file.extractall(course_dir + '/') - # find the 'course.xml' file - dirpath = None + with open(status_file, 'w+') as sf: + sf.write("Verifying") - def get_all_files(directory): - """ - For each file in the directory, yield a 2-tuple of (file-name, - directory-path) - """ - for dirpath, _dirnames, filenames in os.walk(directory): - for filename in filenames: - yield (filename, dirpath) + # find the 'course.xml' file + dirpath = None - def get_dir_for_fname(directory, filename): - """ - Returns the dirpath for the first file found in the directory - with the given name. If there is no file in the directory with - the specified name, return None. - """ - for fname, dirpath in get_all_files(directory): - if fname == filename: - return dirpath - return None + def get_all_files(directory): + """ + For each file in the directory, yield a 2-tuple of (file-name, + directory-path) + """ + for dirpath, _dirnames, filenames in os.walk(directory): + for filename in filenames: + yield (filename, dirpath) - fname = "course.xml" + def get_dir_for_fname(directory, filename): + """ + Returns the dirpath for the first file found in the directory + with the given name. If there is no file in the directory with + the specified name, return None. + """ + for fname, dirpath in get_all_files(directory): + if fname == filename: + return dirpath + return None - dirpath = get_dir_for_fname(course_dir, fname) + fname = "course.xml" - if not dirpath: - return JsonResponse( - {'ErrMsg': 'Could not find the course.xml file in the package.'}, - status=415 + dirpath = get_dir_for_fname(course_dir, fname) + + if not dirpath: + return JsonResponse( + {'ErrMsg': 'Could not find the course.xml file in the package.'}, + status=415 + ) + + logging.debug('found course.xml at {0}'.format(dirpath)) + + if dirpath != course_dir: + for fname in os.listdir(dirpath): + shutil.move(dirpath / fname, course_dir) + + _module_store, course_items = import_from_xml( + modulestore('direct'), + settings.GITHUB_REPO_ROOT, + [course_subdir], + load_error_modules=False, + static_content_store=contentstore(), + target_location_namespace=location, + draft_store=modulestore() ) - logging.debug('found course.xml at {0}'.format(dirpath)) + logging.debug('new course at {0}'.format(course_items[0].location)) - if dirpath != course_dir: - for fname in os.listdir(dirpath): - shutil.move(dirpath / fname, course_dir) + with open(status_file, 'w') as sf: + sf.write("Updating course") - _module_store, course_items = import_from_xml( - modulestore('direct'), - settings.GITHUB_REPO_ROOT, - [course_subdir], - load_error_modules=False, - static_content_store=contentstore(), - target_location_namespace=location, - draft_store=modulestore() - ) - - # we can blow this away when we're done importing. - shutil.rmtree(course_dir) - - logging.debug('new course at {0}'.format(course_items[0].location)) - - with open(lock_filepath, 'w') as lf: - lf.write("Updating course") - - create_all_course_groups(request.user, course_items[0].location) - logging.debug('created all course groups at {0}'.format(course_items[0].location)) - - os.remove(lock_filepath) + create_all_course_groups(request.user, course_items[0].location) + logging.debug('created all course groups at {0}'.format(course_items[0].location)) return JsonResponse({'Status': 'OK'}) else: From 1e44e016296697d48386d3fe7b7ef6ce8785bf3c Mon Sep 17 00:00:00 2001 From: Julian Arni Date: Sun, 25 Aug 2013 22:50:26 -0400 Subject: [PATCH 157/244] Remove assert2xx --- cms/djangoapps/contentstore/tests/test_import_export.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cms/djangoapps/contentstore/tests/test_import_export.py b/cms/djangoapps/contentstore/tests/test_import_export.py index 72c56ac0c4..05f2b0b7b9 100644 --- a/cms/djangoapps/contentstore/tests/test_import_export.py +++ b/cms/djangoapps/contentstore/tests/test_import_export.py @@ -91,4 +91,4 @@ class ImportTestCase(CourseTestCase): "name": self.good_tar, "course-data": [gtar] }) - self.assert2XX(resp.status_code) + self.assertEquals(resp.status_code, 200) From d347390c240cf87224b97936a84b1205accec21f Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Wed, 21 Aug 2013 09:30:59 -0400 Subject: [PATCH 158/244] fix error logging for unenrolling if enrollment not found --- common/djangoapps/student/models.py | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/common/djangoapps/student/models.py b/common/djangoapps/student/models.py index 3d977b28c9..5f29ffa6aa 100644 --- a/common/djangoapps/student/models.py +++ b/common/djangoapps/student/models.py @@ -805,7 +805,8 @@ class CourseEnrollment(models.Model): record.is_active = False record.save() except cls.DoesNotExist: - log.error("Tried to unenroll student {} from {} but they were not enrolled") + err_msg = u"Tried to unenroll student {} from {} but they were not enrolled" + log.error(err_msg.format(user, course_id)) @classmethod def unenroll_by_email(cls, email, course_id): From fc8acb61a33974ca022349fac8f682bb355392dc Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Fri, 28 Jun 2013 12:02:52 -0400 Subject: [PATCH 159/244] Changed voting dialog to only display one tab per answer. No tests for this, yet. Conflicts: common/lib/xmodule/xmodule/crowdsource_hinter.py common/templates/hinter_display.html --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 41 +++++++++---------- .../js/src/crowdsource_hinter/display.coffee | 13 ++++-- common/templates/hinter_display.html | 22 ++++++---- 3 files changed, 43 insertions(+), 33 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 7c7f94a26b..734708ec04 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -188,8 +188,8 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Args: `data` -- not actually used. (It is assumed that the answer is correct.) Output keys: - - 'index_to_hints' maps previous answer indices to hints that the user saw earlier. - - 'index_to_answer' maps previous answer indices to the actual answer submitted. + - 'answer_to_hints': a nested dictionary. + answer_to_hints[answer][hint_pk] returns the text of the hint. """ # The student got it right. # Did he submit at least one wrong answer? @@ -199,27 +199,24 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Make a hint-voting interface for each wrong answer. The student will only # be allowed to make one vote / submission, but he can choose which wrong answer # he wants to look at. - # index_to_hints[previous answer #] = [(hint text, hint pk), + ] - index_to_hints = {} - # index_to_answer[previous answer #] = answer text - index_to_answer = {} + answer_to_hints = {} #answer_to_hints[answer text][hint pk] -> hint text # Go through each previous answer, and populate index_to_hints and index_to_answer. for i in xrange(len(self.previous_answers)): answer, hints_offered = self.previous_answers[i] - index_to_hints[i] = [] - index_to_answer[i] = answer + if answer not in answer_to_hints: + answer_to_hints[answer] = {} if answer in self.hints: # Go through each hint, and add to index_to_hints for hint_id in hints_offered: - if hint_id is not None: + if (hint_id is not None) and (hint_id not in answer_to_hints[answer]): try: - index_to_hints[i].append((self.hints[answer][str(hint_id)][0], hint_id)) + answer_to_hints[answer][hint_id] = self.hints[answer][str(hint_id)][0] except KeyError: # Sometimes, the hint that a user saw will have been deleted by the instructor. continue - return {'index_to_hints': index_to_hints, 'index_to_answer': index_to_answer} + return {'answer_to_hints': answer_to_hints} def tally_vote(self, data): """ @@ -229,27 +226,29 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): `data` -- expected to have the following keys: 'answer': ans_no (index in previous_answers) 'hint': hint_pk + 'pk_list': We will return a list of how many votes each hint has so far. + It's up to the browser to specify which hints to return vote counts for. + Every pk listed here will have a hint count returned. Returns key 'hint_and_votes', a list of (hint_text, #votes) pairs. """ if self.user_voted: - return {} - ans_no = int(data['answer']) - hint_no = str(data['hint']) - answer = self.previous_answers[ans_no][0] + return json.dumps({'contents': 'Sorry, but you have already voted!'}) + ans = data['answer'] + hint_pk = str(data['hint']) + pk_list = json.loads(data['pk_list']) # We use temp_dict because we need to do a direct write for the database to update. temp_dict = self.hints - temp_dict[answer][hint_no][1] += 1 + temp_dict[ans][hint_pk][1] += 1 self.hints = temp_dict # Don't let the user vote again! self.user_voted = True # Return a list of how many votes each hint got. hint_and_votes = [] - for hint_no in self.previous_answers[ans_no][1]: - if hint_no is None: - continue - hint_and_votes.append(temp_dict[answer][str(hint_no)]) + for vote_pk in pk_list: + hint_and_votes.append(temp_dict[ans][vote_pk]) + hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) # Reset self.previous_answers. self.previous_answers = [] return {'hint_and_votes': hint_and_votes} @@ -266,7 +265,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): """ # Do html escaping. Perhaps in the future do profanity filtering, etc. as well. hint = escape(data['hint']) - answer = self.previous_answers[int(data['answer'])][0] + answer = data['answer'] # Only allow a student to vote or submit a hint once. if self.user_voted: return {'message': 'Sorry, but you have already voted!'} diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index 72522f5b03..3df970a618 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -28,6 +28,10 @@ class @Hinter $: (selector) -> $(selector, @el) + jq_escape: (string) => + # Escape a string for jquery selector use. + return string.replace(/[!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~]/g, '\\$&') + bind: => window.update_schematics() @$('input.vote').click @vote @@ -44,14 +48,17 @@ class @Hinter vote: (eventObj) => target = @$(eventObj.currentTarget) - post_json = {'answer': target.data('answer'), 'hint': target.data('hintno')} + parent_div_selector = '#previous-answer-' + @jq_escape(target.attr('data-answer')) + all_pks = @$(parent_div_selector).attr('data-all-pks') + console.debug(all_pks) + post_json = {'answer': target.attr('data-answer'), 'hint': target.data('hintno'), 'pk_list': all_pks} $.postWithPrefix "#{@url}/vote", post_json, (response) => @render(response.contents) submit_hint: (eventObj) => target = @$(eventObj.currentTarget) - textarea_id = '#custom-hint-' + target.data('answer') - post_json = {'answer': target.data('answer'), 'hint': @$(textarea_id).val()} + textarea_id = '#custom-hint-' + @jq_escape(target.attr('data-answer')) + post_json = {'answer': target.attr('data-answer'), 'hint': @$(textarea_id).val()} $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => @render(response.contents) diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index 6f5d6f37fb..d349a17fc5 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -25,21 +25,25 @@
      - % for index, answer in index_to_answer.items(): -
    • ${answer}
    • + % for answer in answer_to_hints: +
    • ${answer}
    • % endfor
    - % for index, answer in index_to_answer.items(): -
    + % for answer, pk_dict in answer_to_hints.items(): + <% + import json + all_pks = json.dumps(pk_dict.keys()) + %> +
    - % if index in index_to_hints and len(index_to_hints[index]) > 0: + % if len(pk_dict) > 0:

    Which hint would be most effective to show a student who also got ${answer}?

    - % for hint_text, hint_pk in index_to_hints[index]: + % for hint_pk, hint_text in pk_dict.items():

    - + ${hint_text}

    % endfor @@ -50,12 +54,12 @@

    What hint would you give a student who made the same mistake you did? Please don't give away the answer.

    -

    - +
    % endfor
    From 69380756d2a45e1147bba360377b9ac6f5f0395c Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Fri, 28 Jun 2013 13:49:25 -0400 Subject: [PATCH 160/244] Fixed tests to work with new vote-tabbing method. Fixed some string-int incompatibility errors. Conflicts: common/lib/xmodule/xmodule/crowdsource_hinter.py common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 12 ++++---- .../xmodule/tests/test_crowdsource_hinter.py | 28 ++++++++----------- 2 files changed, 18 insertions(+), 22 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 734708ec04..f0f36e7ef8 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -199,7 +199,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Make a hint-voting interface for each wrong answer. The student will only # be allowed to make one vote / submission, but he can choose which wrong answer # he wants to look at. - answer_to_hints = {} #answer_to_hints[answer text][hint pk] -> hint text + answer_to_hints = {} # answer_to_hints[answer text][hint pk] -> hint text # Go through each previous answer, and populate index_to_hints and index_to_answer. for i in xrange(len(self.previous_answers)): @@ -224,7 +224,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Args: `data` -- expected to have the following keys: - 'answer': ans_no (index in previous_answers) + 'answer': text of answer we're voting on 'hint': hint_pk 'pk_list': We will return a list of how many votes each hint has so far. It's up to the browser to specify which hints to return vote counts for. @@ -246,7 +246,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Return a list of how many votes each hint got. hint_and_votes = [] for vote_pk in pk_list: - hint_and_votes.append(temp_dict[ans][vote_pk]) + hint_and_votes.append(temp_dict[ans][str(vote_pk)]) hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) # Reset self.previous_answers. @@ -259,7 +259,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Args: `data` -- expected to have the following keys: - 'answer': answer index in previous_answers + 'answer': text of answer 'hint': text of the new hint that the user is adding Returns a thank-you message. """ @@ -276,9 +276,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): else: temp_dict = self.hints if answer in temp_dict: - temp_dict[answer][self.hint_pk] = [hint, 1] # With one vote (the user himself). + temp_dict[answer][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself). else: - temp_dict[answer] = {self.hint_pk: [hint, 1]} + temp_dict[answer] = {str(self.hint_pk): [hint, 1]} self.hint_pk += 1 if self.moderate == 'True': self.mod_queue = temp_dict diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index 19e156a0f3..989b264694 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -294,9 +294,7 @@ class CrowdsourceHinterTest(unittest.TestCase): mock_module = CHModuleFactory.create(previous_answers=[['26.0', [None, None, None]]]) json_in = {'problem_name': '42.5'} out = mock_module.get_feedback(json_in) - print out['index_to_answer'] - self.assertTrue(out['index_to_hints'][0] == []) - self.assertTrue(out['index_to_answer'][0] == '26.0') + self.assertTrue(out['answer_to_hints'] == {'26.0': {}}) def test_getfeedback_1wronganswer_withhints(self): """ @@ -307,8 +305,7 @@ class CrowdsourceHinterTest(unittest.TestCase): mock_module = CHModuleFactory.create(previous_answers=[['24.0', [0, 3, None]]]) json_in = {'problem_name': '42.5'} out = mock_module.get_feedback(json_in) - print out['index_to_hints'] - self.assertTrue(len(out['index_to_hints'][0]) == 2) + self.assertTrue(len(out['answer_to_hints']['24.0']) == 2) def test_getfeedback_missingkey(self): """ @@ -319,7 +316,7 @@ class CrowdsourceHinterTest(unittest.TestCase): previous_answers=[['24.0', [0, 100, None]]]) json_in = {'problem_name': '42.5'} out = mock_module.get_feedback(json_in) - self.assertTrue(len(out['index_to_hints'][0]) == 1) + self.assertTrue(len(out['answer_to_hints']['24.0']) == 1) def test_vote_nopermission(self): """ @@ -327,7 +324,7 @@ class CrowdsourceHinterTest(unittest.TestCase): Should not change any vote tallies. """ mock_module = CHModuleFactory.create(user_voted=True) - json_in = {'answer': 0, 'hint': 1} + json_in = {'answer': '24.0', 'hint': 1, 'pk_list': json.dumps([1, 3])} old_hints = copy.deepcopy(mock_module.hints) mock_module.tally_vote(json_in) self.assertTrue(mock_module.hints == old_hints) @@ -339,7 +336,7 @@ class CrowdsourceHinterTest(unittest.TestCase): """ mock_module = CHModuleFactory.create( previous_answers=[['24.0', [0, 3, None]]]) - json_in = {'answer': 0, 'hint': 3} + json_in = {'answer': '24.0', 'hint': 3, 'pk_list': json.dumps([0, 3])} dict_out = mock_module.tally_vote(json_in) self.assertTrue(mock_module.hints['24.0']['0'][1] == 40) self.assertTrue(mock_module.hints['24.0']['3'][1] == 31) @@ -351,7 +348,7 @@ class CrowdsourceHinterTest(unittest.TestCase): A user tries to submit a hint, but he has already voted. """ mock_module = CHModuleFactory.create(user_voted=True) - json_in = {'answer': 1, 'hint': 'This is a new hint.'} + json_in = {'answer': '29.0', 'hint': 'This is a new hint.'} print mock_module.user_voted mock_module.submit_hint(json_in) print mock_module.hints @@ -363,7 +360,7 @@ class CrowdsourceHinterTest(unittest.TestCase): exist yet. """ mock_module = CHModuleFactory.create() - json_in = {'answer': 1, 'hint': 'This is a new hint.'} + json_in = {'answer': '29.0', 'hint': 'This is a new hint.'} mock_module.submit_hint(json_in) self.assertTrue('29.0' in mock_module.hints) @@ -372,8 +369,8 @@ class CrowdsourceHinterTest(unittest.TestCase): A user submits a hint to an answer that has other hints already. """ - mock_module = CHModuleFactory.create(previous_answers=[['25.0', [1, None, None]]]) - json_in = {'answer': 0, 'hint': 'This is a new hint.'} + mock_module = CHModuleFactory.create(previous_answers = [['25.0', [1, None, None]]]) + json_in = {'answer': '25.0', 'hint': 'This is a new hint.'} mock_module.submit_hint(json_in) # Make a hint request. json_in = {'problem name': '25.0'} @@ -388,7 +385,7 @@ class CrowdsourceHinterTest(unittest.TestCase): dict. """ mock_module = CHModuleFactory.create(moderate='True') - json_in = {'answer': 1, 'hint': 'This is a new hint.'} + json_in = {'answer': '29.0', 'hint': 'This is a new hint.'} mock_module.submit_hint(json_in) self.assertTrue('29.0' not in mock_module.hints) self.assertTrue('29.0' in mock_module.mod_queue) @@ -398,10 +395,9 @@ class CrowdsourceHinterTest(unittest.TestCase): Make sure that hints are being html-escaped. """ mock_module = CHModuleFactory.create() - json_in = {'answer': 1, 'hint': ''} + json_in = {'answer': '29.0', 'hint': ''} mock_module.submit_hint(json_in) - print mock_module.hints - self.assertTrue(mock_module.hints['29.0'][0][0] == u'<script> alert("Trololo"); </script>') + self.assertTrue(mock_module.hints['29.0']['0'][0] == u'<script> alert("Trololo"); </script>') def test_template_gethint(self): """ From c34a81a845b72b7c7e2f714ac9300682c4a81534 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Wed, 10 Jul 2013 13:11:50 -0400 Subject: [PATCH 161/244] Refactored formula response grader to expose formula-evaluating to outside world. In preparation for adding hints to formula response. --- common/lib/capa/capa/responsetypes.py | 88 ++++++++++++++------------- 1 file changed, 47 insertions(+), 41 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 731230ecc1..c3d680ee20 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1778,46 +1778,24 @@ class FormulaResponse(LoncapaResponse): self.correct_answer, given, self.samples) return CorrectMap(self.answer_id, correctness) - def check_formula(self, expected, given, samples): - variables = samples.split('@')[0].split(',') - numsamples = int(samples.split('@')[1].split('#')[1]) - sranges = zip(*map(lambda x: map(float, x.split(",")), - samples.split('@')[1].split('#')[0].split(':'))) - - ranges = dict(zip(variables, sranges)) - for _ in range(numsamples): - instructor_variables = self.strip_dict(dict(self.context)) - student_variables = {} - # ranges give numerical ranges for testing - for var in ranges: - # TODO: allow specified ranges (i.e. integers and complex numbers) for random variables - value = random.uniform(*ranges[var]) - instructor_variables[str(var)] = value - student_variables[str(var)] = value - # log.debug('formula: instructor_vars=%s, expected=%s' % - # (instructor_variables,expected)) - - # Call `evaluator` on the instructor's answer and get a number - instructor_result = evaluator( - instructor_variables, {}, - expected, case_sensitive=self.case_sensitive - ) + def hash_answers(self, answer, var_dict_list): + """ + Takes in an answer and a list of dictionaries mapping variables to values. + Each dictionary represents a test case for the answer. + Returns a tuple of formula evaluation results. + """ + out = [] + for var_dict in var_dict_list: try: - # log.debug('formula: student_vars=%s, given=%s' % - # (student_variables,given)) - - # Call `evaluator` on the student's answer; look for exceptions - student_result = evaluator( - student_variables, - {}, - given, - case_sensitive=self.case_sensitive - ) + out.append(evaluator( + var_dict, + dict(), + answer, + cs=self.case_sensitive, + )) except UndefinedVariable as uv: log.debug( - 'formularesponse: undefined variable in given=%s', - given - ) + 'formularesponse: undefined variable in formula=%s' % answer) raise StudentInputError( "Invalid input: " + uv.message + " not permitted in answer" ) @@ -1840,15 +1818,43 @@ class FormulaResponse(LoncapaResponse): # If non-factorial related ValueError thrown, handle it the same as any other Exception log.debug('formularesponse: error {0} in formula'.format(ve)) raise StudentInputError("Invalid input: Could not parse '%s' as a formula" % - cgi.escape(given)) + cgi.escape(answer)) except Exception as err: # traceback.print_exc() log.debug('formularesponse: error %s in formula', err) raise StudentInputError("Invalid input: Could not parse '%s' as a formula" % - cgi.escape(given)) + cgi.escape(answer)) + return tuple(out) - # No errors in student's response--actually test for correctness - if not compare_with_tolerance(student_result, instructor_result, self.tolerance): + def randomize_variables(self, samples): + """ + Returns a list of dictionaries mapping variables to random values in range, + as expected by hash_answers. + """ + variables = samples.split('@')[0].split(',') + numsamples = int(samples.split('@')[1].split('#')[1]) + sranges = zip(*map(lambda x: map(float, x.split(",")), + samples.split('@')[1].split('#')[0].split(':'))) + ranges = dict(zip(variables, sranges)) + + out = [] + for i in range(numsamples): + var_dict = {} + # ranges give numerical ranges for testing + for var in ranges: + # TODO: allow specified ranges (i.e. integers and complex numbers) for random variables + value = random.uniform(*ranges[var]) + var_dict[str(var)] = value + out.append(var_dict) + return out + + def check_formula(self, expected, given, samples): + var_dict_list = self.randomize_variables(samples) + student_result = self.hash_answers(given, var_dict_list) + instructor_result = self.hash_answers(expected, var_dict_list) + + for i in xrange(len(instructor_result)): + if not compare_with_tolerance(student_result[i], instructor_result[i], self.tolerance): return "incorrect" return "correct" From b88c6b8d68fc99207ab640304ec816a022e0bbe5 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 11 Jul 2013 11:12:12 -0400 Subject: [PATCH 162/244] Hinter now works with formula responses. Tests broken. --- common/lib/capa/capa/responsetypes.py | 2 +- .../lib/xmodule/xmodule/crowdsource_hinter.py | 102 ++++++++++++++---- 2 files changed, 82 insertions(+), 22 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index c3d680ee20..47289aad51 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1824,7 +1824,7 @@ class FormulaResponse(LoncapaResponse): log.debug('formularesponse: error %s in formula', err) raise StudentInputError("Invalid input: Could not parse '%s' as a formula" % cgi.escape(answer)) - return tuple(out) + return out def randomize_variables(self, samples): """ diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index f0f36e7ef8..6c868cb621 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -16,6 +16,8 @@ from xmodule.x_module import XModule from xmodule.xml_module import XmlDescriptor from xblock.core import Scope, String, Integer, Boolean, Dict, List +from capa.responsetypes import FormulaResponse, StudentInputError + from django.utils.html import escape log = logging.getLogger(__name__) @@ -37,6 +39,14 @@ class CrowdsourceHinterFields(object): mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content, default={}) hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0) + # signature_to_ans maps an answer signature to an answer string that shows that answer in a + # human-readable form. + signature_to_ans = Dict(help='Maps a signature to a representative formula.', scope=Scope.content, + default={}) + # A list of dictionaries, each of which represents an n-dimenstional point that we plug into + # formulas. Each dictionary maps variables to values, eg {'x': 5.1}. + formula_test_values = List(help='The values that we plug into formula responses', scope=Scope.content, + default=[]) # A list of previous answers this student made to this problem. # Of the form [answer, [hint_pk_1, hint_pk_2, hint_pk_3]] for each problem. hint_pk's are # None if the hint was not given. @@ -68,6 +78,15 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): def __init__(self, *args, **kwargs): XModule.__init__(self, *args, **kwargs) + # We need to know whether we are working with a FormulaResponse problem. + self.is_formula = (type(self.get_display_items()[0].lcp.responders.values()[0]) == FormulaResponse) + if self.is_formula: + self.answer_to_str = self.formula_answer_to_str + self.answer_signature = self.formula_answer_signature + else: + self.answer_to_str = self.numerical_answer_to_str + # Right now, numerical problems don't need special answer signature treatment. + self.answer_signature = lambda x: x def get_html(self): """ @@ -98,15 +117,45 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): return out - def capa_answer_to_str(self, answer): + def numerical_answer_to_str(self, answer): """ - Converts capa answer format to a string representation + Converts capa numerical answer format to a string representation of the answer. -Lon-capa dependent. -Assumes that the problem only has one part. """ return str(float(answer.values()[0])) + def formula_answer_to_str(self, answer): + """ + Converts capa formula answer into a string. + -Lon-capa dependent. + -Assumes that the problem only has one part. + """ + return str(answer.values()[0]) + + def formula_answer_signature(self, answer): + """ + Converts a capa answer string (output of formula_answer_to_str) + to a string unique to each formula equality class. + So, x^2 and x*x would have the same signature, which would differ + from the signature of 2*x^2. + """ + responder = self.get_display_items()[0].lcp.responders.values()[0] + if self.formula_test_values == []: + # Make a set of test values, and save them. + self.formula_test_values = responder.randomize_variables(responder.samples) + try: + # TODO, maybe: add some rounding to signature generation, so that floating point + # errors don't make a difference. + out = str(responder.hash_answers(answer, self.formula_test_values)) + except StudentInputError: + # I'm not sure what's the best thing to do here. + # I'll return the empty string, for now. + # That way, all invalid hints are clustered together. + return '' + return out + def handle_ajax(self, dispatch, data): """ This is the landing method for AJAX calls. @@ -134,44 +183,46 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Called by hinter javascript after a problem is graded as incorrect. Args: - `data` -- must be interpretable by capa_answer_to_str. + `data` -- must be interpretable by answer_to_str. Output keys: - 'best_hint' is the hint text with the most votes. - 'rand_hint_1' and 'rand_hint_2' are two random hints to the answer in `data`. - 'answer' is the parsed answer that was submitted. """ try: - answer = self.capa_answer_to_str(data) + answer = self.answer_to_str(data) except ValueError: # Sometimes, we get an answer that's just not parsable. Do nothing. log.exception('Answer not parsable: ' + str(data)) return + # Make a signature of the answer, for formula responses. + signature = self.answer_signature(answer) # Look for a hint to give. # Make a local copy of self.hints - this means we only need to do one json unpacking. # (This is because xblocks storage makes the following command a deep copy.) local_hints = self.hints - if (answer not in local_hints) or (len(local_hints[answer]) == 0): + if (signature not in local_hints) or (len(local_hints[signature]) == 0): # No hints to give. Return. self.previous_answers += [[answer, [None, None, None]]] return # Get the top hint, plus two random hints. - n_hints = len(local_hints[answer]) - best_hint_index = max(local_hints[answer], key=lambda key: local_hints[answer][key][1]) - best_hint = local_hints[answer][best_hint_index][0] - if len(local_hints[answer]) == 1: + n_hints = len(local_hints[signature]) + best_hint_index = max(local_hints[signature], key=lambda key: local_hints[signature][key][1]) + best_hint = local_hints[signature][best_hint_index][0] + if len(local_hints[signature]) == 1: rand_hint_1 = '' rand_hint_2 = '' self.previous_answers += [[answer, [best_hint_index, None, None]]] elif n_hints == 2: - best_hint = local_hints[answer].values()[0][0] - best_hint_index = local_hints[answer].keys()[0] - rand_hint_1 = local_hints[answer].values()[1][0] - hint_index_1 = local_hints[answer].keys()[1] + best_hint = local_hints[signature].values()[0][0] + best_hint_index = local_hints[signature].keys()[0] + rand_hint_1 = local_hints[signature].values()[1][0] + hint_index_1 = local_hints[signature].keys()[1] rand_hint_2 = '' self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] else: (hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\ - random.sample(local_hints[answer].items(), 2) + random.sample(local_hints[signature].items(), 2) rand_hint_1 = rand_hint_1[0] rand_hint_2 = rand_hint_2[0] self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]] @@ -206,12 +257,13 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): answer, hints_offered = self.previous_answers[i] if answer not in answer_to_hints: answer_to_hints[answer] = {} - if answer in self.hints: + signature = self.answer_signature(answer) + if signature in self.hints: # Go through each hint, and add to index_to_hints for hint_id in hints_offered: - if (hint_id is not None) and (hint_id not in answer_to_hints[answer]): + if (hint_id is not None) and (hint_id not in answer_to_hints[signature]): try: - answer_to_hints[answer][hint_id] = self.hints[answer][str(hint_id)][0] + answer_to_hints[answer][hint_id] = self.hints[signature][str(hint_id)][0] except KeyError: # Sometimes, the hint that a user saw will have been deleted by the instructor. continue @@ -234,11 +286,12 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if self.user_voted: return json.dumps({'contents': 'Sorry, but you have already voted!'}) ans = data['answer'] + signature = self.answer_signature(ans) hint_pk = str(data['hint']) pk_list = json.loads(data['pk_list']) # We use temp_dict because we need to do a direct write for the database to update. temp_dict = self.hints - temp_dict[ans][hint_pk][1] += 1 + temp_dict[signature][hint_pk][1] += 1 self.hints = temp_dict # Don't let the user vote again! self.user_voted = True @@ -246,7 +299,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Return a list of how many votes each hint got. hint_and_votes = [] for vote_pk in pk_list: - hint_and_votes.append(temp_dict[ans][str(vote_pk)]) + hint_and_votes.append(temp_dict[signature][str(vote_pk)]) hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) # Reset self.previous_answers. @@ -266,6 +319,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Do html escaping. Perhaps in the future do profanity filtering, etc. as well. hint = escape(data['hint']) answer = data['answer'] + signature = self.answer_signature(answer) # Only allow a student to vote or submit a hint once. if self.user_voted: return {'message': 'Sorry, but you have already voted!'} @@ -276,9 +330,15 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): else: temp_dict = self.hints if answer in temp_dict: - temp_dict[answer][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself). + temp_dict[signature][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself). else: - temp_dict[answer] = {str(self.hint_pk): [hint, 1]} + temp_dict[signature] = {str(self.hint_pk): [hint, 1]} + # Add the signature to signature_to_ans, if it's not there yet. + # This allows instructors to see a human-readable answer that corresponds to each signature. + if answer not in self.signature_to_ans: + local_sta = self.signature_to_ans + local_sta[signature] = answer + self.signature_to_ans = local_sta self.hint_pk += 1 if self.moderate == 'True': self.mod_queue = temp_dict From 199b6325137e7cef6f36e1be3c9113680b5d0eb1 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 11 Jul 2013 17:15:01 -0400 Subject: [PATCH 163/244] Crowdsourced hinter now supports formula responses. Tests still broken. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 33 ++++-- .../js/src/crowdsource_hinter/display.coffee | 9 +- common/templates/hinter_display.html | 15 ++- lms/djangoapps/instructor/hint_manager.py | 103 ++++++++++++------ .../hint_manager.html | 2 +- .../hint_manager_inner.html | 6 +- 6 files changed, 116 insertions(+), 52 deletions(-) rename lms/templates/{courseware => instructor}/hint_manager.html (98%) rename lms/templates/{courseware => instructor}/hint_manager_inner.html (90%) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 6c868cb621..bac6982085 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -13,7 +13,7 @@ from pkg_resources import resource_string from lxml import etree from xmodule.x_module import XModule -from xmodule.xml_module import XmlDescriptor +from xmodule.raw_module import RawDescriptor from xblock.core import Scope, String, Integer, Boolean, Dict, List from capa.responsetypes import FormulaResponse, StudentInputError @@ -150,10 +150,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # errors don't make a difference. out = str(responder.hash_answers(answer, self.formula_test_values)) except StudentInputError: - # I'm not sure what's the best thing to do here. - # I'll return the empty string, for now. - # That way, all invalid hints are clustered together. - return '' + # I'm not sure what's the best thing to do here. I'm returning + # None, for now, so that the calling function has a chance to catch + # the error without having to import StudentInputError. + return None return out def handle_ajax(self, dispatch, data): @@ -197,6 +197,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): return # Make a signature of the answer, for formula responses. signature = self.answer_signature(answer) + if signature == None: + # Sometimes, signature conversion may fail. + log.exception('Signature conversion failed: ' + str(answer)) + return # Look for a hint to give. # Make a local copy of self.hints - this means we only need to do one json unpacking. # (This is because xblocks storage makes the following command a deep copy.) @@ -261,7 +265,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if signature in self.hints: # Go through each hint, and add to index_to_hints for hint_id in hints_offered: - if (hint_id is not None) and (hint_id not in answer_to_hints[signature]): + if (hint_id is not None) and (hint_id not in answer_to_hints[answer]): try: answer_to_hints[answer][hint_id] = self.hints[signature][str(hint_id)][0] except KeyError: @@ -335,10 +339,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): temp_dict[signature] = {str(self.hint_pk): [hint, 1]} # Add the signature to signature_to_ans, if it's not there yet. # This allows instructors to see a human-readable answer that corresponds to each signature. - if answer not in self.signature_to_ans: - local_sta = self.signature_to_ans - local_sta[signature] = answer - self.signature_to_ans = local_sta + self.add_signature(signature, answer) self.hint_pk += 1 if self.moderate == 'True': self.mod_queue = temp_dict @@ -349,8 +350,18 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): self.previous_answers = [] return {'message': 'Thank you for your hint!'} + def add_signature(self, signature, answer): + """ + Add a signature to self.signature_to_ans. If the signature already + exists, do nothing. + """ + if signature not in self.signature_to_ans: + local_sta = self.signature_to_ans + local_sta[signature] = answer + self.signature_to_ans = local_sta -class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, XmlDescriptor): + +class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, RawDescriptor): module_class = CrowdsourceHinterModule stores_state = True diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index 3df970a618..2e4d0e2c3b 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -48,8 +48,8 @@ class @Hinter vote: (eventObj) => target = @$(eventObj.currentTarget) - parent_div_selector = '#previous-answer-' + @jq_escape(target.attr('data-answer')) - all_pks = @$(parent_div_selector).attr('data-all-pks') + parent_div = $('.previous-answer[data-answer="'+target.attr('data-answer')+'"]') + all_pks = parent_div.attr('data-all-pks') console.debug(all_pks) post_json = {'answer': target.attr('data-answer'), 'hint': target.data('hintno'), 'pk_list': all_pks} $.postWithPrefix "#{@url}/vote", post_json, (response) => @@ -57,8 +57,9 @@ class @Hinter submit_hint: (eventObj) => target = @$(eventObj.currentTarget) - textarea_id = '#custom-hint-' + @jq_escape(target.attr('data-answer')) - post_json = {'answer': target.attr('data-answer'), 'hint': @$(textarea_id).val()} + textarea = $('.custom-hint[data-answer="'+target.attr('data-answer')+'"]') + console.debug(textarea) + post_json = {'answer': target.attr('data-answer'), 'hint': @$(textarea).val()} $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => @render(response.contents) diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index d349a17fc5..4f40113265 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -23,10 +23,19 @@ Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below:

    + <% + def unspace(string): + """ + HTML id's can't have spaces in them. This little function + removes spaces. + """ + return ''.join(string.split()) + %> +
    @@ -35,7 +44,7 @@ import json all_pks = json.dumps(pk_dict.keys()) %> -
    +
    % if len(pk_dict) > 0:

    @@ -54,7 +63,7 @@

    What hint would you give a student who made the same mistake you did? Please don't give away the answer.

    - diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 899e386f70..b20cdc81e6 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -10,11 +10,14 @@ import re from django.http import HttpResponse, Http404 from django_future.csrf import ensure_csrf_cookie +from django.core.exceptions import ObjectDoesNotExist from mitxmako.shortcuts import render_to_response, render_to_string from courseware.courses import get_course_with_access from courseware.models import XModuleContentField +from courseware.module_render import get_module +from courseware.model_data import ModelDataCache from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -28,24 +31,29 @@ def hint_manager(request, course_id): return HttpResponse(out) if request.method == 'GET': out = get_hints(request, course_id, 'mod_queue') - return render_to_response('courseware/hint_manager.html', out) + out.update({'error': ''}) + return render_to_response('instructor/hint_manager.html', out) field = request.POST['field'] if not (field == 'mod_queue' or field == 'hints'): # Invalid field. (Don't let users continue - they may overwrite other db's) out = 'Error in hint manager - an invalid field was accessed.' return HttpResponse(out) - if request.POST['op'] == 'delete hints': - delete_hints(request, course_id, field) - if request.POST['op'] == 'switch fields': - pass - if request.POST['op'] == 'change votes': - change_votes(request, course_id, field) - if request.POST['op'] == 'add hint': - add_hint(request, course_id, field) - if request.POST['op'] == 'approve': - approve(request, course_id, field) - rendered_html = render_to_string('courseware/hint_manager_inner.html', get_hints(request, course_id, field)) + switch_dict = { + 'delete hints': delete_hints, + 'switch fields': lambda *args: None, # Takes any number of arguments, returns None. + 'change votes': change_votes, + 'add hint': add_hint, + 'approve': approve, + } + + # Do the operation requested, and collect any error messages. + error_text = switch_dict[request.POST['op']](request, course_id, field) + if error_text is None: + error_text = '' + render_dict = get_hints(request, course_id, field) + render_dict.update({'error': error_text}) + rendered_html = render_to_string('instructor/hint_manager_inner.html', render_dict) return HttpResponse(json.dumps({'success': True, 'contents': rendered_html})) @@ -106,8 +114,27 @@ def get_hints(request, course_id, field): # Put all non-numerical answers first. return float('-inf') - # Answer list contains [answer, dict_of_hints] pairs. - answer_list = sorted(json.loads(hints_by_problem.value).items(), key=answer_sorter) + # Find the signature to answer converter for this problem. Sometimes, + # it doesn't exist; just assume that the signatures are the answers. + try: + signature_to_ans = XModuleContentField.objects.get( + field_name='signature_to_ans', + definition_id__regex=chopped_id + ) + signature_to_ans = json.loads(signature_to_ans.value) + except ObjectDoesNotExist: + signature_to_ans = {} + + signatures_dict = json.loads(hints_by_problem.value) + unsorted = [] + for signature, dict_of_hints in signatures_dict.items(): + if signature in signature_to_ans: + ans_txt = signature_to_ans[signature] + else: + ans_txt = signature + unsorted.append([signature, ans_txt, dict_of_hints]) + # Answer list contains [signature, answer, dict_of_hints] sub-lists. + answer_list = sorted(unsorted, key=answer_sorter) big_out_dict[hints_by_problem.definition_id] = answer_list render_dict = {'field': field, @@ -138,7 +165,7 @@ def delete_hints(request, course_id, field): Deletes the hints specified. `request.POST` contains some fields keyed by integers. Each such field contains a - [problem_defn_id, answer, pk] tuple. These tuples specify the hints to be deleted. + [problem_defn_id, signature, pk] tuple. These tuples specify the hints to be deleted. Example `request.POST`: {'op': 'delete_hints', @@ -150,12 +177,12 @@ def delete_hints(request, course_id, field): for key in request.POST: if key == 'op' or key == 'field': continue - problem_id, answer, pk = request.POST.getlist(key) + problem_id, signature, pk = request.POST.getlist(key) # Can be optimized - sort the delete list by problem_id, and load each problem # from the database only once. this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) problem_dict = json.loads(this_problem.value) - del problem_dict[answer][pk] + del problem_dict[signature][pk] this_problem.value = json.dumps(problem_dict) this_problem.save() @@ -164,18 +191,18 @@ def change_votes(request, course_id, field): """ Updates the number of votes. - The numbered fields of `request.POST` contain [problem_id, answer, pk, new_votes] tuples. + The numbered fields of `request.POST` contain [problem_id, signature, pk, new_votes] tuples. - Very similar to `delete_hints`. Is there a way to merge them? Nah, too complicated. """ for key in request.POST: if key == 'op' or key == 'field': continue - problem_id, answer, pk, new_votes = request.POST.getlist(key) + problem_id, signature, pk, new_votes = request.POST.getlist(key) this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) problem_dict = json.loads(this_problem.value) - # problem_dict[answer][pk] points to a [hint_text, #votes] pair. - problem_dict[answer][pk][1] = int(new_votes) + # problem_dict[signature][pk] points to a [hint_text, #votes] pair. + problem_dict[signature][pk][1] = int(new_votes) this_problem.value = json.dumps(problem_dict) this_problem.save() @@ -187,6 +214,7 @@ def add_hint(request, course_id, field): field problem - The problem id answer - The answer to which a hint will be added + - Needs to be converted into signature first. hint - The text of the hint """ @@ -200,10 +228,23 @@ def add_hint(request, course_id, field): hint_pk_entry.value = this_pk + 1 hint_pk_entry.save() + # Make signature. This is really annoying, but I don't see + # any alternative :( + loc = Location(problem_id) + descriptors = modulestore().get_items(loc) + m_d_c = ModelDataCache(descriptors, course_id, request.user) + hinter_module = get_module(request.user, request, loc, m_d_c, course_id) + signature = hinter_module.answer_signature(answer) + if signature is None: + # Signature generation failed. + # We should probably return an error message, too... working on that. + return 'Error - your answer could not be parsed as a formula expression.' + hinter_module.add_signature(signature, answer) + problem_dict = json.loads(this_problem.value) - if answer not in problem_dict: - problem_dict[answer] = {} - problem_dict[answer][this_pk] = [hint_text, 1] + if signature not in problem_dict: + problem_dict[signature] = {} + problem_dict[signature][this_pk] = [hint_text, 1] this_problem.value = json.dumps(problem_dict) this_problem.save() @@ -213,26 +254,26 @@ def approve(request, course_id, field): Approve a list of hints, moving them from the mod_queue to the real hint list. POST: op, field - (some number) -> [problem, answer, pk] + (some number) -> [problem, signature, pk] """ for key in request.POST: if key == 'op' or key == 'field': continue - problem_id, answer, pk = request.POST.getlist(key) + problem_id, signature, pk = request.POST.getlist(key) # Can be optimized - sort the delete list by problem_id, and load each problem # from the database only once. problem_in_mod = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) problem_dict = json.loads(problem_in_mod.value) - hint_to_move = problem_dict[answer][pk] - del problem_dict[answer][pk] + hint_to_move = problem_dict[signature][pk] + del problem_dict[signature][pk] problem_in_mod.value = json.dumps(problem_dict) problem_in_mod.save() problem_in_hints = XModuleContentField.objects.get(field_name='hints', definition_id=problem_id) problem_dict = json.loads(problem_in_hints.value) - if answer not in problem_dict: - problem_dict[answer] = {} - problem_dict[answer][pk] = hint_to_move + if signature not in problem_dict: + problem_dict[signature] = {} + problem_dict[signature][pk] = hint_to_move problem_in_hints.value = json.dumps(problem_dict) problem_in_hints.save() diff --git a/lms/templates/courseware/hint_manager.html b/lms/templates/instructor/hint_manager.html similarity index 98% rename from lms/templates/courseware/hint_manager.html rename to lms/templates/instructor/hint_manager.html index ebd7091a09..4a6e219560 100644 --- a/lms/templates/courseware/hint_manager.html +++ b/lms/templates/instructor/hint_manager.html @@ -1,6 +1,6 @@ <%inherit file="/main.html" /> <%namespace name='static' file='/static_content.html'/> -<%namespace name="content" file="/courseware/hint_manager_inner.html"/> +<%namespace name="content" file="/instructor/hint_manager_inner.html"/> <%block name="headextra"> diff --git a/lms/templates/courseware/hint_manager_inner.html b/lms/templates/instructor/hint_manager_inner.html similarity index 90% rename from lms/templates/courseware/hint_manager_inner.html rename to lms/templates/instructor/hint_manager_inner.html index c69539522f..3acf8102ac 100644 --- a/lms/templates/courseware/hint_manager_inner.html +++ b/lms/templates/instructor/hint_manager_inner.html @@ -4,15 +4,16 @@

    ${field_label}

    Switch to ${other_field_label} +

    ${error}

    % for definition_id in all_hints:

    Problem: ${id_to_name[definition_id]}

    - % for answer, hint_dict in all_hints[definition_id]: + % for signature, answer, hint_dict in all_hints[definition_id]: % if len(hint_dict) > 0:

    Answer: ${answer}

    % endif % for pk, hint in hint_dict.items(): -

    +

    ${hint[0]}
    Votes: @@ -36,6 +37,7 @@ Switch to ${other_field_label
    % endfor +

    ${error}

    % if field == 'mod_queue': From a730f9189bc32558995937ef72aae687a3f9c8be Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Mon, 15 Jul 2013 11:25:59 -0400 Subject: [PATCH 164/244] Fixed numerous 500 errors that result from mal-formatted post requests. Fixed tests to work with symbolic answer changes. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 24 +++- .../xmodule/tests/test_crowdsource_hinter.py | 135 +++++++++++++++++- common/templates/hinter_display.html | 4 + 3 files changed, 158 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index bac6982085..5c5c869088 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -173,6 +173,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if out is None: out = {'op': 'empty'} + elif 'error' in out: + # Error in processing. + out.update({'op': 'error'}) else: out.update({'op': dispatch}) return json.dumps({'contents': self.system.render_template('hinter_display.html', out)}) @@ -288,14 +291,23 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Returns key 'hint_and_votes', a list of (hint_text, #votes) pairs. """ if self.user_voted: - return json.dumps({'contents': 'Sorry, but you have already voted!'}) + return {'error': 'Sorry, but you have already voted!'} ans = data['answer'] signature = self.answer_signature(ans) + if signature is None: + # Uh oh. Invalid answer. + log.exception('Failure in hinter tally_vote: Unable to parse answer: ' + ans) + return {'error': 'Failure in voting!'} hint_pk = str(data['hint']) pk_list = json.loads(data['pk_list']) # We use temp_dict because we need to do a direct write for the database to update. temp_dict = self.hints - temp_dict[signature][hint_pk][1] += 1 + try: + temp_dict[signature][hint_pk][1] += 1 + except KeyError: + log.exception('Failure in hinter tally_vote: User voted for non-existant hint: Answer=' + + ans + ' pk=' + hint_pk) + return {'error': 'Failure in voting!'} self.hints = temp_dict # Don't let the user vote again! self.user_voted = True @@ -303,7 +315,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Return a list of how many votes each hint got. hint_and_votes = [] for vote_pk in pk_list: - hint_and_votes.append(temp_dict[signature][str(vote_pk)]) + try: + hint_and_votes.append(temp_dict[signature][str(vote_pk)]) + except KeyError: + log.exception('In hinter tally_vote: pk_list contains non-existant pk: ' + str(vote_pk)) hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) # Reset self.previous_answers. @@ -324,6 +339,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): hint = escape(data['hint']) answer = data['answer'] signature = self.answer_signature(answer) + if signature is None: + log.exception('Failure in hinter submit_hint: Unable to parse answer: ' + answer) + return {'error': 'Could not submit answer'} # Only allow a student to vote or submit a hint once. if self.user_voted: return {'message': 'Sorry, but you have already voted!'} diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index 989b264694..b8a205e90e 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -2,13 +2,15 @@ Tests the crowdsourced hinter xmodule. """ -from mock import Mock +from mock import Mock, MagicMock import unittest import copy from xmodule.crowdsource_hinter import CrowdsourceHinterModule from xmodule.vertical_module import VerticalModule, VerticalDescriptor +from capa.responsetypes import StudentInputError + from . import get_test_system import json @@ -94,12 +96,54 @@ class CHModuleFactory(object): if moderate is not None: model_data['moderate'] = moderate - descriptor = Mock(weight="1") + descriptor = Mock(weight='1') + # Make the descriptor have a capa problem child. + capa_descriptor = MagicMock() + capa_descriptor.name = 'capa' + descriptor.get_children = lambda: [capa_descriptor] + + # Make a fake capa module. + capa_module = MagicMock() + capa_module.responders = {'responder0': MagicMock()} + capa_module.displayable_items = lambda: [capa_module] + system = get_test_system() + # Make the system have a marginally-functional get_module + + def fake_get_module(descriptor): + """ + A fake module-maker. + """ + if descriptor.name == 'capa': + return capa_module + system.get_module = fake_get_module + module = CrowdsourceHinterModule(system, descriptor, model_data) return module + @staticmethod + def setup_formula_response(module): + """ + Adds additional mock methods to the module, so that we can test + formula responses. + """ + # Do a bunch of monkey patching, to mock the lon-capa problem. + responder = MagicMock() + responder.randomize_variables = MagicMock(return_value=4) + + def fake_hash_answers(answer, test_values): + """ A fake answer hasher """ + if test_values == 4 and answer == 'x*y^2': + return 'good' + raise StudentInputError + + responder.hash_answers = fake_hash_answers + lcp = MagicMock() + lcp.responders = {'responder0': responder} + module.get_display_items()[0].lcp = lcp + return module + class VerticalWithModulesFactory(object): """ @@ -226,6 +270,44 @@ class CrowdsourceHinterTest(unittest.TestCase): self.assertTrue('Test numerical problem.' in out_html) self.assertTrue('Another test numerical problem.' in out_html) + def test_numerical_answer_to_str(self): + """ + Tests the get request to string converter for numerical responses. + """ + mock_module = CHModuleFactory.create() + get = {'response1': '4'} + parsed = mock_module.numerical_answer_to_str(get) + self.assertTrue(parsed == '4.0') + + def test_formula_answer_to_str(self): + """ + Tests the get request to string converter for formula responses. + """ + mock_module = CHModuleFactory.create() + get = {'response1': 'x*y^2'} + parsed = mock_module.formula_answer_to_str(get) + self.assertTrue(parsed == 'x*y^2') + + def test_formula_answer_signature(self): + """ + Tests the answer signature generator for formula responses. + """ + mock_module = CHModuleFactory.create() + mock_module = CHModuleFactory.setup_formula_response(mock_module) + answer = 'x*y^2' + out = mock_module.formula_answer_signature(answer) + self.assertTrue(out == 'good') + + def test_formula_answer_signature_failure(self): + """ + Makes sure that bad answer strings return None as a signature. + """ + mock_module = CHModuleFactory.create() + mock_module = CHModuleFactory.setup_formula_response(mock_module) + answer = 'fish' + out = mock_module.formula_answer_signature(answer) + self.assertTrue(out is None) + def test_gethint_0hint(self): """ Someone asks for a hint, when there's no hint to give. @@ -343,6 +425,44 @@ class CrowdsourceHinterTest(unittest.TestCase): self.assertTrue(['Best hint', 40] in dict_out['hint_and_votes']) self.assertTrue(['Another hint', 31] in dict_out['hint_and_votes']) + def test_vote_unparsable(self): + """ + A user somehow votes for an unparsable answer. + Should return a friendly error. + (This is an unusual exception path - I don't know how it occurs, + except if you manually make a post request. But, it seems to happen + occasionally.) + """ + mock_module = CHModuleFactory.create() + # None means that the answer couldn't be parsed. + mock_module.answer_signature = lambda text: None + json_in = {'answer': 'fish', 'hint': 3, 'pk_list': '[]'} + dict_out = mock_module.tally_vote(json_in) + print dict_out + self.assertTrue(dict_out == {'error': 'Failure in voting!'}) + + def test_vote_nohint(self): + """ + A user somehow votes for a hint that doesn't exist. + Should return a friendly error. + """ + mock_module = CHModuleFactory.create() + json_in = {'answer': '24.0', 'hint': '25', 'pk_list': '[]'} + dict_out = mock_module.tally_vote(json_in) + self.assertTrue(dict_out == {'error': 'Failure in voting!'}) + + def test_vote_badpklist(self): + """ + Some of the pk's specified in pk_list are invalid. + Should just skip those. + """ + mock_module = CHModuleFactory.create() + json_in = {'answer': '24.0', 'hint': '0', 'pk_list': json.dumps([0, 12])} + hint_and_votes = mock_module.tally_vote(json_in)['hint_and_votes'] + self.assertTrue(['Best hint', 41] in hint_and_votes) + self.assertTrue(len(hint_and_votes) == 1) + + def test_submithint_nopermission(self): """ A user tries to submit a hint, but he has already voted. @@ -399,6 +519,17 @@ class CrowdsourceHinterTest(unittest.TestCase): mock_module.submit_hint(json_in) self.assertTrue(mock_module.hints['29.0']['0'][0] == u'<script> alert("Trololo"); </script>') + def test_submithint_unparsable(self): + mock_module = CHModuleFactory.create() + mock_module.answer_signature = lambda text: None + json_in = {'answer': 'fish', 'hint': 'A hint'} + dict_out = mock_module.submit_hint(json_in) + print dict_out + print mock_module.hints + self.assertTrue('error' in dict_out) + self.assertTrue(None not in mock_module.hints) + self.assertTrue('fish' not in mock_module.hints) + def test_template_gethint(self): """ Test the templates for get_hint. diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index 4f40113265..3ca1ce5330 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -137,6 +137,10 @@ What would you say to help someone who got this wrong answer? ${simple_message()} % endif +% if op == "error": + ${error} +% endif + % if op == "vote": ${show_votes()} % endif From 6b40c5cf132164d9615e353f04034285af9eece4 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Mon, 15 Jul 2013 15:59:58 -0400 Subject: [PATCH 165/244] Changed hint voting ui to show all hints on one page. Fixed broken tests for hint manager. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 45 ++++++++--- .../js/src/crowdsource_hinter/display.coffee | 5 +- .../xmodule/tests/test_crowdsource_hinter.py | 26 +++++- common/templates/hinter_display.html | 80 ++++++++++++------- lms/djangoapps/instructor/hint_manager.py | 8 +- .../instructor/tests/test_hint_manager.py | 23 +++++- 6 files changed, 132 insertions(+), 55 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 5c5c869088..b82241bd08 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -18,6 +18,9 @@ from xblock.core import Scope, String, Integer, Boolean, Dict, List from capa.responsetypes import FormulaResponse, StudentInputError +from calc import evaluator, UndefinedVariable +from pyparsing import ParseException + from django.utils.html import escape log = logging.getLogger(__name__) @@ -85,8 +88,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): self.answer_signature = self.formula_answer_signature else: self.answer_to_str = self.numerical_answer_to_str - # Right now, numerical problems don't need special answer signature treatment. - self.answer_signature = lambda x: x + self.answer_signature = self.numerical_answer_signature def get_html(self): """ @@ -134,6 +136,17 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): """ return str(answer.values()[0]) + def numerical_answer_signature(self, answer): + """ + Runs the answer string through the evaluator. (This is because + symbolic expressions like sin(pi/12)*3 are allowed.) + """ + try: + out = str(evaluator(dict(), dict(), answer)) + except (UndefinedVariable, ParseException): + out = None + return out + def formula_answer_signature(self, answer): """ Converts a capa answer string (output of formula_answer_to_str) @@ -200,7 +213,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): return # Make a signature of the answer, for formula responses. signature = self.answer_signature(answer) - if signature == None: + if signature is None: # Sometimes, signature conversion may fail. log.exception('Signature conversion failed: ' + str(answer)) return @@ -258,13 +271,21 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # be allowed to make one vote / submission, but he can choose which wrong answer # he wants to look at. answer_to_hints = {} # answer_to_hints[answer text][hint pk] -> hint text + signature_to_ans = {} # Lets us combine equivalent answers + # Same mapping as the field, but local. # Go through each previous answer, and populate index_to_hints and index_to_answer. for i in xrange(len(self.previous_answers)): answer, hints_offered = self.previous_answers[i] + # Does this answer equal one of the ones offered already? + signature = self.answer_signature(answer) + if signature in signature_to_ans: + # Re-assign this answer text to the one we've seen already. + answer = signature_to_ans[signature] + else: + signature_to_ans[signature] = answer if answer not in answer_to_hints: answer_to_hints[answer] = {} - signature = self.answer_signature(answer) if signature in self.hints: # Go through each hint, and add to index_to_hints for hint_id in hints_offered: @@ -285,9 +306,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): `data` -- expected to have the following keys: 'answer': text of answer we're voting on 'hint': hint_pk - 'pk_list': We will return a list of how many votes each hint has so far. + 'pk_list': A list of [answer, pk] pairs, each of which representing a hint. + We will return a list of how many votes each hint in the list has so far. It's up to the browser to specify which hints to return vote counts for. - Every pk listed here will have a hint count returned. + Returns key 'hint_and_votes', a list of (hint_text, #votes) pairs. """ if self.user_voted: @@ -299,7 +321,6 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): log.exception('Failure in hinter tally_vote: Unable to parse answer: ' + ans) return {'error': 'Failure in voting!'} hint_pk = str(data['hint']) - pk_list = json.loads(data['pk_list']) # We use temp_dict because we need to do a direct write for the database to update. temp_dict = self.hints try: @@ -313,12 +334,18 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): self.user_voted = True # Return a list of how many votes each hint got. + pk_list = json.loads(data['pk_list']) hint_and_votes = [] - for vote_pk in pk_list: + for answer, vote_pk in pk_list: + signature = self.answer_signature(answer) + if signature is None: + log.exception('In hinter tally_vote, couldn\'t parse ' + answer) + continue try: hint_and_votes.append(temp_dict[signature][str(vote_pk)]) except KeyError: - log.exception('In hinter tally_vote: pk_list contains non-existant pk: ' + str(vote_pk)) + log.exception('In hinter tally_vote, couldn\'t find: ' + + answer + ', ' + str(vote_pk)) hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) # Reset self.previous_answers. diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index 2e4d0e2c3b..db7ccfaba0 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -48,9 +48,7 @@ class @Hinter vote: (eventObj) => target = @$(eventObj.currentTarget) - parent_div = $('.previous-answer[data-answer="'+target.attr('data-answer')+'"]') - all_pks = parent_div.attr('data-all-pks') - console.debug(all_pks) + all_pks = @$('#pk-list').attr('data-pk-list') post_json = {'answer': target.attr('data-answer'), 'hint': target.data('hintno'), 'pk_list': all_pks} $.postWithPrefix "#{@url}/vote", post_json, (response) => @render(response.contents) @@ -58,7 +56,6 @@ class @Hinter submit_hint: (eventObj) => target = @$(eventObj.currentTarget) textarea = $('.custom-hint[data-answer="'+target.attr('data-answer')+'"]') - console.debug(textarea) post_json = {'answer': target.attr('data-answer'), 'hint': @$(textarea).val()} $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => @render(response.contents) diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index b8a205e90e..844c73a990 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -288,6 +288,26 @@ class CrowdsourceHinterTest(unittest.TestCase): parsed = mock_module.formula_answer_to_str(get) self.assertTrue(parsed == 'x*y^2') + def test_numerical_answer_signature(self): + """ + Tests answer signature generator for numerical responses. + """ + mock_module = CHModuleFactory.create() + answer = '4*5+3' + signature = mock_module.numerical_answer_signature(answer) + print signature + self.assertTrue(signature == '23.0') + + def test_numerical_answer_signature_failure(self): + """ + Makes sure that unparsable numerical answers return None. + """ + mock_module = CHModuleFactory.create() + answer = 'fish' + signature = mock_module.numerical_answer_signature(answer) + print signature + self.assertTrue(signature is None) + def test_formula_answer_signature(self): """ Tests the answer signature generator for formula responses. @@ -406,7 +426,7 @@ class CrowdsourceHinterTest(unittest.TestCase): Should not change any vote tallies. """ mock_module = CHModuleFactory.create(user_voted=True) - json_in = {'answer': '24.0', 'hint': 1, 'pk_list': json.dumps([1, 3])} + json_in = {'answer': '24.0', 'hint': 1, 'pk_list': json.dumps([['24.0', 1], ['24.0', 3]])} old_hints = copy.deepcopy(mock_module.hints) mock_module.tally_vote(json_in) self.assertTrue(mock_module.hints == old_hints) @@ -418,7 +438,7 @@ class CrowdsourceHinterTest(unittest.TestCase): """ mock_module = CHModuleFactory.create( previous_answers=[['24.0', [0, 3, None]]]) - json_in = {'answer': '24.0', 'hint': 3, 'pk_list': json.dumps([0, 3])} + json_in = {'answer': '24.0', 'hint': 3, 'pk_list': json.dumps([['24.0', 0], ['24.0', 3]])} dict_out = mock_module.tally_vote(json_in) self.assertTrue(mock_module.hints['24.0']['0'][1] == 40) self.assertTrue(mock_module.hints['24.0']['3'][1] == 31) @@ -457,7 +477,7 @@ class CrowdsourceHinterTest(unittest.TestCase): Should just skip those. """ mock_module = CHModuleFactory.create() - json_in = {'answer': '24.0', 'hint': '0', 'pk_list': json.dumps([0, 12])} + json_in = {'answer': '24.0', 'hint': '0', 'pk_list': json.dumps([['24.0', 0], ['24.0', 12]])} hint_and_votes = mock_module.tally_vote(json_in)['hint_and_votes'] self.assertTrue(['Best hint', 41] in hint_and_votes) self.assertTrue(len(hint_and_votes) == 1) diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index 3ca1ce5330..b13dc83ea2 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -18,11 +18,6 @@ <%def name="get_feedback()"> -

    Participation in the hinting system is strictly optional, and will not influence your grade.

    -

    - Help your classmates by writing hints for this problem. Start by picking one of your previous incorrect answers from below: -

    - <% def unspace(string): """ @@ -30,7 +25,44 @@ removes spaces. """ return ''.join(string.split()) + + # Make a list of all hints shown. (This is fed back to the site as pk_list.) + # At the same time, determine whether any hints were shown at all. + # If the user never saw hints, don't ask him to vote. + import json + hints_exist = False + pk_list = [] + for answer, pk_dict in answer_to_hints.items(): + if len(pk_dict) > 0: + hints_exist = True + for pk, hint_text in pk_dict.items(): + pk_list.append([answer, pk]) + json_pk_list = json.dumps(pk_list) %> + % if hints_exist: +

    + Optional. Help us improve our hints! Which hint was most helpful to you? +

    + + + + % for answer, pk_dict in answer_to_hints.items(): + % for hint_pk, hint_text in pk_dict.items(): +

    + + ${answer}: ${hint_text} +

    + % endfor + % endfor + +

    + Don't like any of the hints above? Please write your own, for one of your previous incorrect answers below: +

    + % else: +

    + Optional. Help us write hints for this problem! Select one of your incorrect answers below: +

    + % endif
      @@ -40,36 +72,22 @@
    % for answer, pk_dict in answer_to_hints.items(): - <% - import json - all_pks = json.dumps(pk_dict.keys()) - %> -
    -
    - % if len(pk_dict) > 0: + <% + import json + all_pks = json.dumps(pk_dict.keys()) + %> +
    +

    - Which hint would be most effective to show a student who also got ${answer}? + What hint would you give a student who made the same mistake you did? Please don't give away the answer.

    - % for hint_pk, hint_text in pk_dict.items(): -

    - - ${hint_text} -

    - % endfor -

    - Don't like any of the hints above? You can also submit your own. -

    - % endif -

    - What hint would you give a student who made the same mistake you did? Please don't give away the answer. -

    - -

    - -
    + +

    + +
    % endfor
    diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index b20cdc81e6..1f0d17ab03 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -16,8 +16,8 @@ from mitxmako.shortcuts import render_to_response, render_to_string from courseware.courses import get_course_with_access from courseware.models import XModuleContentField -from courseware.module_render import get_module -from courseware.model_data import ModelDataCache +import courseware.module_render as module_render +import courseware.model_data as model_data from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -232,8 +232,8 @@ def add_hint(request, course_id, field): # any alternative :( loc = Location(problem_id) descriptors = modulestore().get_items(loc) - m_d_c = ModelDataCache(descriptors, course_id, request.user) - hinter_module = get_module(request.user, request, loc, m_d_c, course_id) + m_d_c = model_data.ModelDataCache(descriptors, course_id, request.user) + hinter_module = module_render.get_module(request.user, request, loc, m_d_c, course_id) signature = hinter_module.answer_signature(answer) if signature is None: # Signature generation failed. diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py index 456f8e0ed8..9e579ab997 100644 --- a/lms/djangoapps/instructor/tests/test_hint_manager.py +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -2,6 +2,7 @@ import json from django.test.client import Client, RequestFactory from django.test.utils import override_settings +from mock import MagicMock from courseware.models import XModuleContentField from courseware.tests.factories import ContentFactory @@ -89,7 +90,7 @@ class HintManagerTest(ModuleStoreTestCase): out = view.get_hints(post, self.course_id, 'mod_queue') print out self.assertTrue(out['other_field'] == 'hints') - expected = {self.problem_id: [(u'2.0', {u'2': [u'Hint 2', 1]})]} + expected = {self.problem_id: [[u'2.0', u'2.0', {u'2': [u'Hint 2', 1]}]]} self.assertTrue(out['all_hints'] == expected) def test_gethints_other(self): @@ -101,9 +102,9 @@ class HintManagerTest(ModuleStoreTestCase): out = view.get_hints(post, self.course_id, 'hints') print out self.assertTrue(out['other_field'] == 'mod_queue') - expected = {self.problem_id: [('1.0', {'1': ['Hint 1', 2], - '3': ['Hint 3', 12]}), - ('2.0', {'4': ['Hint 4', 3]}) + expected = {self.problem_id: [['1.0', '1.0', {'1': ['Hint 1', 2], + '3': ['Hint 3', 12]}], + ['2.0', '2.0', {'4': ['Hint 4', 3]}] ]} self.assertTrue(out['all_hints'] == expected) @@ -137,16 +138,30 @@ class HintManagerTest(ModuleStoreTestCase): """ Check that instructors can add new hints. """ + # Because add_hint accesses the xmodule, this test requires a bunch + # of monkey patching. + import courseware.module_render as module_render + import courseware.model_data as model_data + hinter = MagicMock() + hinter.answer_signature = lambda string: string + module_render.get_module = MagicMock(return_value=hinter) + model_data.ModelDataCache = MagicMock(return_value=None) + request = RequestFactory() post = request.post(self.url, {'field': 'mod_queue', 'op': 'add hint', 'problem': self.problem_id, 'answer': '3.14', 'hint': 'This is a new hint.'}) + post.user = 'fake user' view.add_hint(post, self.course_id, 'mod_queue') problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value self.assertTrue('3.14' in json.loads(problem_hints)) + # Reload the things we monkey-patched. + reload(module_render) + reload(model_data) + def test_approve(self): """ Check that instructors can approve hints. (Move them From 4ee8111cf4a77229404716ad85eeb00995a0fbbc Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Tue, 16 Jul 2013 09:12:35 -0400 Subject: [PATCH 166/244] Fixed monkey-patching persistent state bug. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 4 ++-- .../xmodule/tests/test_crowdsource_hinter.py | 18 +++++++++++++++--- .../instructor/tests/test_hint_manager.py | 16 ++++++---------- 3 files changed, 23 insertions(+), 15 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index b82241bd08..1d7cf954a4 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -126,7 +126,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): -Lon-capa dependent. -Assumes that the problem only has one part. """ - return str(float(answer.values()[0])) + return str(answer.values()[0]) def formula_answer_to_str(self, answer): """ @@ -207,7 +207,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): """ try: answer = self.answer_to_str(data) - except ValueError: + except (ValueError, AttributeError): # Sometimes, we get an answer that's just not parsable. Do nothing. log.exception('Answer not parsable: ' + str(data)) return diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index 844c73a990..cc0634cb7d 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -277,7 +277,7 @@ class CrowdsourceHinterTest(unittest.TestCase): mock_module = CHModuleFactory.create() get = {'response1': '4'} parsed = mock_module.numerical_answer_to_str(get) - self.assertTrue(parsed == '4.0') + self.assertTrue(parsed == '4') def test_formula_answer_to_str(self): """ @@ -342,12 +342,24 @@ class CrowdsourceHinterTest(unittest.TestCase): def test_gethint_unparsable(self): """ - Someone submits a hint that cannot be parsed into a float. + Someone submits an answer that is in the wrong format. - The answer should not be added to previous_answers. """ mock_module = CHModuleFactory.create() old_answers = copy.deepcopy(mock_module.previous_answers) - json_in = {'problem_name': 'fish'} + json_in = 'blah' + out = mock_module.get_hint(json_in) + self.assertTrue(out is None) + self.assertTrue(mock_module.previous_answers == old_answers) + + def test_gethint_signature_error(self): + """ + Someone submits an answer that cannot be calculated as a float. + Nothing should change. + """ + mock_module = CHModuleFactory.create() + old_answers = copy.deepcopy(mock_module.previous_answers) + json_in = {'problem1': 'fish'} out = mock_module.get_hint(json_in) self.assertTrue(out is None) self.assertTrue(mock_module.previous_answers == old_answers) diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py index 9e579ab997..e9387a8eef 100644 --- a/lms/djangoapps/instructor/tests/test_hint_manager.py +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -2,7 +2,7 @@ import json from django.test.client import Client, RequestFactory from django.test.utils import override_settings -from mock import MagicMock +from mock import MagicMock, patch from courseware.models import XModuleContentField from courseware.tests.factories import ContentFactory @@ -12,6 +12,8 @@ from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +import unittest + @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class HintManagerTest(ModuleStoreTestCase): @@ -140,12 +142,8 @@ class HintManagerTest(ModuleStoreTestCase): """ # Because add_hint accesses the xmodule, this test requires a bunch # of monkey patching. - import courseware.module_render as module_render - import courseware.model_data as model_data hinter = MagicMock() hinter.answer_signature = lambda string: string - module_render.get_module = MagicMock(return_value=hinter) - model_data.ModelDataCache = MagicMock(return_value=None) request = RequestFactory() post = request.post(self.url, {'field': 'mod_queue', @@ -154,14 +152,12 @@ class HintManagerTest(ModuleStoreTestCase): 'answer': '3.14', 'hint': 'This is a new hint.'}) post.user = 'fake user' - view.add_hint(post, self.course_id, 'mod_queue') + with patch('courseware.module_render.get_module', MagicMock(return_value=hinter)): + with patch('courseware.model_data.ModelDataCache', MagicMock(return_value=None)): + view.add_hint(post, self.course_id, 'mod_queue') problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value self.assertTrue('3.14' in json.loads(problem_hints)) - # Reload the things we monkey-patched. - reload(module_render) - reload(model_data) - def test_approve(self): """ Check that instructors can approve hints. (Move them From 8ce53ed8eb865f651d755245d554926a3583a7c2 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Wed, 17 Jul 2013 17:00:51 -0400 Subject: [PATCH 167/244] Got rid of answer signatures - these don't work with approximate answers at all. Instead, implemented comparison-based hint matching. Tests are broken, hint manager is probably broken. --- common/lib/capa/capa/responsetypes.py | 39 ++++ .../lib/xmodule/xmodule/crowdsource_hinter.py | 196 ++++++++---------- common/templates/hinter_display.html | 24 +-- 3 files changed, 131 insertions(+), 128 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 47289aad51..5f2408a09e 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -917,6 +917,27 @@ class NumericalResponse(LoncapaResponse): # TODO: add check_hint_condition(self, hxml_set, student_answers) + def answer_compare(self, a, b): + """ + Outside-facing function that lets us compare two numerical answers, + with this problem's tolerance. + """ + return compare_with_tolerance( + evaluator(dict(), dict(), a), + evaluator(dict(), dict(), b), + self.tolerance + ) + + def validate_answer(self, answer): + """ + Returns whether this answer is in a valid form. + """ + try: + evaluator(dict(), dict(), answer) + return True + except StudentInputError: + return False + def get_answers(self): return {self.answer_id: self.correct_answer} @@ -1858,6 +1879,24 @@ class FormulaResponse(LoncapaResponse): return "incorrect" return "correct" + def answer_compare(self, a, b): + """ + An external interface for comparing whether a and b are equal. + """ + internal_result = self.check_formula(a, b, self.samples) + return internal_result == "correct" + + def validate_answer(self, answer): + """ + Returns whether this answer is in a valid form. + """ + var_dict_list = self.randomize_variables(self.samples) + try: + self.hash_answers(answer, var_dict_list) + return True + except StudentInputError: + return False + def strip_dict(self, d): ''' Takes a dict. Returns an identical dict, with all non-word keys and all non-numeric values stripped out. All values also diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 1d7cf954a4..37f244cdba 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -42,18 +42,11 @@ class CrowdsourceHinterFields(object): mod_queue = Dict(help='A dictionary containing hints still awaiting approval', scope=Scope.content, default={}) hint_pk = Integer(help='Used to index hints.', scope=Scope.content, default=0) - # signature_to_ans maps an answer signature to an answer string that shows that answer in a - # human-readable form. - signature_to_ans = Dict(help='Maps a signature to a representative formula.', scope=Scope.content, - default={}) - # A list of dictionaries, each of which represents an n-dimenstional point that we plug into - # formulas. Each dictionary maps variables to values, eg {'x': 5.1}. - formula_test_values = List(help='The values that we plug into formula responses', scope=Scope.content, - default=[]) + # A list of previous answers this student made to this problem. - # Of the form [answer, [hint_pk_1, hint_pk_2, hint_pk_3]] for each problem. hint_pk's are - # None if the hint was not given. - previous_answers = List(help='A list of previous submissions.', scope=Scope.user_state, default=[]) + # Of the form [answer, [hint_pk_1, ...]] for each problem. + previous_answers = List(help='A list of hints viewed.', scope=Scope.user_state, default=[]) + user_submissions = List(help='A list of previous submissions', scope=Scope.user_state, default=[]) user_voted = Boolean(help='Specifies if the user has voted on this problem or not.', scope=Scope.user_state, default=False) @@ -82,13 +75,20 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): def __init__(self, *args, **kwargs): XModule.__init__(self, *args, **kwargs) # We need to know whether we are working with a FormulaResponse problem. - self.is_formula = (type(self.get_display_items()[0].lcp.responders.values()[0]) == FormulaResponse) + responder = self.get_display_items()[0].lcp.responders.values()[0] + self.is_formula = (type(responder) == FormulaResponse) if self.is_formula: self.answer_to_str = self.formula_answer_to_str - self.answer_signature = self.formula_answer_signature else: self.answer_to_str = self.numerical_answer_to_str - self.answer_signature = self.numerical_answer_signature + # answer_compare is expected to return whether its two inputs are close enough + # to be equal, or raise a StudentInputError if one of the inputs is malformatted. + try: + self.answer_compare = responder.answer_compare + self.validate_answer = responder.validate_answer + except AttributeError: + # This response type is not supported! + log.exception('Response type not supported for hinting: ' + str(responder)) def get_html(self): """ @@ -136,38 +136,12 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): """ return str(answer.values()[0]) - def numerical_answer_signature(self, answer): + def get_matching_answers(self, answer): """ - Runs the answer string through the evaluator. (This is because - symbolic expressions like sin(pi/12)*3 are allowed.) + Look in self.hints, and find all answer keys that are "equal with tolerance" + to the input answer. """ - try: - out = str(evaluator(dict(), dict(), answer)) - except (UndefinedVariable, ParseException): - out = None - return out - - def formula_answer_signature(self, answer): - """ - Converts a capa answer string (output of formula_answer_to_str) - to a string unique to each formula equality class. - So, x^2 and x*x would have the same signature, which would differ - from the signature of 2*x^2. - """ - responder = self.get_display_items()[0].lcp.responders.values()[0] - if self.formula_test_values == []: - # Make a set of test values, and save them. - self.formula_test_values = responder.randomize_variables(responder.samples) - try: - # TODO, maybe: add some rounding to signature generation, so that floating point - # errors don't make a difference. - out = str(responder.hash_answers(answer, self.formula_test_values)) - except StudentInputError: - # I'm not sure what's the best thing to do here. I'm returning - # None, for now, so that the calling function has a chance to catch - # the error without having to import StudentInputError. - return None - return out + return [key for key in self.hints if self.answer_compare(key, answer)] def handle_ajax(self, dispatch, data): """ @@ -211,47 +185,67 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Sometimes, we get an answer that's just not parsable. Do nothing. log.exception('Answer not parsable: ' + str(data)) return - # Make a signature of the answer, for formula responses. - signature = self.answer_signature(answer) - if signature is None: - # Sometimes, signature conversion may fail. - log.exception('Signature conversion failed: ' + str(answer)) - return + if not self.validate_answer(answer): + # Answer is not in the right form. + log.exception('Answer not valid: ' + str(answer)) + if answer not in self.user_submissions: + self.user_submissions += [answer] # Look for a hint to give. # Make a local copy of self.hints - this means we only need to do one json unpacking. # (This is because xblocks storage makes the following command a deep copy.) local_hints = self.hints - if (signature not in local_hints) or (len(local_hints[signature]) == 0): + # For all answers similar enough to our own, accumulate all hints together. + # Also track the original answer of each hint. + matching_answers = self.get_matching_answers(answer) + matching_hints = {} + for matching_answer in matching_answers: + temp_dict = local_hints[matching_answer] + for key, value in temp_dict.items(): + # Each value now has hint, votes, matching_answer. + temp_dict[key] = value + [matching_answer] + matching_hints.update(local_hints[matching_answer]) + # matching_hints now maps pk's to lists of [hint, votes, matching_answer] + if len(matching_hints) == 0: # No hints to give. Return. - self.previous_answers += [[answer, [None, None, None]]] return # Get the top hint, plus two random hints. - n_hints = len(local_hints[signature]) - best_hint_index = max(local_hints[signature], key=lambda key: local_hints[signature][key][1]) - best_hint = local_hints[signature][best_hint_index][0] - if len(local_hints[signature]) == 1: - rand_hint_1 = '' - rand_hint_2 = '' - self.previous_answers += [[answer, [best_hint_index, None, None]]] - elif n_hints == 2: - best_hint = local_hints[signature].values()[0][0] - best_hint_index = local_hints[signature].keys()[0] - rand_hint_1 = local_hints[signature].values()[1][0] - hint_index_1 = local_hints[signature].keys()[1] - rand_hint_2 = '' - self.previous_answers += [[answer, [best_hint_index, hint_index_1, None]]] - else: - (hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\ - random.sample(local_hints[signature].items(), 2) - rand_hint_1 = rand_hint_1[0] - rand_hint_2 = rand_hint_2[0] - self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]] - - return {'best_hint': best_hint, - 'rand_hint_1': rand_hint_1, - 'rand_hint_2': rand_hint_2, + n_hints = len(matching_hints) + hints = [] + best_hint_index = max(matching_hints, key=lambda key: matching_hints[key][1]) + hints.append(matching_hints[best_hint_index][0]) + best_hint_answer = matching_hints[best_hint_index][2] + # The brackets surrounding the index are for backwards compatability purposes. + # (It used to be that each answer was paired with multiple hints in a list.) + self.previous_answers += [[best_hint_answer, [best_hint_index]]] + for i in xrange(min(2, n_hints-1)): + # Keep making random hints until we hit a target, or run out. + (hint_index, (rand_hint, votes, hint_answer)) =\ + random.choice(matching_hints.items()) + if rand_hint in hints: + # Don't show the same hint twice. Try again. + i -= 1 + continue + hints.append(rand_hint) + self.previous_answers += [[hint_index, [hint_answer]]] + return {'hints': hints, 'answer': answer} + # rand_hint_1 = '' + # rand_hint_2 = '' + # if n_hints == 2: + # best_hint = matching_hints.values()[0][0] + # best_hint_index = matching_hints.keys()[0] + # rand_hint_1 = matching_hints.values()[1][0] + # hint_index_1 = matching_hints.keys()[1] + # rand_hint_2 = '' + # self.previous_answers += [[answer, [best_hint_index, hint_index_1]]] + # else: + # (hint_index_1, rand_hint_1), (hint_index_2, rand_hint_2) =\ + # random.sample(matching_hints.items(), 2) + # rand_hint_1 = rand_hint_1[0] + # rand_hint_2 = rand_hint_2[0] + # self.previous_answers += [[answer, [best_hint_index, hint_index_1, hint_index_2]]] + def get_feedback(self, data): """ The student got it correct. Ask him to vote on hints, or submit a hint. @@ -271,32 +265,24 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # be allowed to make one vote / submission, but he can choose which wrong answer # he wants to look at. answer_to_hints = {} # answer_to_hints[answer text][hint pk] -> hint text - signature_to_ans = {} # Lets us combine equivalent answers - # Same mapping as the field, but local. # Go through each previous answer, and populate index_to_hints and index_to_answer. for i in xrange(len(self.previous_answers)): answer, hints_offered = self.previous_answers[i] - # Does this answer equal one of the ones offered already? - signature = self.answer_signature(answer) - if signature in signature_to_ans: - # Re-assign this answer text to the one we've seen already. - answer = signature_to_ans[signature] - else: - signature_to_ans[signature] = answer if answer not in answer_to_hints: answer_to_hints[answer] = {} - if signature in self.hints: + if answer in self.hints: # Go through each hint, and add to index_to_hints for hint_id in hints_offered: if (hint_id is not None) and (hint_id not in answer_to_hints[answer]): try: - answer_to_hints[answer][hint_id] = self.hints[signature][str(hint_id)][0] + answer_to_hints[answer][hint_id] = self.hints[answer][str(hint_id)][0] except KeyError: # Sometimes, the hint that a user saw will have been deleted by the instructor. continue - return {'answer_to_hints': answer_to_hints} + return {'answer_to_hints': answer_to_hints, + 'user_submissions': self.user_submissions} def tally_vote(self, data): """ @@ -315,8 +301,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if self.user_voted: return {'error': 'Sorry, but you have already voted!'} ans = data['answer'] - signature = self.answer_signature(ans) - if signature is None: + if not self.validate_answer(ans): # Uh oh. Invalid answer. log.exception('Failure in hinter tally_vote: Unable to parse answer: ' + ans) return {'error': 'Failure in voting!'} @@ -324,7 +309,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # We use temp_dict because we need to do a direct write for the database to update. temp_dict = self.hints try: - temp_dict[signature][hint_pk][1] += 1 + temp_dict[ans][hint_pk][1] += 1 except KeyError: log.exception('Failure in hinter tally_vote: User voted for non-existant hint: Answer=' + ans + ' pk=' + hint_pk) @@ -337,19 +322,19 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): pk_list = json.loads(data['pk_list']) hint_and_votes = [] for answer, vote_pk in pk_list: - signature = self.answer_signature(answer) - if signature is None: + if not self.validate_answer(answer): log.exception('In hinter tally_vote, couldn\'t parse ' + answer) continue try: - hint_and_votes.append(temp_dict[signature][str(vote_pk)]) + hint_and_votes.append(temp_dict[answer][str(vote_pk)]) except KeyError: log.exception('In hinter tally_vote, couldn\'t find: ' + answer + ', ' + str(vote_pk)) hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) - # Reset self.previous_answers. + # Reset self.previous_answers and user_submissions. self.previous_answers = [] + self.user_submissions = [] return {'hint_and_votes': hint_and_votes} def submit_hint(self, data): @@ -365,8 +350,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Do html escaping. Perhaps in the future do profanity filtering, etc. as well. hint = escape(data['hint']) answer = data['answer'] - signature = self.answer_signature(answer) - if signature is None: + if not self.validate_answer(answer): log.exception('Failure in hinter submit_hint: Unable to parse answer: ' + answer) return {'error': 'Could not submit answer'} # Only allow a student to vote or submit a hint once. @@ -379,12 +363,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): else: temp_dict = self.hints if answer in temp_dict: - temp_dict[signature][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself). + temp_dict[answer][str(self.hint_pk)] = [hint, 1] # With one vote (the user himself). else: - temp_dict[signature] = {str(self.hint_pk): [hint, 1]} - # Add the signature to signature_to_ans, if it's not there yet. - # This allows instructors to see a human-readable answer that corresponds to each signature. - self.add_signature(signature, answer) + temp_dict[answer] = {str(self.hint_pk): [hint, 1]} self.hint_pk += 1 if self.moderate == 'True': self.mod_queue = temp_dict @@ -393,18 +374,9 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # Mark the user has having voted; reset previous_answers self.user_voted = True self.previous_answers = [] + self.user_submissions = [] return {'message': 'Thank you for your hint!'} - def add_signature(self, signature, answer): - """ - Add a signature to self.signature_to_ans. If the signature already - exists, do nothing. - """ - if signature not in self.signature_to_ans: - local_sta = self.signature_to_ans - local_sta[signature] = answer - self.signature_to_ans = local_sta - class CrowdsourceHinterDescriptor(CrowdsourceHinterFields, RawDescriptor): module_class = CrowdsourceHinterModule diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index b13dc83ea2..ebd98e09bd 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -3,18 +3,14 @@ <%def name="get_hint()"> - % if best_hint != '': + % if len(hints) > 0:

    Hints from students who made similar mistakes:

      -
    • ${best_hint}
    • + % for hint in hints: +
    • ${hint}
    • + % endfor +
    % endif - % if rand_hint_1 != '': -
  • ${rand_hint_1}
  • - % endif - % if rand_hint_2 != '': -
  • ${rand_hint_2}
  • - % endif - <%def name="get_feedback()"> @@ -66,17 +62,13 @@
    - % for answer, pk_dict in answer_to_hints.items(): - <% - import json - all_pks = json.dumps(pk_dict.keys()) - %> -
    + % for answer in user_submissions: +

    What hint would you give a student who made the same mistake you did? Please don't give away the answer. From d9517ea13e2cbb63f4c2203186bb108a3074e78f Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 18 Jul 2013 15:02:19 -0400 Subject: [PATCH 168/244] Fixed tests for removing hash access to hints. Fixed instructor view for same. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 13 ++- .../xmodule/tests/test_crowdsource_hinter.py | 109 ++++++------------ lms/djangoapps/instructor/hint_manager.py | 84 +++++--------- .../instructor/tests/test_hint_manager.py | 35 ++++-- .../instructor/hint_manager_inner.html | 4 +- 5 files changed, 105 insertions(+), 140 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 37f244cdba..ebb07506cb 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -188,6 +188,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if not self.validate_answer(answer): # Answer is not in the right form. log.exception('Answer not valid: ' + str(answer)) + return if answer not in self.user_submissions: self.user_submissions += [answer] # Look for a hint to give. @@ -219,12 +220,12 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): self.previous_answers += [[best_hint_answer, [best_hint_index]]] for i in xrange(min(2, n_hints-1)): # Keep making random hints until we hit a target, or run out. - (hint_index, (rand_hint, votes, hint_answer)) =\ - random.choice(matching_hints.items()) - if rand_hint in hints: - # Don't show the same hint twice. Try again. - i -= 1 - continue + go_on = False + while not go_on: + (hint_index, (rand_hint, votes, hint_answer)) =\ + random.choice(matching_hints.items()) + if not rand_hint in hints: + go_on = True hints.append(rand_hint) self.previous_answers += [[hint_index, [hint_answer]]] return {'hints': hints, diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index cc0634cb7d..15939f2679 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -55,6 +55,7 @@ class CHModuleFactory(object): @staticmethod def create(hints=None, previous_answers=None, + user_submissions=None, user_voted=None, moderate=None, mod_queue=None): @@ -87,9 +88,14 @@ class CHModuleFactory(object): else: model_data['previous_answers'] = [ ['24.0', [0, 3, 4]], - ['29.0', [None, None, None]] + ['29.0', []] ] + if user_submissions is not None: + model_data['user_submissions'] = user_submissions + else: + model_data['user_submissions'] = ['24.0', '29.0'] + if user_voted is not None: model_data['user_voted'] = user_voted @@ -104,7 +110,24 @@ class CHModuleFactory(object): # Make a fake capa module. capa_module = MagicMock() - capa_module.responders = {'responder0': MagicMock()} + capa_module.lcp = MagicMock() + responder = MagicMock() + + def validate_answer(answer): + """ A mock answer validator - simulates a numerical response""" + try: + float(answer) + return True + except ValueError: + return False + responder.validate_answer = validate_answer + + def answer_compare(a, b): + """ A fake answer comparer """ + return a == b + responder.answer_compare = answer_compare + + capa_module.lcp.responders = {'responder0': responder} capa_module.displayable_items = lambda: [capa_module] system = get_test_system() @@ -122,28 +145,6 @@ class CHModuleFactory(object): return module - @staticmethod - def setup_formula_response(module): - """ - Adds additional mock methods to the module, so that we can test - formula responses. - """ - # Do a bunch of monkey patching, to mock the lon-capa problem. - responder = MagicMock() - responder.randomize_variables = MagicMock(return_value=4) - - def fake_hash_answers(answer, test_values): - """ A fake answer hasher """ - if test_values == 4 and answer == 'x*y^2': - return 'good' - raise StudentInputError - - responder.hash_answers = fake_hash_answers - lcp = MagicMock() - lcp.responders = {'responder0': responder} - module.get_display_items()[0].lcp = lcp - return module - class VerticalWithModulesFactory(object): """ @@ -288,46 +289,6 @@ class CrowdsourceHinterTest(unittest.TestCase): parsed = mock_module.formula_answer_to_str(get) self.assertTrue(parsed == 'x*y^2') - def test_numerical_answer_signature(self): - """ - Tests answer signature generator for numerical responses. - """ - mock_module = CHModuleFactory.create() - answer = '4*5+3' - signature = mock_module.numerical_answer_signature(answer) - print signature - self.assertTrue(signature == '23.0') - - def test_numerical_answer_signature_failure(self): - """ - Makes sure that unparsable numerical answers return None. - """ - mock_module = CHModuleFactory.create() - answer = 'fish' - signature = mock_module.numerical_answer_signature(answer) - print signature - self.assertTrue(signature is None) - - def test_formula_answer_signature(self): - """ - Tests the answer signature generator for formula responses. - """ - mock_module = CHModuleFactory.create() - mock_module = CHModuleFactory.setup_formula_response(mock_module) - answer = 'x*y^2' - out = mock_module.formula_answer_signature(answer) - self.assertTrue(out == 'good') - - def test_formula_answer_signature_failure(self): - """ - Makes sure that bad answer strings return None as a signature. - """ - mock_module = CHModuleFactory.create() - mock_module = CHModuleFactory.setup_formula_response(mock_module) - answer = 'fish' - out = mock_module.formula_answer_signature(answer) - self.assertTrue(out is None) - def test_gethint_0hint(self): """ Someone asks for a hint, when there's no hint to give. @@ -337,8 +298,9 @@ class CrowdsourceHinterTest(unittest.TestCase): mock_module = CHModuleFactory.create() json_in = {'problem_name': '26.0'} out = mock_module.get_hint(json_in) + print mock_module.previous_answers self.assertTrue(out is None) - self.assertTrue(['26.0', [None, None, None]] in mock_module.previous_answers) + self.assertTrue('26.0' in mock_module.user_submissions) def test_gethint_unparsable(self): """ @@ -359,10 +321,13 @@ class CrowdsourceHinterTest(unittest.TestCase): """ mock_module = CHModuleFactory.create() old_answers = copy.deepcopy(mock_module.previous_answers) + old_user_submissions = copy.deepcopy(mock_module.user_submissions) json_in = {'problem1': 'fish'} out = mock_module.get_hint(json_in) self.assertTrue(out is None) self.assertTrue(mock_module.previous_answers == old_answers) + self.assertTrue(mock_module.user_submissions == old_user_submissions) + def test_gethint_1hint(self): """ @@ -372,7 +337,11 @@ class CrowdsourceHinterTest(unittest.TestCase): mock_module = CHModuleFactory.create() json_in = {'problem_name': '25.0'} out = mock_module.get_hint(json_in) - self.assertTrue(out['best_hint'] == 'Really popular hint') + self.assertTrue('Really popular hint' in out['hints']) + # Also make sure that the input gets added to user_submissions, + # and that the hint is logged in previous_answers. + self.assertTrue('25.0' in mock_module.user_submissions) + self.assertTrue(['25.0', ['1']] in mock_module.previous_answers) def test_gethint_manyhints(self): """ @@ -385,9 +354,8 @@ class CrowdsourceHinterTest(unittest.TestCase): mock_module = CHModuleFactory.create() json_in = {'problem_name': '24.0'} out = mock_module.get_hint(json_in) - self.assertTrue(out['best_hint'] == 'Best hint') - self.assertTrue('rand_hint_1' in out) - self.assertTrue('rand_hint_2' in out) + self.assertTrue('Best hint' in out['hints']) + self.assertTrue(len(out['hints']) == 3) def test_getfeedback_0wronganswers(self): """ @@ -527,8 +495,7 @@ class CrowdsourceHinterTest(unittest.TestCase): # Make a hint request. json_in = {'problem name': '25.0'} out = mock_module.get_hint(json_in) - self.assertTrue((out['best_hint'] == 'This is a new hint.') - or (out['rand_hint_1'] == 'This is a new hint.')) + self.assertTrue('This is a new hint.' in out['hints']) def test_submithint_moderate(self): """ diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 1f0d17ab03..3616445543 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -10,7 +10,6 @@ import re from django.http import HttpResponse, Http404 from django_future.csrf import ensure_csrf_cookie -from django.core.exceptions import ObjectDoesNotExist from mitxmako.shortcuts import render_to_response, render_to_string @@ -114,27 +113,8 @@ def get_hints(request, course_id, field): # Put all non-numerical answers first. return float('-inf') - # Find the signature to answer converter for this problem. Sometimes, - # it doesn't exist; just assume that the signatures are the answers. - try: - signature_to_ans = XModuleContentField.objects.get( - field_name='signature_to_ans', - definition_id__regex=chopped_id - ) - signature_to_ans = json.loads(signature_to_ans.value) - except ObjectDoesNotExist: - signature_to_ans = {} - - signatures_dict = json.loads(hints_by_problem.value) - unsorted = [] - for signature, dict_of_hints in signatures_dict.items(): - if signature in signature_to_ans: - ans_txt = signature_to_ans[signature] - else: - ans_txt = signature - unsorted.append([signature, ans_txt, dict_of_hints]) - # Answer list contains [signature, answer, dict_of_hints] sub-lists. - answer_list = sorted(unsorted, key=answer_sorter) + # Answer list contains [answer, dict_of_hints] pairs. + answer_list = sorted(json.loads(hints_by_problem.value).items(), key=answer_sorter) big_out_dict[hints_by_problem.definition_id] = answer_list render_dict = {'field': field, @@ -165,7 +145,7 @@ def delete_hints(request, course_id, field): Deletes the hints specified. `request.POST` contains some fields keyed by integers. Each such field contains a - [problem_defn_id, signature, pk] tuple. These tuples specify the hints to be deleted. + [problem_defn_id, answer, pk] tuple. These tuples specify the hints to be deleted. Example `request.POST`: {'op': 'delete_hints', @@ -177,12 +157,12 @@ def delete_hints(request, course_id, field): for key in request.POST: if key == 'op' or key == 'field': continue - problem_id, signature, pk = request.POST.getlist(key) + problem_id, answer, pk = request.POST.getlist(key) # Can be optimized - sort the delete list by problem_id, and load each problem # from the database only once. this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) problem_dict = json.loads(this_problem.value) - del problem_dict[signature][pk] + del problem_dict[answer][pk] this_problem.value = json.dumps(problem_dict) this_problem.save() @@ -191,18 +171,18 @@ def change_votes(request, course_id, field): """ Updates the number of votes. - The numbered fields of `request.POST` contain [problem_id, signature, pk, new_votes] tuples. + The numbered fields of `request.POST` contain [problem_id, answer, pk, new_votes] tuples. - Very similar to `delete_hints`. Is there a way to merge them? Nah, too complicated. """ for key in request.POST: if key == 'op' or key == 'field': continue - problem_id, signature, pk, new_votes = request.POST.getlist(key) + problem_id, answer, pk, new_votes = request.POST.getlist(key) this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) problem_dict = json.loads(this_problem.value) - # problem_dict[signature][pk] points to a [hint_text, #votes] pair. - problem_dict[signature][pk][1] = int(new_votes) + # problem_dict[answer][pk] points to a [hint_text, #votes] pair. + problem_dict[answer][pk][1] = int(new_votes) this_problem.value = json.dumps(problem_dict) this_problem.save() @@ -214,13 +194,24 @@ def add_hint(request, course_id, field): field problem - The problem id answer - The answer to which a hint will be added - - Needs to be converted into signature first. hint - The text of the hint """ problem_id = request.POST['problem'] answer = request.POST['answer'] hint_text = request.POST['hint'] + + # Validate the answer. This requires initializing the xmodules, which + # is annoying. + loc = Location(problem_id) + descriptors = modulestore().get_items(loc) + m_d_c = model_data.ModelDataCache(descriptors, course_id, request.user) + hinter_module = module_render.get_module(request.user, request, loc, m_d_c, course_id) + if not hinter_module.validate_answer(answer): + # Invalid answer. Don't add it to the database, or else the + # hinter will crash when we encounter it. + return 'Error - the answer you specified is not properly formatted: ' + str(answer) + this_problem = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) hint_pk_entry = XModuleContentField.objects.get(field_name='hint_pk', definition_id=problem_id) @@ -228,23 +219,10 @@ def add_hint(request, course_id, field): hint_pk_entry.value = this_pk + 1 hint_pk_entry.save() - # Make signature. This is really annoying, but I don't see - # any alternative :( - loc = Location(problem_id) - descriptors = modulestore().get_items(loc) - m_d_c = model_data.ModelDataCache(descriptors, course_id, request.user) - hinter_module = module_render.get_module(request.user, request, loc, m_d_c, course_id) - signature = hinter_module.answer_signature(answer) - if signature is None: - # Signature generation failed. - # We should probably return an error message, too... working on that. - return 'Error - your answer could not be parsed as a formula expression.' - hinter_module.add_signature(signature, answer) - problem_dict = json.loads(this_problem.value) - if signature not in problem_dict: - problem_dict[signature] = {} - problem_dict[signature][this_pk] = [hint_text, 1] + if answer not in problem_dict: + problem_dict[answer] = {} + problem_dict[answer][this_pk] = [hint_text, 1] this_problem.value = json.dumps(problem_dict) this_problem.save() @@ -254,26 +232,26 @@ def approve(request, course_id, field): Approve a list of hints, moving them from the mod_queue to the real hint list. POST: op, field - (some number) -> [problem, signature, pk] + (some number) -> [problem, answer, pk] """ for key in request.POST: if key == 'op' or key == 'field': continue - problem_id, signature, pk = request.POST.getlist(key) + problem_id, answer, pk = request.POST.getlist(key) # Can be optimized - sort the delete list by problem_id, and load each problem # from the database only once. problem_in_mod = XModuleContentField.objects.get(field_name=field, definition_id=problem_id) problem_dict = json.loads(problem_in_mod.value) - hint_to_move = problem_dict[signature][pk] - del problem_dict[signature][pk] + hint_to_move = problem_dict[answer][pk] + del problem_dict[answer][pk] problem_in_mod.value = json.dumps(problem_dict) problem_in_mod.save() problem_in_hints = XModuleContentField.objects.get(field_name='hints', definition_id=problem_id) problem_dict = json.loads(problem_in_hints.value) - if signature not in problem_dict: - problem_dict[signature] = {} - problem_dict[signature][pk] = hint_to_move + if answer not in problem_dict: + problem_dict[answer] = {} + problem_dict[answer][pk] = hint_to_move problem_in_hints.value = json.dumps(problem_dict) problem_in_hints.save() diff --git a/lms/djangoapps/instructor/tests/test_hint_manager.py b/lms/djangoapps/instructor/tests/test_hint_manager.py index e9387a8eef..fae2e48bb4 100644 --- a/lms/djangoapps/instructor/tests/test_hint_manager.py +++ b/lms/djangoapps/instructor/tests/test_hint_manager.py @@ -2,7 +2,7 @@ import json from django.test.client import Client, RequestFactory from django.test.utils import override_settings -from mock import MagicMock, patch +from mock import patch, MagicMock from courseware.models import XModuleContentField from courseware.tests.factories import ContentFactory @@ -12,8 +12,6 @@ from student.tests.factories import UserFactory from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory -import unittest - @override_settings(MODULESTORE=TEST_DATA_MIXED_MODULESTORE) class HintManagerTest(ModuleStoreTestCase): @@ -92,7 +90,7 @@ class HintManagerTest(ModuleStoreTestCase): out = view.get_hints(post, self.course_id, 'mod_queue') print out self.assertTrue(out['other_field'] == 'hints') - expected = {self.problem_id: [[u'2.0', u'2.0', {u'2': [u'Hint 2', 1]}]]} + expected = {self.problem_id: [(u'2.0', {u'2': [u'Hint 2', 1]})]} self.assertTrue(out['all_hints'] == expected) def test_gethints_other(self): @@ -104,9 +102,9 @@ class HintManagerTest(ModuleStoreTestCase): out = view.get_hints(post, self.course_id, 'hints') print out self.assertTrue(out['other_field'] == 'mod_queue') - expected = {self.problem_id: [['1.0', '1.0', {'1': ['Hint 1', 2], - '3': ['Hint 3', 12]}], - ['2.0', '2.0', {'4': ['Hint 4', 3]}] + expected = {self.problem_id: [('1.0', {'1': ['Hint 1', 2], + '3': ['Hint 3', 12]}), + ('2.0', {'4': ['Hint 4', 3]}) ]} self.assertTrue(out['all_hints'] == expected) @@ -143,7 +141,7 @@ class HintManagerTest(ModuleStoreTestCase): # Because add_hint accesses the xmodule, this test requires a bunch # of monkey patching. hinter = MagicMock() - hinter.answer_signature = lambda string: string + hinter.validate_answer = lambda string: True request = RequestFactory() post = request.post(self.url, {'field': 'mod_queue', @@ -158,6 +156,27 @@ class HintManagerTest(ModuleStoreTestCase): problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value self.assertTrue('3.14' in json.loads(problem_hints)) + def test_addbadhint(self): + """ + Check that instructors cannot add hints with unparsable answers. + """ + # Patching. + hinter = MagicMock() + hinter.validate_answer = lambda string: False + + request = RequestFactory() + post = request.post(self.url, {'field': 'mod_queue', + 'op': 'add hint', + 'problem': self.problem_id, + 'answer': 'fish', + 'hint': 'This is a new hint.'}) + post.user = 'fake user' + with patch('courseware.module_render.get_module', MagicMock(return_value=hinter)): + with patch('courseware.model_data.ModelDataCache', MagicMock(return_value=None)): + view.add_hint(post, self.course_id, 'mod_queue') + problem_hints = XModuleContentField.objects.get(field_name='mod_queue', definition_id=self.problem_id).value + self.assertTrue('fish' not in json.loads(problem_hints)) + def test_approve(self): """ Check that instructors can approve hints. (Move them diff --git a/lms/templates/instructor/hint_manager_inner.html b/lms/templates/instructor/hint_manager_inner.html index 3acf8102ac..45101be2f6 100644 --- a/lms/templates/instructor/hint_manager_inner.html +++ b/lms/templates/instructor/hint_manager_inner.html @@ -8,12 +8,12 @@ Switch to ${other_field_label % for definition_id in all_hints:

    Problem: ${id_to_name[definition_id]}

    - % for signature, answer, hint_dict in all_hints[definition_id]: + % for answer, hint_dict in all_hints[definition_id]: % if len(hint_dict) > 0:

    Answer: ${answer}

    % endif % for pk, hint in hint_dict.items(): -

    +

    ${hint[0]}
    Votes: From e2aea75f1626b7407053cbd44905ba07bb4087c8 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 18 Jul 2013 15:39:01 -0400 Subject: [PATCH 169/244] Fixed a bug in recording hints shown. Removed the answer display next to voting. (This was deemed distracting.) --- common/lib/xmodule/xmodule/crowdsource_hinter.py | 3 +-- common/templates/hinter_display.html | 4 ++-- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index ebb07506cb..147de956af 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -227,7 +227,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if not rand_hint in hints: go_on = True hints.append(rand_hint) - self.previous_answers += [[hint_index, [hint_answer]]] + self.previous_answers += [[hint_answer, [hint_index]]] return {'hints': hints, 'answer': answer} @@ -281,7 +281,6 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): except KeyError: # Sometimes, the hint that a user saw will have been deleted by the instructor. continue - return {'answer_to_hints': answer_to_hints, 'user_submissions': self.user_submissions} diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index ebd98e09bd..4050824c5b 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -46,7 +46,7 @@ % for hint_pk, hint_text in pk_dict.items():

    - ${answer}: ${hint_text} + ${hint_text}

    % endfor % endfor @@ -71,7 +71,7 @@

    - What hint would you give a student who made the same mistake you did? Please don't give away the answer. + What hint would you give a student who also arrived at an answer of ${answer}? Please don't give away the correct answer.

    -

    - -
    - % endfor -
    + +

    + -

    Read about what makes a good hint.

    - + % endif + From 7c93b45d0c0a7c2a0bc1d131593fe11258c028d2 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Wed, 31 Jul 2013 17:13:32 -0400 Subject: [PATCH 176/244] Added wizard / slideshow style hint collection script. Still requires a little polishing, I think. --- .../js/src/crowdsource_hinter/display.coffee | 32 +++++- common/templates/hinter_display.html | 101 +++++++++++------- 2 files changed, 87 insertions(+), 46 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index a1dc35a604..34956603c6 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -36,8 +36,9 @@ class @Hinter @$('input.vote').click @vote @$('input.submit-hint').click @submit_hint @$('.custom-hint').click @clear_default_text - @$('#answer-tabs').tabs({active: 0}) @$('.expand').click @expand + @$('.wizard-link').click @wizard_link_handle + @$('.answer-choice').click @answer_choice_handle expand: (eventObj) => target = @$('#' + @$(eventObj.currentTarget).data('target')) @@ -55,11 +56,10 @@ class @Hinter submit_hint: (eventObj) => textarea = $('.custom-hint') - answer = $('input:radio[name=answer-select]:checked').val() - if answer == undefined - # The user didn't choose an answer. Do nothing. + if @answer == '' + # The user didn't choose an answer, somehow. Do nothing. return - post_json = {'answer': answer, 'hint': textarea.val()} + post_json = {'answer': @answer, 'hint': textarea.val()} $.postWithPrefix "#{@url}/submit_hint",post_json, (response) => @render(response.contents) @@ -69,6 +69,15 @@ class @Hinter target.val('') target.data('cleared', true) + wizard_link_handle: (eventObj) => + target = @$(eventObj.currentTarget) + @go_to(target.attr('dest')) + + answer_choice_handle: (eventObj) => + @answer = @$(eventObj.target).attr('value') + @$('#blank-answer').html(@answer) + @go_to('p3') + render: (content) -> if content # Trim leading and trailing whitespace @@ -82,3 +91,16 @@ class @Hinter @$('#previous-answer-0').css('display', 'inline') else @el.hide() + # Initialize the answer choice - remembers which answer the user picked on + # p2 when he submits a hint on p3. + @answer = '' + # Make the correct wizard view show up. + hints_exist = @$('#hints-exist').html() == 'True' + if hints_exist + @go_to('p1') + else + @go_to('p2') + + go_to: (view_id) -> + $('.wizard-view').css('display', 'none') + $('#' + view_id).css('display', 'block') \ No newline at end of file diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index 8fd219e847..0f350083de 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -34,50 +34,71 @@ for pk, hint_text in pk_dict.items(): pk_list.append([answer, pk]) json_pk_list = json.dumps(pk_list) - %> - % if hints_exist: -

    - Optional. Help us improve our hints! Which hint was most helpful to you? -

    + %> - - - % for answer, pk_dict in answer_to_hints.items(): - % for hint_pk, hint_text in pk_dict.items(): -

    - - ${hint_text} -

    - % endfor - % endfor - -

    - Don't like any of the hints above? - - Write your own! -

    - - +
    + From 35a8e9be326dab1ca27cc80160e0a29319f62653 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 1 Aug 2013 09:40:49 -0400 Subject: [PATCH 177/244] Some fixes on hint ui. --- common/templates/hinter_display.html | 22 +++++++++------------- 1 file changed, 9 insertions(+), 13 deletions(-) diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index 0f350083de..a7f2eafd1c 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -78,9 +78,7 @@ % endfor % if hints_exist:
    -

    - Back to voting. -

    + Back % endif
    @@ -89,17 +87,7 @@

    Write a hint for other students who get the wrong answer of . -
    - - Choose a different answer. -

    - -

    - -

    Read about what makes a good hint.

    + + +

    + +

    + Back
    From 2d1c9158d885fee77e35146f5588cb931b6d3704 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 1 Aug 2013 14:52:37 -0400 Subject: [PATCH 178/244] Added fancy sliding transitions into hint collection page. --- .../css/crowdsource_hinter/display.scss | 71 +++----- .../js/src/crowdsource_hinter/display.coffee | 36 +++- common/templates/hinter_display.html | 170 +++++++++--------- 3 files changed, 143 insertions(+), 134 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss index c1d7b1f048..ce40d10b53 100644 --- a/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss +++ b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss @@ -7,52 +7,6 @@ background: rgb(253, 248, 235); } -#answer-tabs { - background: #FFFFFF; - border: none; - margin-bottom: 20px; - padding-bottom: 20px; -} - -#answer-tabs .ui-widget-header { - border-bottom: 1px solid #DCDCDC; - background: #FDF8EB; -} - -#answer-tabs .ui-tabs-nav .ui-state-default { - border: 1px solid #DCDCDC; - background: #E6E6E3; - margin-bottom: 0px; -} - -#answer-tabs .ui-tabs-nav .ui-state-default:hover { - background: #FFFFFF; -} - -#answer-tabs .ui-tabs-nav .ui-state-active:hover { - background: #FFFFFF; -} - -#answer-tabs .ui-tabs-nav .ui-state-active { - border: 1px solid #DCDCDC; - background: #FFFFFF; -} - -#answer-tabs .ui-tabs-nav .ui-state-active a { - color: #222222; - background: #FFFFFF; -} - -#answer-tabs .ui-tabs-nav .ui-state-default a:hover { - color: #222222; - background: #FFFFFF; -} - -#answer-tabs .custom-hint { - height: 100px; - width: 100%; -} - .hint-inner-container { padding-left: 15px; padding-right: 15px; @@ -63,3 +17,28 @@ padding-top: 0px !important; padding-bottom: 0px !important; } + +.wizard-view { + float: left; + width: 800px; +} + +.wizard-container { + width: 3000px; + + -webkit-transition:all 1.0s ease-in-out; + -moz-transition:all 1.0s ease-in-out; + -o-transition:all 1.0s ease-in-out; + transition:all 1.0s ease-in-out; +} + +.wizard-viewbox { + width: 790px; + overflow: hidden; + position: relative; +} + +.bottom { + position: absolute; + bottom: 0; +} \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index 34956603c6..3ecf73a0dc 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -41,13 +41,17 @@ class @Hinter @$('.answer-choice').click @answer_choice_handle expand: (eventObj) => + # Expand a hidden div. target = @$('#' + @$(eventObj.currentTarget).data('target')) if @$(target).css('display') == 'none' @$(target).css('display', 'block') else @$(target).css('display', 'none') + # Fix positioning errors with the bottom class. + @$('.bottom').removeClass('bottom').addClass('bottom') vote: (eventObj) => + # Make an ajax request with the user's vote. target = @$(eventObj.currentTarget) all_pks = @$('#pk-list').attr('data-pk-list') post_json = {'answer': target.attr('data-answer'), 'hint': target.data('hintno'), 'pk_list': all_pks} @@ -55,6 +59,7 @@ class @Hinter @render(response.contents) submit_hint: (eventObj) => + # Make an ajax request with the user's new hint. textarea = $('.custom-hint') if @answer == '' # The user didn't choose an answer, somehow. Do nothing. @@ -64,21 +69,25 @@ class @Hinter @render(response.contents) clear_default_text: (eventObj) => + # Remove placeholder text in the hint submission textbox. target = @$(eventObj.currentTarget) if target.data('cleared') == undefined target.val('') target.data('cleared', true) wizard_link_handle: (eventObj) => + # Move to another wizard view, based on the link that the user clicked. target = @$(eventObj.currentTarget) @go_to(target.attr('dest')) answer_choice_handle: (eventObj) => + # A special case of wizard_link_handle - we need to track a state variable, + # the answer that the user chose. @answer = @$(eventObj.target).attr('value') @$('#blank-answer').html(@answer) @go_to('p3') - render: (content) -> + render: (content) => if content # Trim leading and trailing whitespace content = content.replace /^\s+|\s+$/g, "" @@ -94,6 +103,13 @@ class @Hinter # Initialize the answer choice - remembers which answer the user picked on # p2 when he submits a hint on p3. @answer = '' + # Determine whether the browser supports CSS3 transforms. + styles = document.body.style + if styles.WebkitTransform == '' or styles.transform == '' + @go_to = @transform_go_to + else + @go_to = @legacy_go_to + # Make the correct wizard view show up. hints_exist = @$('#hints-exist').html() == 'True' if hints_exist @@ -101,6 +117,18 @@ class @Hinter else @go_to('p2') - go_to: (view_id) -> - $('.wizard-view').css('display', 'none') - $('#' + view_id).css('display', 'block') \ No newline at end of file + transform_go_to: (view_id) -> + # Switch wizard views using sliding transitions. + id_to_index = { + 'p1': 0, + 'p2': 1, + 'p3': 2, + } + translate_string = 'translateX(' +id_to_index[view_id] * -1 * parseInt($('#' + view_id).css('width'), 10) + 'px)' + @$('.wizard-container').css('transform', translate_string) + @$('.wizard-container').css('-webkit-transform', translate_string) + + legacy_go_to: (view_id) -> + # For older browsers - switch wizard views by changing the screen. + @$('.wizard-view').css('display', 'none') + @$('#' + view_id).css('display', 'block') \ No newline at end of file diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index a7f2eafd1c..6cd5040072 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -38,98 +38,100 @@ - -
    -

    - Optional. Help us improve our hints! Which hint was most helpful to you? -

    - - - - % for answer, pk_dict in answer_to_hints.items(): - % for hint_pk, hint_text in pk_dict.items(): -

    - - ${hint_text} -

    - % endfor - % endfor - -

    - Don't like any of the hints above? - - Write your own! -

    -
    - -
    - % if hints_exist: +
    +

    - Choose the incorrect answer for which you want to write a hint: -

    - % else: -

    - Optional. Help other students by submitting a hint! Pick one of your previous - answers for which you would like to write a hint: -

    - % endif - % for answer in user_submissions: - ${answer}
    - % endfor - % if hints_exist: -
    - Back - % endif - -
    - -
    - -

    - Write a hint for other students who get the wrong answer of . -

    -

    Read about what makes a good hint.

    - - -

    - -

    - Back -
    +
    + % if hints_exist: +

    + Choose the incorrect answer for which you want to write a hint: +

    + % else: +

    + Optional. Help other students by submitting a hint! Pick one of your previous + answers for which you would like to write a hint: +

    + % endif + % for answer in user_submissions: + ${answer}
    + % endfor + % if hints_exist: + +

     

    + Back + % endif +
    + +
    + +

    + Write a hint for other students who get the wrong answer of . +

    +

    Read about what makes a good hint.

    + + +

    + + +
    + Back +
    + +
    From 42202520551cfb9988723599565cc8a37b9fe961 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Fri, 2 Aug 2013 09:22:22 -0400 Subject: [PATCH 179/244] Fixed terribly annoying issues with back button. Back button is now on the bottom of the hint div, no matter what. --- .../xmodule/css/crowdsource_hinter/display.scss | 5 ----- .../js/src/crowdsource_hinter/display.coffee | 15 +++++++++++++-- common/templates/hinter_display.html | 11 ++++++----- 3 files changed, 19 insertions(+), 12 deletions(-) diff --git a/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss index ce40d10b53..0c479ab5cb 100644 --- a/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss +++ b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss @@ -37,8 +37,3 @@ overflow: hidden; position: relative; } - -.bottom { - position: absolute; - bottom: 0; -} \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index 3ecf73a0dc..2e99b107f9 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -48,7 +48,7 @@ class @Hinter else @$(target).css('display', 'none') # Fix positioning errors with the bottom class. - @$('.bottom').removeClass('bottom').addClass('bottom') + @set_bottom_links() vote: (eventObj) => # Make an ajax request with the user's vote. @@ -87,6 +87,15 @@ class @Hinter @$('#blank-answer').html(@answer) @go_to('p3') + set_bottom_links: => + # Makes each .bottom class stick to the bottom of .wizard-viewbox + @$('.bottom').css('margin-top', '0px') + viewbox_height = parseInt(@$('.wizard-viewbox').css('height'), 10) + @$('.bottom').each((index, obj) -> + view_height = parseInt($(obj).parent().css('height'), 10) + $(obj).css('margin-top', (viewbox_height - view_height) + 'px') + ) + render: (content) => if content # Trim leading and trailing whitespace @@ -127,8 +136,10 @@ class @Hinter translate_string = 'translateX(' +id_to_index[view_id] * -1 * parseInt($('#' + view_id).css('width'), 10) + 'px)' @$('.wizard-container').css('transform', translate_string) @$('.wizard-container').css('-webkit-transform', translate_string) + @set_bottom_links() legacy_go_to: (view_id) -> # For older browsers - switch wizard views by changing the screen. @$('.wizard-view').css('display', 'none') - @$('#' + view_id).css('display', 'block') \ No newline at end of file + @$('#' + view_id).css('display', 'block') + @set_bottom_links() \ No newline at end of file diff --git a/common/templates/hinter_display.html b/common/templates/hinter_display.html index 6cd5040072..12f6b9f2f4 100644 --- a/common/templates/hinter_display.html +++ b/common/templates/hinter_display.html @@ -77,9 +77,9 @@ ${answer}
    % endfor % if hints_exist: - -

     

    - Back +

    + Back +

    % endif
    @@ -127,8 +127,9 @@ Write your hint here. Please don't give away the correct answer. Learn even more

    -
    - Back +

    + Back +

    From 69fbe77dcb82840cc8fea78fac1b103fe69858cd Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Thu, 15 Aug 2013 10:57:06 -0400 Subject: [PATCH 180/244] Fixed a rebase error in responsetypes. Fixed a css bug in the hinter. --- common/lib/capa/capa/responsetypes.py | 2 +- .../lib/xmodule/xmodule/css/crowdsource_hinter/display.scss | 5 +++-- 2 files changed, 4 insertions(+), 3 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 182266c21a..192d6c0663 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -1812,7 +1812,7 @@ class FormulaResponse(LoncapaResponse): var_dict, dict(), answer, - cs=self.case_sensitive, + case_sensitive=self.case_sensitive, )) except UndefinedVariable as uv: log.debug( diff --git a/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss index 0c479ab5cb..dab94ed2f7 100644 --- a/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss +++ b/common/lib/xmodule/xmodule/css/crowdsource_hinter/display.scss @@ -20,7 +20,8 @@ .wizard-view { float: left; - width: 800px; + width: 790px; + margin-right: 10px; } .wizard-container { @@ -33,7 +34,7 @@ } .wizard-viewbox { - width: 790px; + width: 800px; overflow: hidden; position: relative; } From e6067d88145992e9b6ebc02ff78bf34e088081b7 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Mon, 19 Aug 2013 13:58:59 -0400 Subject: [PATCH 181/244] Addressed PR comments. Fixed coffeescript event logging error. Fixed crowdsourced hinter dependence on self.field.append() not working. --- common/lib/capa/capa/responsetypes.py | 4 +- .../lib/xmodule/xmodule/crowdsource_hinter.py | 48 ++++++++++--------- .../xmodule/js/src/capa/display.coffee | 1 + 3 files changed, 28 insertions(+), 25 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 192d6c0663..550042d1df 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -923,8 +923,8 @@ class NumericalResponse(LoncapaResponse): with this problem's tolerance. """ return compare_with_tolerance( - evaluator(dict(), dict(), a), - evaluator(dict(), dict(), b), + evaluator({}, {}, a), + evaluator({}, {}, b), self.tolerance ) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 74b5f7b36e..90699beda6 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -7,6 +7,7 @@ Currently experimental - not for instructor use, yet. import logging import json import random +import copy from pkg_resources import resource_string @@ -82,17 +83,17 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): log.exception('Unable to find a capa problem child.') return - self.is_formula = (type(responder) == FormulaResponse) + self.is_formula = isinstance(self, FormulaResponse) if self.is_formula: self.answer_to_str = self.formula_answer_to_str else: self.answer_to_str = self.numerical_answer_to_str # compare_answer is expected to return whether its two inputs are close enough # to be equal, or raise a StudentInputError if one of the inputs is malformatted. - try: + if hasattr(responder, 'compare_answer') and hasattr(responder, 'validate_answer'): self.compare_answer = responder.compare_answer self.validate_answer = responder.validate_answer - except AttributeError: + else: # This response type is not supported! log.exception('Response type not supported for hinting: ' + str(responder)) @@ -199,43 +200,43 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): if answer not in self.user_submissions: self.user_submissions += [answer] - # Next, find all of the hints that could possibly go with this answer. - # Make a local copy of self.hints - this means we only need to do one json unpacking. - # (This is because xblocks storage makes the following command a deep copy.) - local_hints = self.hints # For all answers similar enough to our own, accumulate all hints together. # Also track the original answer of each hint. matching_answers = self.get_matching_answers(answer) matching_hints = {} for matching_answer in matching_answers: - temp_dict = local_hints[matching_answer] + temp_dict = copy.deepcopy(self.hints[matching_answer]) for key, value in temp_dict.items(): # Each value now has hint, votes, matching_answer. temp_dict[key] = value + [matching_answer] - matching_hints.update(local_hints[matching_answer]) + matching_hints.update(temp_dict) # matching_hints now maps pk's to lists of [hint, votes, matching_answer] # Finally, randomly choose a subset of matching_hints to actually show. - if len(matching_hints) == 0: + if not matching_hints: # No hints to give. Return. return # Get the top hint, plus two random hints. n_hints = len(matching_hints) hints = [] - best_hint_index = max(matching_hints, key=lambda key: matching_hints[key][1]) + # max(dict) returns the maximum key in dict. + # The key function takes each pk, and returns the number of votes for the + # hint with that pk. + best_hint_index = max(matching_hints, key=lambda pk: matching_hints[pk][1]) hints.append(matching_hints[best_hint_index][0]) best_hint_answer = matching_hints[best_hint_index][2] # The brackets surrounding the index are for backwards compatability purposes. # (It used to be that each answer was paired with multiple hints in a list.) self.previous_answers += [[best_hint_answer, [best_hint_index]]] - for i in xrange(min(2, n_hints-1)): + for i in xrange(min(2, n_hints - 1)): # Keep making random hints until we hit a target, or run out. - go_on = False - while not go_on: + while True: + # random.choice randomly chooses an element from its input list. + # (We then unpack the item, in this case data for a hint.) (hint_index, (rand_hint, votes, hint_answer)) =\ random.choice(matching_hints.items()) - if not rand_hint in hints: - go_on = True + if rand_hint not in hints: + break hints.append(rand_hint) self.previous_answers += [[hint_answer, [hint_index]]] return {'hints': hints, @@ -297,7 +298,7 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): ans = data['answer'] if not self.validate_answer(ans): # Uh oh. Invalid answer. - log.exception('Failure in hinter tally_vote: Unable to parse answer: ' + ans) + log.exception('Failure in hinter tally_vote: Unable to parse answer: {ans}'.format(ans=ans)) return {'error': 'Failure in voting!'} hint_pk = str(data['hint']) # We use temp_dict because we need to do a direct write for the database to update. @@ -305,8 +306,8 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): try: temp_dict[ans][hint_pk][1] += 1 except KeyError: - log.exception('Failure in hinter tally_vote: User voted for non-existant hint: Answer=' + - ans + ' pk=' + hint_pk) + log.exception('''Failure in hinter tally_vote: User voted for non-existant hint: + Answer={ans} pk={hint_pk}'''.format(ans=ans, hint_pk=hint_pk)) return {'error': 'Failure in voting!'} self.hints = temp_dict # Don't let the user vote again! @@ -317,13 +318,13 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): hint_and_votes = [] for answer, vote_pk in pk_list: if not self.validate_answer(answer): - log.exception('In hinter tally_vote, couldn\'t parse ' + answer) + log.exception('In hinter tally_vote, couldn\'t parse {ans}'.format(ans=answer)) continue try: hint_and_votes.append(temp_dict[answer][str(vote_pk)]) except KeyError: - log.exception('In hinter tally_vote, couldn\'t find: ' - + answer + ', ' + str(vote_pk)) + log.exception('In hinter tally_vote, couldn\'t find: {ans}, {vote_pk}'.format( + ans=answer, vote_pk=str(vote_pk))) hint_and_votes.sort(key=lambda pair: pair[1], reverse=True) # Reset self.previous_answers and user_submissions. @@ -345,7 +346,8 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): hint = escape(data['hint']) answer = data['answer'] if not self.validate_answer(answer): - log.exception('Failure in hinter submit_hint: Unable to parse answer: ' + answer) + log.exception('Failure in hinter submit_hint: Unable to parse answer: {ans}'.format( + ans=answer)) return {'error': 'Could not submit answer'} # Only allow a student to vote or submit a hint once. if self.user_voted: diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index 7263dde1f0..ab64617644 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -247,6 +247,7 @@ class @Problem @updateProgress response else @gentle_alert response.success + Logger.log 'problem_graded', [@answers, response.contents], @url if not abort_submission $.ajaxWithPrefix("#{@url}/problem_check", settings) From 68bb45ab67dc3dee4215e2c166ff898544099cd9 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Tue, 20 Aug 2013 14:44:12 -0400 Subject: [PATCH 182/244] Added jasmine tests to crowdsource_hinter. UI is not covered; only event-inteception is covered. --- .../js/fixtures/crowdsource_hinter.html | 52 ++++++++++++++++++ .../xmodule/js/spec/capa/display_spec.coffee | 12 ++++- .../crowdsource_hinter/display_spec.coffee | 54 +++++++++++++++++++ .../xmodule/js/src/capa/display.coffee | 1 - .../js/src/crowdsource_hinter/display.coffee | 2 +- 5 files changed, 118 insertions(+), 3 deletions(-) create mode 100644 common/lib/xmodule/xmodule/js/fixtures/crowdsource_hinter.html create mode 100644 common/lib/xmodule/xmodule/js/spec/crowdsource_hinter/display_spec.coffee diff --git a/common/lib/xmodule/xmodule/js/fixtures/crowdsource_hinter.html b/common/lib/xmodule/xmodule/js/fixtures/crowdsource_hinter.html new file mode 100644 index 0000000000..02122017bd --- /dev/null +++ b/common/lib/xmodule/xmodule/js/fixtures/crowdsource_hinter.html @@ -0,0 +1,52 @@ +
  • + + +
    + + +
    +
    + + +

    + Numerical Input +

    + +
    (1/1 points)
    + +
    +

    The answer is 2*x^2*y + 5 +


    Answer = +
    +
    + + +
    + + + +
    + +
    + + + + +
    +
    +
    + +
    + + + +
    + + + + +
    + + + +
  • \ No newline at end of file diff --git a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee index 33d74e2335..111174a0b4 100644 --- a/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee +++ b/common/lib/xmodule/xmodule/js/spec/capa/display_spec.coffee @@ -125,9 +125,10 @@ describe 'Problem', -> expect(@problem.bind).toHaveBeenCalled() describe 'check_fd', -> - xit 'should have specs written for this functionality', -> + xit 'should have more specs written for this functionality', -> expect(false) + describe 'check', -> beforeEach -> @problem = new Problem($('.xmodule_display')) @@ -137,6 +138,15 @@ describe 'Problem', -> @problem.check() expect(Logger.log).toHaveBeenCalledWith 'problem_check', 'foo=1&bar=2' + it 'log the problem_graded event, after the problem is done grading.', -> + spyOn($, 'postWithPrefix').andCallFake (url, answers, callback) -> + response = + success: 'correct' + contents: 'mock grader response' + callback(response) + @problem.check() + expect(Logger.log).toHaveBeenCalledWith 'problem_graded', ['foo=1&bar=2', 'mock grader response'], @problem.url + it 'submit the answer for check', -> spyOn $, 'postWithPrefix' @problem.check() diff --git a/common/lib/xmodule/xmodule/js/spec/crowdsource_hinter/display_spec.coffee b/common/lib/xmodule/xmodule/js/spec/crowdsource_hinter/display_spec.coffee new file mode 100644 index 0000000000..917741f8af --- /dev/null +++ b/common/lib/xmodule/xmodule/js/spec/crowdsource_hinter/display_spec.coffee @@ -0,0 +1,54 @@ +describe 'Crowdsourced hinter', -> + beforeEach -> + window.update_schematics = -> + jasmine.stubRequests() + # note that the fixturesPath is set in spec/helper.coffee + loadFixtures 'crowdsource_hinter.html' + @hinter = new Hinter($('#hinter-root')) + + describe 'high-level integration tests', -> + # High-level, happy-path tests for integration with capa problems. + beforeEach -> + # Make a more thorough $.postWithPrefix mock. + spyOn($, 'postWithPrefix').andCallFake( -> + last_argument = arguments[arguments.length - 1] + if typeof last_argument == 'function' + response = + success: 'incorrect' + contents: 'mock grader response' + last_argument(response) + ) + @problem = new Problem($('#problem')) + @problem.bind() + + it 'knows when a capa problem is graded, using check.', -> + @problem.answers = 'test answer' + @problem.check() + expect($.postWithPrefix).toHaveBeenCalledWith("#{@hinter.url}/get_hint", 'test answer', jasmine.any(Function)) + + it 'knows when a capa problem is graded usig check_fd.', -> + spyOn($, 'ajaxWithPrefix').andCallFake((url, settings) -> + response = + success: 'incorrect' + contents: 'mock grader response' + settings.success(response) + ) + @problem.answers = 'test answer' + @problem.check_fd() + expect($.postWithPrefix).toHaveBeenCalledWith("#{@hinter.url}/get_hint", 'test answer', jasmine.any(Function)) + + describe 'capture_problem', -> + beforeEach -> + spyOn($, 'postWithPrefix').andReturn(null) + + it 'gets hints for an incorrect answer', -> + data = ['some answers', ''] + @hinter.capture_problem('problem_graded', data, 'fake element') + expect($.postWithPrefix).toHaveBeenCalledWith("#{@hinter.url}/get_hint", 'some answers', jasmine.any(Function)) + + it 'gets feedback for a correct answer', -> + data = ['some answers', ''] + @hinter.capture_problem('problem_graded', data, 'fake element') + expect($.postWithPrefix).toHaveBeenCalledWith("#{@hinter.url}/get_feedback", 'some answers', jasmine.any(Function)) + + diff --git a/common/lib/xmodule/xmodule/js/src/capa/display.coffee b/common/lib/xmodule/xmodule/js/src/capa/display.coffee index ab64617644..61df101d08 100644 --- a/common/lib/xmodule/xmodule/js/src/capa/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/capa/display.coffee @@ -19,7 +19,6 @@ class @Problem problem_prefix = @element_id.replace(/problem_/,'') @inputs = @$("[id^=input_#{problem_prefix}_]") - @$('section.action input:button').click @refreshAnswers @$('section.action input.check').click @check_fd @$('section.action input.reset').click @reset diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index 2e99b107f9..c53a2e2066 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -96,7 +96,7 @@ class @Hinter $(obj).css('margin-top', (viewbox_height - view_height) + 'px') ) - render: (content) => + render: (content) -> if content # Trim leading and trailing whitespace content = content.replace /^\s+|\s+$/g, "" From 1f9eafeedecb164b87963c3ee2b6592220e64d18 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Wed, 21 Aug 2013 15:10:34 -0400 Subject: [PATCH 183/244] Addressed some minor PR comments. --- .../xmodule/js/src/crowdsource_hinter/display.coffee | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee index c53a2e2066..f9f3e43826 100644 --- a/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee +++ b/common/lib/xmodule/xmodule/js/src/crowdsource_hinter/display.coffee @@ -28,10 +28,6 @@ class @Hinter $: (selector) -> $(selector, @el) - jq_escape: (string) => - # Escape a string for jquery selector use. - return string.replace(/[!"#$%&'()*+,.\/:;<=>?@\[\\\]^`{|}~]/g, '\\$&') - bind: => @$('input.vote').click @vote @$('input.submit-hint').click @submit_hint @@ -99,7 +95,7 @@ class @Hinter render: (content) -> if content # Trim leading and trailing whitespace - content = content.replace /^\s+|\s+$/g, "" + content = content.trim() if content @el.html(content) From 74ed2dafb3e07ed085e920eb4eba2d3024846b22 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Fri, 23 Aug 2013 10:19:36 -0400 Subject: [PATCH 184/244] Improved docstrings. --- .../lib/xmodule/xmodule/crowdsource_hinter.py | 7 +++++-- lms/djangoapps/instructor/hint_manager.py | 19 ++++++++++++++++--- 2 files changed, 21 insertions(+), 5 deletions(-) diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index 90699beda6..f2d21c459b 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -182,9 +182,10 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Args: `data` -- must be interpretable by answer_to_str. Output keys: - - 'best_hint' is the hint text with the most votes. - - 'rand_hint_1' and 'rand_hint_2' are two random hints to the answer in `data`. + - 'hints' is a list of hint strings to show to the user. - 'answer' is the parsed answer that was submitted. + Will record the user's wrong answer in user_submissions, and the hints shown + in previous_answers. """ # First, validate our inputs. try: @@ -251,6 +252,8 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): Output keys: - 'answer_to_hints': a nested dictionary. answer_to_hints[answer][hint_pk] returns the text of the hint. + - 'user_submissions': the same thing as self.user_submissions. A list of + the answers that the user previously submitted. """ # The student got it right. # Did he submit at least one wrong answer? diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 3616445543..71c2beb7d5 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -1,8 +1,10 @@ """ Views for hint management. -Along with the crowdsource_hinter xmodule, this code is still -experimental, and should not be used in new courses, yet. +Get to these views through courseurl/hint_manager. +For example: https://courses.edx.org/courses/MITx/2.01x/2013_Spring/hint_manager + +These views will only be visible if MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True """ import json @@ -23,6 +25,9 @@ from xmodule.modulestore.django import modulestore @ensure_csrf_cookie def hint_manager(request, course_id): + """ + The URL landing function for all calls to the hint manager, both POST and GET. + """ try: get_course_with_access(request.user, course_id, 'staff', depth=None) except Http404: @@ -172,7 +177,13 @@ def change_votes(request, course_id, field): Updates the number of votes. The numbered fields of `request.POST` contain [problem_id, answer, pk, new_votes] tuples. - - Very similar to `delete_hints`. Is there a way to merge them? Nah, too complicated. + See `delete_hints`. + + Example `request.POST`: + {'op': 'delete_hints', + 'field': 'mod_queue', + 1: ['problem_whatever', '42.0', '3', 42], + 2: ['problem_whatever', '32.5', '12', 9001]} """ for key in request.POST: @@ -233,6 +244,8 @@ def approve(request, course_id, field): hint list. POST: op, field (some number) -> [problem, answer, pk] + + The numbered fields are analogous to those in `delete_hints` and `change_votes`. """ for key in request.POST: From eee18c3db33f7c5d635a3c89de07a0302babf98a Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Fri, 23 Aug 2013 17:19:55 -0400 Subject: [PATCH 185/244] Add combined coverage to `rake coverage` --- rakelib/tests.rake | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/rakelib/tests.rake b/rakelib/tests.rake index f940c8f9c4..fe5d0a351d 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -133,6 +133,7 @@ task :coverage => :report_dirs do found_coverage_info = false + reports = [] TEST_TASK_DIRS.each do |dir| report_dir = report_dir_path(dir) @@ -142,11 +143,16 @@ task :coverage => :report_dirs do found_coverage_info = true end + puts "***************" + puts "Generating diff coverage report for: #{dir}" + puts "***************\n" + # Generate the coverage.py HTML report sh("coverage html --rcfile=#{dir}/.coveragerc") # Generate the coverage.py XML report sh("coverage xml -o #{report_dir}/coverage.xml --rcfile=#{dir}/.coveragerc") + reports << "#{report_dir}/coverage.xml" # Generate the diff coverage HTML report, based on the XML report sh("diff-cover #{report_dir}/coverage.xml --html-report #{report_dir}/diff_cover.html") @@ -158,5 +164,13 @@ task :coverage => :report_dirs do if not found_coverage_info puts "No coverage info found. Run `rake test` before running `rake coverage`." + else + puts "***************" + puts "Generating combined diff coverage report" + puts "Combined over: #{TEST_TASK_DIRS.join(', ')}" + puts "***************\n" + sh("diff-cover #{reports.join(' ')} --html-report #{REPORT_DIR}/diff_coverage_combined.html") + sh("diff-cover #{reports.join(' ')}") + puts "\n" end end From 6e64e994f66138533b844c4def9755140b95ef74 Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Mon, 26 Aug 2013 09:46:29 -0400 Subject: [PATCH 186/244] Fixed a test broken when the mixed modulestore was introduced. --- lms/djangoapps/instructor/hint_manager.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lms/djangoapps/instructor/hint_manager.py b/lms/djangoapps/instructor/hint_manager.py index 71c2beb7d5..f97115f19b 100644 --- a/lms/djangoapps/instructor/hint_manager.py +++ b/lms/djangoapps/instructor/hint_manager.py @@ -215,7 +215,7 @@ def add_hint(request, course_id, field): # Validate the answer. This requires initializing the xmodules, which # is annoying. loc = Location(problem_id) - descriptors = modulestore().get_items(loc) + descriptors = modulestore().get_items(loc, course_id=course_id) m_d_c = model_data.ModelDataCache(descriptors, course_id, request.user) hinter_module = module_render.get_module(request.user, request, loc, m_d_c, course_id) if not hinter_module.validate_answer(answer): From 935ebc4e7a6992c7b492e104665d8db297236f2e Mon Sep 17 00:00:00 2001 From: Adam Palay Date: Fri, 23 Aug 2013 10:03:33 -0400 Subject: [PATCH 187/244] remove unused registration templates --- .../registration/change_password.html | 55 ----------------- lms/templates/registration/logged_out.html | 12 ---- lms/templates/registration/login.html | 28 --------- lms/templates/registration/login_error.html | 0 lms/templates/registration/logout.html | 9 --- .../registration/password_change_done.html | 15 ----- .../registration/password_change_form.html | 51 ---------------- .../registration/registration_complete.html | 9 --- .../registration/registration_form.html | 59 ------------------- 9 files changed, 238 deletions(-) delete mode 100644 lms/templates/registration/change_password.html delete mode 100644 lms/templates/registration/logged_out.html delete mode 100644 lms/templates/registration/login.html delete mode 100644 lms/templates/registration/login_error.html delete mode 100644 lms/templates/registration/logout.html delete mode 100644 lms/templates/registration/password_change_done.html delete mode 100644 lms/templates/registration/password_change_form.html delete mode 100644 lms/templates/registration/registration_complete.html delete mode 100644 lms/templates/registration/registration_form.html diff --git a/lms/templates/registration/change_password.html b/lms/templates/registration/change_password.html deleted file mode 100644 index 5a2036d1dc..0000000000 --- a/lms/templates/registration/change_password.html +++ /dev/null @@ -1,55 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load i18n admin_modify adminmedia %} -{% load url from future %} -{% block extrahead %}{{ block.super }} -{% url 'admin:jsi18n' as jsi18nurl %} - -{% endblock %} -{% block extrastyle %}{{ block.super }}{% endblock %} -{% block bodyclass %}{{ opts.app_label }}-{{ opts.object_name.lower }} change-form{% endblock %} -{% block breadcrumbs %}{% if not is_popup %} - -{% endif %}{% endblock %} -{% block content %}
    -{% csrf_token %}{% block form_top %}{% endblock %} -
    -{% if is_popup %}{% endif %} -{% if form.errors %} -

    - {% blocktrans count form.errors.items|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} -

    -{% endif %} - -

    {% blocktrans with original.username as username %}Enter a new password for the user {{ username }}.{% endblocktrans %}

    - -
    - -
    - {{ form.password1.errors }} - {# TODO: get required class on label_tag #} - {{ form.password1 }} -
    - -
    - {{ form.password2.errors }} - {# TODO: get required class on label_tag #} - {{ form.password2 }} -

    {% trans 'Enter the same password as above, for verification.' %}

    -
    - -
    - -
    - -
    - - -
    -
    -{% endblock %} diff --git a/lms/templates/registration/logged_out.html b/lms/templates/registration/logged_out.html deleted file mode 100644 index d339ef0a49..0000000000 --- a/lms/templates/registration/logged_out.html +++ /dev/null @@ -1,12 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load i18n %} - -{% block breadcrumbs %}{% endblock %} - -{% block content %} - -

    {% trans "Thanks for spending some quality time with the Web site today." %}

    - -

    {% trans 'Log in again' %}

    - -{% endblock %} diff --git a/lms/templates/registration/login.html b/lms/templates/registration/login.html deleted file mode 100644 index d8fb92855e..0000000000 --- a/lms/templates/registration/login.html +++ /dev/null @@ -1,28 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> -{% extends "registration/base.html" %} - -{% block title %}${_("Log in")}{% endblock %} - -{% block content %} - -

    ${_("Log in")}

    - -{% if form.errors %} -

    ${_("Please correct the errors below:")}

    -{% endif %} - -
    -{% csrf_token %} -
    -
    {% if form.username.errors %} {{ form.username.errors|join:", " }}{% endif %}
    -
    {{ form.username }}
    -
    {% if form.password.errors %} {{ form.password.errors|join:", " }}{% endif %}
    -
    {{ form.password }}
    -
    -
    -
    -{% endblock %} - -{% block content-related %} -

    ${_("If you don't have an account, you can {link_start}sign up{link_end} for one.").format(link_start='', link_end='')} -{% endblock %} diff --git a/lms/templates/registration/login_error.html b/lms/templates/registration/login_error.html deleted file mode 100644 index e69de29bb2..0000000000 diff --git a/lms/templates/registration/logout.html b/lms/templates/registration/logout.html deleted file mode 100644 index a3ff64507a..0000000000 --- a/lms/templates/registration/logout.html +++ /dev/null @@ -1,9 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> -{% extends "registration/base.html" %} - -{% block title %}${_("Logged out")}{% endblock %/} - -{% block content %} -

    ${_("You've been logged out.")}

    -

    ${_("Thanks for stopping by; when you come back, don't forget to {link_start}log in{link_end} again.").format(link_start='', link_end='')}

    -{% endblock %} diff --git a/lms/templates/registration/password_change_done.html b/lms/templates/registration/password_change_done.html deleted file mode 100644 index 0c0690d5e2..0000000000 --- a/lms/templates/registration/password_change_done.html +++ /dev/null @@ -1,15 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load i18n %} -{% load url from future %} -{% block userlinks %}{% url 'django-admindocs-docroot' as docsroot %}{% if docsroot %}{% trans 'Documentation' %} / {% endif %}{% trans 'Change password' %} / {% trans 'Log out' %}{% endblock %} -{% block breadcrumbs %}{% endblock %} - -{% block title %}{% trans 'Password change successful' %}{% endblock %} - -{% block content %} - -

    {% trans 'Password change successful' %}

    - -

    {% trans 'Your password was changed.' %}

    - -{% endblock %} diff --git a/lms/templates/registration/password_change_form.html b/lms/templates/registration/password_change_form.html deleted file mode 100644 index 23d6c1d8af..0000000000 --- a/lms/templates/registration/password_change_form.html +++ /dev/null @@ -1,51 +0,0 @@ -{% extends "admin/base_site.html" %} -{% load i18n adminmedia %} -{% load url from future %} -{% block extrastyle %}{{ block.super }}{% endblock %} -{% block userlinks %}{% url 'django-admindocs-docroot' as docsroot %}{% if docsroot %}{% trans 'Documentation' %} / {% endif %} {% trans 'Change password' %} / {% trans 'Log out' %}{% endblock %} -{% block breadcrumbs %}{% endblock %} - -{% block title %}{% trans 'Password change' %}{% endblock %} - -{% block content %}
    - -
    {% csrf_token %} -
    -{% if form.errors %} -

    - {% blocktrans count form.errors.items|length as counter %}Please correct the error below.{% plural %}Please correct the errors below.{% endblocktrans %} -

    -{% endif %} - -

    {% trans 'Password change' %}

    - -

    {% trans "Please enter your old password, for security's sake, and then enter your new password twice so we can verify you typed it in correctly." %}

    - -
    - -
    - {{ form.old_password.errors }} - {{ form.old_password }} -
    - -
    - {{ form.new_password1.errors }} - {{ form.new_password1 }} -
    - -
    -{{ form.new_password2.errors }} - {{ form.new_password2 }} -
    - -
    - -
    - -
    - - -
    -
    - -{% endblock %} diff --git a/lms/templates/registration/registration_complete.html b/lms/templates/registration/registration_complete.html deleted file mode 100644 index d6f9a5659a..0000000000 --- a/lms/templates/registration/registration_complete.html +++ /dev/null @@ -1,9 +0,0 @@ -{% extends "registration/base.html" %} -{% load i18n %} - -{% block title %}{% trans "Registration complete" %}{% endblock %} - -{% block content %} -

    {% trans Check your email %}

    -

    {% trans "An activation link has been sent to the email address you supplied, along with instructions for activating your account."%}

    -{% endblock %} \ No newline at end of file diff --git a/lms/templates/registration/registration_form.html b/lms/templates/registration/registration_form.html deleted file mode 100644 index f90392d73f..0000000000 --- a/lms/templates/registration/registration_form.html +++ /dev/null @@ -1,59 +0,0 @@ -<%! from django.utils.translation import ugettext as _ %> -{% extends "registration/base.html" %} - -{% block title %}${_("Sign up")}{% endblock %} - -{% block content %} - - {% if form.errors %} -

    ${_("Please correct the errors below: {{ form.non_field_errors }}")}

    - {% endif %} - -

    ${_("Create an account")}

    - -
    -{% csrf_token %} -

    - - {% if form.username.errors %} -

    {{ form.username.errors.as_text }}

    - {% endif %} - {{ form.username }} -

    -

    - - {% if form.email.errors %} -

    {{ form.email.errors.as_text }}

    - {% endif %} - {{ form.email }} -

    -

    - - {% if form.password1.errors %} -

    {{ form.password1.errors.as_text }}

    - {% endif %} - {{ form.password1 }} -

    -

    - - {% if form.password2.errors %} -

    {{ form.password2.errors.as_text }}

    - {% endif %} - {{ form.password2 }} -

    -

    -
    - -{% endblock %} - -{% block content-related %} -

    ${_("Fill out the form to the left (all fields are required), and your " -"account will be created; you'll be sent an email with instructions on how " -"to finish your registration.")}

    - -

    ${_("We'll only use your email to send you signup instructions. We hate spam " -"as much as you do.")}

    - -

    ${_("This account will let you log into the ticket tracker, claim tickets, " -"and be exempt from spam filtering")}.

    -{% endblock %} From a4d1c52ec8ce67fd78def2f36d6b7675ad89503c Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Fri, 23 Aug 2013 14:44:56 -0400 Subject: [PATCH 188/244] Change .pep8 to ignore migrations files --- .pep8 | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.pep8 b/.pep8 index 25d0edbcb4..badb0219a0 100644 --- a/.pep8 +++ b/.pep8 @@ -1,2 +1,3 @@ [pep8] -ignore=E501 \ No newline at end of file +ignore=E501 +exclude=migrations \ No newline at end of file From ff9e01b4031996e6c27c44073e62df045a42a292 Mon Sep 17 00:00:00 2001 From: Sarina Canelake Date: Mon, 5 Aug 2013 15:04:00 -0400 Subject: [PATCH 189/244] Add a python prefix for `diff-quality` task --- rakelib/quality.rake | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/rakelib/quality.rake b/rakelib/quality.rake index 7cbe10ce1f..a3314919bf 100644 --- a/rakelib/quality.rake +++ b/rakelib/quality.rake @@ -54,6 +54,7 @@ task :quality => dquality_dir do sh("diff-quality --violations=pep8") # Generage diff-quality html report for pylint, and print to console - sh("diff-quality --violations=pylint --html-report #{dquality_dir}/diff_quality_pylint.html") - sh("diff-quality --violations=pylint") + pythonpath_prefix = "PYTHONPATH=$PYTHONPATH:lms:lms/djangoapps:lms/lib:cms:cms/djangoapps:cms/lib:common:common/djangoapps:common/lib" + sh("#{pythonpath_prefix} diff-quality --violations=pylint --html-report #{dquality_dir}/diff_quality_pylint.html") + sh("#{pythonpath_prefix} diff-quality --violations=pylint") end \ No newline at end of file From 3c55a44b6b5b134efccb821ea71f4e2549e2cf7f Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Mon, 26 Aug 2013 10:36:24 -0400 Subject: [PATCH 190/244] Address review comments --- .../xmodule/combined_open_ended_module.py | 2 +- .../combined_open_ended_modulev1.py | 17 ++++++++++++----- .../xmodule/tests/test_combined_open_ended.py | 14 ++++++++++---- .../xmodule/tests/test_util_open_ended.py | 8 ++++++++ 4 files changed, 31 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 2e193a20b4..5354915b86 100644 --- a/common/lib/xmodule/xmodule/combined_open_ended_module.py +++ b/common/lib/xmodule/xmodule/combined_open_ended_module.py @@ -186,7 +186,7 @@ class CombinedOpenEndedFields(object): old_task_states = List( help=("A list of lists of state dictionaries for student states that are saved." "This field is only populated if the instructor changes tasks after" - "the module is created and students have attempted it (ie changes a self assessed problem to " + "the module is created and students have attempted it (for example changes a self assessed problem to " "self and peer assessed."), scope = Scope.user_state ) 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 b6b0d99456..6ab8db8334 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 @@ -173,13 +173,14 @@ class CombinedOpenEndedV1Module(): If that is the case, moved it to old_task_states and delete task_states. """ - #If we are on a task that is greater than the number of available tasks, it is an invalid state - #If the current task number is greater than the number of tasks we have in the xml definition, our state is invalid. + # If we are on a task that is greater than the number of available tasks, + # it is an invalid state. If the current task number is greater than the number of tasks + # we have in the definition, our state is invalid. if self.current_task_number > len(self.task_states) or self.current_task_number > len(self.task_xml): - self.current_task_number = min([len(self.task_states), len(self.task_xml)]) - 1 + self.current_task_number = max(min(len(self.task_states), len(self.task_xml)) - 1, 0) #If the length of the task xml is less than the length of the task states, state is invalid if len(self.task_xml) < len(self.task_states): - self.current_task_number = 0 + self.current_task_number = len(self.task_xml) - 1 self.task_states = self.task_states[:len(self.task_xml)] #Loop through each task state and make sure it matches the xml definition for (i, t) in enumerate(self.task_states): @@ -221,8 +222,14 @@ class CombinedOpenEndedV1Module(): break def reset_task_state(self, message=""): - info_message = "Combined open ended user state for user {0} in location {1} was invalid. Reset it. {2}".format(self.system.anonymous_student_id, self.location.url(), message) + """ + Resets the task states. Moves current task state to an old_state variable, and then makes the task number 0. + :param message: A message to put in the log. + :return: None + """ + info_message = "Combined open ended user state for user {0} in location {1} was invalid. It has been reset, and you now have a new attempt. {2}".format(self.system.anonymous_student_id, self.location.url(), message) self.current_task_number = 0 + self.student_attempts = 0 self.old_task_states.append(self.task_states) self.task_states = [] log.info(info_message) 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 855ba15231..c07fd7e187 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -572,9 +572,9 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): self.assertEqual(score_dict['score'], 15.0) self.assertEqual(score_dict['total'], 15.0) - def ai_state(self, task_state, task_number, task_xml): + def generate_oe_module(self, task_state, task_number, task_xml): """ - See if state is properly reset or left unchanged + Return a combined open ended module with the specified parameters """ definition = {'prompt': etree.XML(self.prompt), 'rubric': etree.XML(self.rubric), 'task_xml': task_xml} @@ -595,17 +595,23 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): """ See if state is properly reset """ - combinedoe = self.ai_state(task_state, task_number, [self.task_xml2]) + combinedoe = self.generate_oe_module(task_state, task_number, [self.task_xml2]) html = combinedoe.get_html() self.assertIsInstance(html, basestring) + score = combinedoe.get_score() + if combinedoe.is_scored: + self.assertEqual(score['score'], 0) + else: + self.assertEqual(score['score'], None) + def ai_state_success(self, task_state, task_number=None, iscore=2, tasks=None): """ See if state stays the same """ if tasks is None: tasks = [self.task_xml1, self.task_xml2] - combinedoe = self.ai_state(task_state, task_number, tasks) + combinedoe = self.generate_oe_module(task_state, task_number, tasks) html = combinedoe.get_html() self.assertIsInstance(html, basestring) score = combinedoe.get_score() diff --git a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py index 79b0e77f80..4e2657a2c0 100644 --- a/common/lib/xmodule/xmodule/tests/test_util_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_util_open_ended.py @@ -53,18 +53,26 @@ class DummyModulestore(object): descriptor = self.modulestore.get_instance(course.id, location, depth=None) return descriptor.xmodule(self.test_system) +# Task state for a module with self assessment then instructor assessment. TEST_STATE_SA_IN = ["{\"child_created\": false, \"child_attempts\": 2, \"version\": 1, \"child_history\": [{\"answer\": \"However venture pursuit he am mr cordial. Forming musical am hearing studied be luckily. Ourselves for determine attending how led gentleman sincerity. Valley afford uneasy joy she thrown though bed set. In me forming general prudent on country carried. Behaved an or suppose justice. Seemed whence how son rather easily and change missed. Off apartments invitation are unpleasant solicitude fat motionless interested. Hardly suffer wisdom wishes valley as an. As friendship advantages resolution it alteration stimulated he or increasing. \\r

    Now led tedious shy lasting females off. Dashwood marianne in of entrance be on wondered possible building. Wondered sociable he carriage in speedily margaret. Up devonshire of he thoroughly insensible alteration. An mr settling occasion insisted distance ladyship so. Not attention say frankness intention out dashwoods now curiosity. Stronger ecstatic as no judgment daughter speedily thoughts. Worse downs nor might she court did nay forth these. \", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}, {\"answer\": \"Delightful remarkably mr on announcing themselves entreaties favourable. About to in so terms voice at. Equal an would is found seems of. The particular friendship one sufficient terminated frequently themselves. It more shed went up is roof if loud case. Delay music in lived noise an. Beyond genius really enough passed is up. \\r

    John draw real poor on call my from. May she mrs furnished discourse extremely. Ask doubt noisy shade guest did built her him. Ignorant repeated hastened it do. Consider bachelor he yourself expenses no. Her itself active giving for expect vulgar months. Discovery commanded fat mrs remaining son she principle middleton neglected. Be miss he in post sons held. No tried is defer do money scale rooms. \", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"However venture pursuit he am mr cordial. Forming musical am hearing studied be luckily. Ourselves for determine attending how led gentleman sincerity. Valley afford uneasy joy she thrown though bed set. In me forming general prudent on country carried. Behaved an or suppose justice. Seemed whence how son rather easily and change missed. Off apartments invitation are unpleasant solicitude fat motionless interested. Hardly suffer wisdom wishes valley as an. As friendship advantages resolution it alteration stimulated he or increasing. \\r

    Now led tedious shy lasting females off. Dashwood marianne in of entrance be on wondered possible building. Wondered sociable he carriage in speedily margaret. Up devonshire of he thoroughly insensible alteration. An mr settling occasion insisted distance ladyship so. Not attention say frankness intention out dashwoods now curiosity. Stronger ecstatic as no judgment daughter speedily thoughts. Worse downs nor might she court did nay forth these. \", \"post_assessment\": \"{\\\"submission_id\\\": 1460, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5413, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}, {\"answer\": \"Delightful remarkably mr on announcing themselves entreaties favourable. About to in so terms voice at. Equal an would is found seems of. The particular friendship one sufficient terminated frequently themselves. It more shed went up is roof if loud case. Delay music in lived noise an. Beyond genius really enough passed is up. \\r

    John draw real poor on call my from. May she mrs furnished discourse extremely. Ask doubt noisy shade guest did built her him. Ignorant repeated hastened it do. Consider bachelor he yourself expenses no. Her itself active giving for expect vulgar months. Discovery commanded fat mrs remaining son she principle middleton neglected. Be miss he in post sons held. No tried is defer do money scale rooms. \", \"post_assessment\": \"{\\\"submission_id\\\": 1462, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5418, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"post_assessment\"}"] +# Mock instance state. Should receive a score of 15. MOCK_INSTANCE_STATE = r"""{"ready_to_reset": false, "skip_spelling_checks": true, "current_task_number": 1, "weight": 5.0, "graceperiod": "1 day 12 hours 59 minutes 59 seconds", "graded": "True", "task_states": ["{\"child_created\": false, \"child_attempts\": 4, \"version\": 1, \"child_history\": [{\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"[3]\", \"score\": 3}, {\"answer\": \"\", \"post_assessment\": \"[3]\", \"score\": 3}], \"max_score\": 3, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"The students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the group\\u2019s procedure, describe what additional information you would need in order to replicate the expe\", \"post_assessment\": \"{\\\"submission_id\\\": 3097, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: More grammar errors than average.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the groups procedure , describe what additional information you would need in order to replicate the expe\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3233, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"After 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\\r\\nStarting Mass (g)\\tEnding Mass (g)\\tDifference in Mass (g)\\r\\nMarble\\t 9.8\\t 9.4\\t\\u20130.4\\r\\nLimestone\\t10.4\\t 9.1\\t\\u20131.3\\r\\nWood\\t11.2\\t11.2\\t 0.0\\r\\nPlastic\\t 7.2\\t 7.1\\t\\u20130.1\\r\\nAfter reading the\", \"post_assessment\": \"{\\\"submission_id\\\": 3098, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . starting mass g ending mass g difference in mass g marble . . . limestone . . . wood . . . plastic . . . after reading the\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3235, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"To replicate the experiment, the procedure would require more detail. One piece of information that is omitted is the amount of vinegar used in the experiment. It is also important to know what temperature the experiment was kept at during the 24 hours. Finally, the procedure needs to include details about the experiment, for example if the whole sample must be submerged.\", \"post_assessment\": \"{\\\"submission_id\\\": 3099, \\\"score\\\": 3, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"to replicate the experiment , the procedure would require more detail . one piece of information that is omitted is the amount of vinegar used in the experiment . it is also important to know what temperature the experiment was kept at during the hours . finally , the procedure needs to include details about the experiment , for example if the whole sample must be submerged .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3237, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality3\\\"}\", \"score\": 3}, {\"answer\": \"e the mass of four different samples.\\r\\nPour vinegar in each of four separate, but identical, containers.\\r\\nPlace a sample of one material into one container and label. Repeat with remaining samples, placing a single sample into a single container.\\r\\nAfter 24 hours, remove the samples from the containers and rinse each sample with distilled water.\\r\\nAllow the samples to sit and dry for 30 minutes.\\r\\nDetermine the mass of each sample.\\r\\nThe students\\u2019 data are recorded in the table below.\\r\\n\", \"post_assessment\": \"{\\\"submission_id\\\": 3100, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"e the mass of four different samples . pour vinegar in each of four separate , but identical , containers . place a sample of one material into one container and label . repeat with remaining samples , placing a single sample into a single container . after hours , remove the samples from the containers and rinse each sample with distilled water . allow the samples to sit and dry for minutes . determine the mass of each sample . the students data are recorded in the table below . \\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3239, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}, {\"answer\": \"\", \"post_assessment\": \"{\\\"submission_id\\\": 3101, \\\"score\\\": 0, \\\"feedback\\\": \\\"{\\\\\\\"spelling\\\\\\\": \\\\\\\"Spelling: Ok.\\\\\\\", \\\\\\\"grammar\\\\\\\": \\\\\\\"Grammar: Ok.\\\\\\\", \\\\\\\"markup-text\\\\\\\": \\\\\\\"invalid essay .\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 3241, \\\"grader_type\\\": \\\"ML\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Response Quality0\\\"}\", \"score\": 0}], \"max_score\": 3, \"child_state\": \"done\"}"], "attempts": "10000", "student_attempts": 0, "due": null, "state": "done", "accept_file_upload": false, "display_name": "Science Question -- Machine Assessed"}""" +# Task state with self assessment only. TEST_STATE_SA = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
    'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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.\", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Censorship in the Libraries\\r
    'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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.\", \"post_assessment\": \"{\\\"submission_id\\\": 1461, \\\"score\\\": 12, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 5414, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"\\\\nIdeas\\\\n3\\\\nContent\\\\n3\\\\nOrganization\\\\n2\\\\nStyle\\\\n2\\\\nVoice\\\\n2\\\"}\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"post_assessment\"}"] +# Task state with self and then ai assessment. TEST_STATE_AI = ["{\"child_created\": false, \"child_attempts\": 2, \"version\": 1, \"child_history\": [{\"answer\": \"In libraries, there should not be censorship on materials considering that it's an individual's decision to read what they prefer. There is no appropriate standard on what makes a book offensive to a group, so it should be undetermined as to what makes a book offensive. In a public library, many children, who the books are censored for, are with their parents. Parents should make an independent choice on what they can allow their children to read. Letting society ban a book simply for the use of inappropriate materials is ridiculous. If an author spent time creating a story, it should be appreciated, and should not put on a list of no-nos. If a certain person doesn't like a book's reputation, all they have to do is not read it. Even in school systems, librarians are there to guide kids to read good books. If a child wants to read an inappropriate book, the librarian will most likely discourage him or her not to read it. In my experience, I wanted to read a book that my mother suggested to me, but as I went to the school library it turned out to be a censored book. Some parents believe children should be ignorant about offensive things written in books, but honestly many of the same ideas are exploited to them everyday on television and internet. So trying to shield your child from the bad things may be a great thing, but the efforts are usually failed attempts. It also never occurs to the people censoring the books, that some people can't afford to buy the books they want to read. The libraries, for some, are the main means for getting books. To conclude there is very little reason to ban a book from the shelves. Many of the books banned have important lessons that can be obtained through reading it. If a person doesn't like a book, the simplest thing to do is not to pick it up.\", \"post_assessment\": \"[1, 1]\", \"score\": 2}, {\"answer\": \"This is another response\", \"post_assessment\": \"[1, 1]\", \"score\": 2}], \"max_score\": 2, \"child_state\": \"done\"}", "{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"In libraries, there should not be censorship on materials considering that it's an individual's decision to read what they prefer. There is no appropriate standard on what makes a book offensive to a group, so it should be undetermined as to what makes a book offensive. In a public library, many children, who the books are censored for, are with their parents. Parents should make an independent choice on what they can allow their children to read. Letting society ban a book simply for the use of inappropriate materials is ridiculous. If an author spent time creating a story, it should be appreciated, and should not put on a list of no-nos. If a certain person doesn't like a book's reputation, all they have to do is not read it. Even in school systems, librarians are there to guide kids to read good books. If a child wants to read an inappropriate book, the librarian will most likely discourage him or her not to read it. In my experience, I wanted to read a book that my mother suggested to me, but as I went to the school library it turned out to be a censored book. Some parents believe children should be ignorant about offensive things written in books, but honestly many of the same ideas are exploited to them everyday on television and internet. So trying to shield your child from the bad things may be a great thing, but the efforts are usually failed attempts. It also never occurs to the people censoring the books, that some people can't afford to buy the books they want to read. The libraries, for some, are the main means for getting books. To conclude there is very little reason to ban a book from the shelves. Many of the books banned have important lessons that can be obtained through reading it. If a person doesn't like a book, the simplest thing to do is not to pick it up.\", \"post_assessment\": \"{\\\"submission_id\\\": 6107, \\\"score\\\": 2, \\\"feedback\\\": \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"success\\\": true, \\\"grader_id\\\": 1898718, \\\"grader_type\\\": \\\"IN\\\", \\\"rubric_scores_complete\\\": true, \\\"rubric_xml\\\": \\\"Writing Applications1 Language Conventions 1\\\"}\", \"score\": 2}, {\"answer\": \"This is another response\"}], \"max_score\": 2, \"child_state\": \"assessing\"}"] +# Task state with ai assessment only. TEST_STATE_AI2 = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"This isn't a real essay, and you should give me a zero on it. \", \"post_assessment\": \"{\\\"submission_id\\\": 18446, \\\"score\\\": [0, 1, 0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"Zero it is! \\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [1944146, 1943188, 1940991], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true, true, true], \\\"rubric_xml\\\": [\\\"Writing Applications0 Language Conventions 0\\\", \\\"Writing Applications0 Language Conventions 1\\\", \\\"Writing Applications0 Language Conventions 0\\\"]}\", \"score\": 0}], \"max_score\": 2, \"child_state\": \"post_assessment\"}"] +# Invalid task state with ai assessment. TEST_STATE_AI2_INVALID = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"This isn't a real essay, and you should give me a zero on it. \", \"post_assessment\": \"{\\\"submission_id\\\": 18446, \\\"score\\\": [0, 1, 0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\", \\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"Zero it is! \\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [1943188, 1940991], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true, true, true], \\\"rubric_xml\\\": [\\\"Writing Applications0 Language Conventions 0\\\", \\\"Writing Applications0 Language Conventions 1\\\", \\\"Writing Applications0 Language Conventions 0\\\"]}\", \"score\": 0}], \"max_score\": 2, \"child_state\": \"post_assessment\"}"] +# Self assessment state. TEST_STATE_SINGLE = ["{\"child_created\": false, \"child_attempts\": 1, \"version\": 1, \"child_history\": [{\"answer\": \"'All of us can think of a book that we hope none of our children or any other children have taken off the shelf. But if I have the right to remove that book from the shelf -- that work I abhor -- then you also have exactly the same right and so does everyone else. And then we have no books left on the shelf for any of us.' --Katherine Paterson, Author\\r

    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. \", \"post_assessment\": \"[3, 3, 2, 2, 2]\", \"score\": 12}], \"max_score\": 12, \"child_state\": \"done\"}"] +# Peer grading state. TEST_STATE_PE_SINGLE = ["{\"child_created\": false, \"child_attempts\": 0, \"version\": 1, \"child_history\": [{\"answer\": \"Passage its ten led hearted removal cordial. Preference any astonished unreserved mrs. Prosperous understood middletons in conviction an uncommonly do. Supposing so be resolving breakfast am or perfectly. Is drew am hill from mr. Valley by oh twenty direct me so. Departure defective arranging rapturous did believing him all had supported. Family months lasted simple set nature vulgar him. Picture for attempt joy excited ten carried manners talking how. Suspicion neglected he resolving agreement perceived at an. \\r

    Ye on properly handsome returned throwing am no whatever. In without wishing he of picture no exposed talking minutes. Curiosity continual belonging offending so explained it exquisite. Do remember to followed yourself material mr recurred carriage. High drew west we no or at john. About or given on witty event. Or sociable up material bachelor bringing landlord confined. Busy so many in hung easy find well up. So of exquisite my an explained remainder. Dashwood denoting securing be on perceive my laughing so. \\r

    Ought these are balls place mrs their times add she. Taken no great widow spoke of it small. Genius use except son esteem merely her limits. Sons park by do make on. It do oh cottage offered cottage in written. Especially of dissimilar up attachment themselves by interested boisterous. Linen mrs seems men table. Jennings dashwood to quitting marriage bachelor in. On as conviction in of appearance apartments boisterous. \", \"post_assessment\": \"{\\\"submission_id\\\": 1439, \\\"score\\\": [0], \\\"feedback\\\": [\\\"{\\\\\\\"feedback\\\\\\\": \\\\\\\"\\\\\\\"}\\\"], \\\"success\\\": true, \\\"grader_id\\\": [5337], \\\"grader_type\\\": \\\"PE\\\", \\\"rubric_scores_complete\\\": [true], \\\"rubric_xml\\\": [\\\"\\\\nIdeas\\\\n0\\\\nContent\\\\n0\\\\nOrganization\\\\n0\\\\nStyle\\\\n0\\\\nVoice\\\\n0\\\"]}\", \"score\": 0}], \"max_score\": 12, \"child_state\": \"done\"}"] \ No newline at end of file From bf331c9d7e39e4c14fb56c2d08f02634b570bfe7 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Mon, 26 Aug 2013 12:41:04 -0400 Subject: [PATCH 191/244] Upgrade newrelic agent to the latest version --- docs/shared/requirements.txt | 2 +- requirements/edx/base.txt | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/shared/requirements.txt b/docs/shared/requirements.txt index be4f8cb503..7dc7da8a75 100644 --- a/docs/shared/requirements.txt +++ b/docs/shared/requirements.txt @@ -58,7 +58,7 @@ xmltodict==0.4.1 # Metrics gathering and monitoring dogapi==1.2.1 dogstatsd-python==0.2.1 -newrelic==1.8.0.13 +newrelic==1.13.1.31 # Used for Internationalization and localization Babel==1.3 diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index 9179315797..13a1faf2a5 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -63,7 +63,7 @@ watchdog==0.6.0 # Metrics gathering and monitoring dogapi==1.2.1 dogstatsd-python==0.2.1 -newrelic==1.8.0.13 +newrelic==1.13.1.31 # Used for documentation gathering sphinx==1.1.3 From 12cf060ba7059a521203a85ad56ef62b3fae67c7 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 26 Aug 2013 15:57:58 -0400 Subject: [PATCH 192/244] Silence doc test errors/warnings --- docs/course_authors/Makefile | 4 ++-- docs/data/Makefile | 4 ++-- docs/developers/Makefile | 2 +- rakelib/docs.rake | 2 +- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/docs/course_authors/Makefile b/docs/course_authors/Makefile index b05c2d944a..d02280e2fd 100644 --- a/docs/course_authors/Makefile +++ b/docs/course_authors/Makefile @@ -15,13 +15,13 @@ endif Q_FLAG = ifeq ($(quiet), true) -Q_FLAG = -q +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/docs/data/Makefile b/docs/data/Makefile index 272527a445..2096b14f91 100644 --- a/docs/data/Makefile +++ b/docs/data/Makefile @@ -15,13 +15,13 @@ endif Q_FLAG = ifeq ($(quiet), true) -Q_FLAG = -q +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/docs/developers/Makefile b/docs/developers/Makefile index 65fea6d46e..faa8708cd4 100644 --- a/docs/developers/Makefile +++ b/docs/developers/Makefile @@ -15,7 +15,7 @@ endif Q_FLAG = ifeq ($(quiet), true) -Q_FLAG = -q +Q_FLAG = -Q endif # Internal variables. diff --git a/rakelib/docs.rake b/rakelib/docs.rake index 3af50e2492..025bee3727 100644 --- a/rakelib/docs.rake +++ b/rakelib/docs.rake @@ -18,7 +18,7 @@ task :builddocs, [:type, :quiet] do |t, args| if args.quiet == 'verbose' sh('make html quiet=false') else - sh('make html') + sh('make html quiet=true') end end end From fe727309694b3450313d5dcd57a12860957ff9ac Mon Sep 17 00:00:00 2001 From: Will Daly Date: Mon, 26 Aug 2013 20:03:18 -0400 Subject: [PATCH 193/244] Integrate js-test-tool into testing infrastructure --- cms/envs/jasmine.py | 53 ------ cms/static/coffee/files.json | 20 --- cms/static/coffee/spec/helpers.coffee | 2 +- cms/static/js_test.yml | 96 ++++++++++ cms/static/xmodule_js | 1 + cms/urls.py | 4 - common/lib/xmodule/xmodule/js/common_static | 1 + .../js/fixtures}/test.mp4 | Bin .../js/fixtures}/test.ogv | Bin .../js/fixtures}/test.webm | Bin .../xmodule/js/fixtures/video_all.html | 8 +- .../xmodule/js/fixtures/video_html5.html | 8 +- common/lib/xmodule/xmodule/js/js_test.yml | 64 +++++++ .../js/spec/annotatable/display_spec.coffee | 2 +- .../xmodule/js/spec/capa/display_spec.coffee | 1 - .../lib/xmodule/xmodule/js/spec/helper.coffee | 2 +- .../xmodule/js/spec/html/edit_spec.coffee | 2 +- .../xmodule/js/spec/video/general_spec.js | 8 +- .../xmodule/js/spec/video/html5_video_spec.js | 7 +- .../xmodule/xmodule/js/spec/video/readme.md | 8 - .../js/spec/video/video_caption_spec.js | 14 +- .../js/spec/video/video_control_spec.js | 2 +- .../js/spec/video/video_player_spec.js | 2 +- .../spec/video/video_progress_slider_spec.js | 2 +- .../spec/video/video_quality_control_spec.js | 2 +- .../js/spec/video/video_speed_control_spec.js | 2 +- .../spec/video/video_volume_control_spec.js | 2 +- common/static/js/test/add_ajax_prefix.js | 5 + common/static/js_test.yml | 84 +++++++++ docs/internal/testing.md | 42 ++--- jenkins/test.sh | 7 + jenkins/test_acceptance.sh | 2 +- lms/envs/jasmine.py | 47 ----- lms/static/coffee/README.md | 5 +- lms/static/coffee/files.json | 6 - lms/static/coffee/spec/helper.coffee | 2 +- lms/static/js/Markdown.Editor.js | 2 +- lms/static/js_test.yml | 88 ++++++++++ lms/static/xmodule_js | 1 + lms/urls.py | 3 - package.json | 4 +- rakelib/deprecated.rake | 46 ++++- rakelib/jasmine.rake | 165 ------------------ rakelib/js_test.rake | 85 +++++++++ rakelib/tests.rake | 51 ++---- requirements/edx/base.txt | 1 - requirements/edx/github.txt | 1 + 47 files changed, 546 insertions(+), 414 deletions(-) delete mode 100644 cms/envs/jasmine.py delete mode 100644 cms/static/coffee/files.json create mode 100644 cms/static/js_test.yml create mode 120000 cms/static/xmodule_js create mode 120000 common/lib/xmodule/xmodule/js/common_static rename common/lib/xmodule/{test_files => xmodule/js/fixtures}/test.mp4 (100%) rename common/lib/xmodule/{test_files => xmodule/js/fixtures}/test.ogv (100%) rename common/lib/xmodule/{test_files => xmodule/js/fixtures}/test.webm (100%) create mode 100644 common/lib/xmodule/xmodule/js/js_test.yml delete mode 100644 common/lib/xmodule/xmodule/js/spec/video/readme.md create mode 100644 common/static/js/test/add_ajax_prefix.js create mode 100644 common/static/js_test.yml delete mode 100644 lms/envs/jasmine.py delete mode 100644 lms/static/coffee/files.json create mode 100644 lms/static/js_test.yml create mode 120000 lms/static/xmodule_js delete mode 100644 rakelib/jasmine.rake create mode 100644 rakelib/js_test.rake diff --git a/cms/envs/jasmine.py b/cms/envs/jasmine.py deleted file mode 100644 index a4b8292d71..0000000000 --- a/cms/envs/jasmine.py +++ /dev/null @@ -1,53 +0,0 @@ -""" -This configuration is used for running jasmine tests -""" - -# We intentionally define lots of variables that aren't used, and -# want to import all variables from base settings files -# pylint: disable=W0401, W0614 - -from .test import * -from logsettings import get_logger_config - -ENABLE_JASMINE = True -DEBUG = True - -LOGGING = get_logger_config(TEST_ROOT / "log", - logging_env="dev", - tracking_filename="tracking.log", - dev_env=True, - debug=True, - local_loglevel='ERROR', - console_loglevel='ERROR') - -PIPELINE_JS['js-test-source'] = { - 'source_filenames': sum([ - pipeline_group['source_filenames'] - for group_name, pipeline_group - in sorted(PIPELINE_JS.items(), key=lambda item: item[1].get('test_order', 1e100)) - if group_name != 'spec' - ], []), - 'output_filename': 'js/cms-test-source.js' -} - -PIPELINE_JS['spec'] = { - 'source_filenames': sorted(rooted_glob(PROJECT_ROOT / 'static/', 'coffee/spec/**/*.js')), - 'output_filename': 'js/cms-spec.js' -} - -JASMINE_TEST_DIRECTORY = PROJECT_ROOT + '/static/coffee' -JASMINE_REPORT_DIR = os.environ.get('JASMINE_REPORT_DIR', 'reports/cms/jasmine') - -TEMPLATE_CONTEXT_PROCESSORS += ('settings_context_processor.context_processors.settings',) -TEMPLATE_VISIBLE_SETTINGS = ('JASMINE_REPORT_DIR', ) - -STATICFILES_DIRS.append(REPO_ROOT/'node_modules/phantom-jasmine/lib') -STATICFILES_DIRS.append(REPO_ROOT/'node_modules/jasmine-reporters/src') - -# Remove the localization middleware class because it requires the test database -# to be sync'd and migrated in order to run the jasmine tests interactively -# with a browser -MIDDLEWARE_CLASSES = tuple(e for e in MIDDLEWARE_CLASSES \ - if e != 'django.middleware.locale.LocaleMiddleware') - -INSTALLED_APPS += ('django_jasmine', 'settings_context_processor') diff --git a/cms/static/coffee/files.json b/cms/static/coffee/files.json deleted file mode 100644 index 3964bee455..0000000000 --- a/cms/static/coffee/files.json +++ /dev/null @@ -1,20 +0,0 @@ -{ - "static_files": [ - "../jsi18n/", - "js/vendor/RequireJS.js", - "js/vendor/jquery.min.js", - "js/vendor/jquery-ui.min.js", - "js/vendor/jquery.ui.draggable.js", - "js/vendor/jquery.cookie.js", - "js/vendor/json2.js", - "js/vendor/underscore-min.js", - "js/vendor/underscore.string.min.js", - "js/vendor/backbone-min.js", - "js/vendor/backbone-associations-min.js", - "js/vendor/jquery.leanModal.min.js", - "js/vendor/jquery.form.js", - "js/vendor/sinon-1.7.1.js", - "js/vendor/jasmine-stealth.js", - "js/test/i18n.js" - ] -} diff --git a/cms/static/coffee/spec/helpers.coffee b/cms/static/coffee/spec/helpers.coffee index 116983edf5..a03e2a0e56 100644 --- a/cms/static/coffee/spec/helpers.coffee +++ b/cms/static/coffee/spec/helpers.coffee @@ -1,4 +1,4 @@ -jasmine.getFixtures().fixturesPath = 'fixtures' +jasmine.getFixtures().fixturesPath += 'coffee/fixtures' # Stub jQuery.cookie @stubCookies = diff --git a/cms/static/js_test.yml b/cms/static/js_test.yml new file mode 100644 index 0000000000..cc4ee97d49 --- /dev/null +++ b/cms/static/js_test.yml @@ -0,0 +1,96 @@ +--- +# JavaScript test suite description +# +# +# To run all the tests and print results to the console: +# +# js-test-tool run TEST_SUITE --use-firefox +# +# where `TEST_SUITE` is this file. +# +# +# To run the tests in your default browser ("dev mode"): +# +# js-test-tool dev TEST_SUITE +# + +test_suite_name: cms + +test_runner: jasmine + +# Path prepended to source files in the coverage report (optional) +# For example, if the source path +# is "src/source.js" (relative to this YAML file) +# and the prepend path is "base/dir" +# then the coverage report will show +# "base/dir/src/source.js" +prepend_path: cms/static + +# Paths to library JavaScript files (optional) +lib_paths: + - xmodule_js/common_static/coffee/src/ajax_prefix.js + - xmodule_js/common_static/coffee/src/logger.js + - xmodule_js/common_static/js/vendor/RequireJS.js + - xmodule_js/common_static/js/vendor/json2.js + - xmodule_js/common_static/js/vendor/jquery.min.js + - xmodule_js/common_static/js/vendor/jquery-ui.min.js + - xmodule_js/common_static/js/vendor/jquery.cookie.js + - xmodule_js/common_static/js/vendor/jquery.qtip.min.js + - xmodule_js/common_static/js/vendor/swfobject/swfobject.js + - xmodule_js/common_static/js/vendor/jquery.ba-bbq.min.js + - xmodule_js/common_static/js/vendor/annotator.min.js + - xmodule_js/common_static/js/vendor/annotator.store.min.js + - xmodule_js/common_static/js/vendor/annotator.tags.min.js + - xmodule_js/common_static/js/vendor/underscore-min.js + - xmodule_js/common_static/js/vendor/underscore.string.min.js + - xmodule_js/common_static/js/vendor/backbone-min.js + - xmodule_js/common_static/js/vendor/backbone-associations-min.js + - xmodule_js/common_static/js/vendor/timepicker/jquery.timepicker.js + - xmodule_js/common_static/js/vendor/jquery.leanModal.min.js + - xmodule_js/common_static/js/vendor/jquery.form.js + - xmodule_js/common_static/js/vendor/sinon-1.7.1.js + - xmodule_js/common_static/js/vendor/jasmine-jquery.js + - xmodule_js/common_static/js/vendor/jasmine-stealth.js + - xmodule_js/src/xmodule.js + - xmodule_js/src + - xmodule_js/common_static/js/test/add_ajax_prefix.js + +# Paths to source JavaScript files +src_paths: + - coffee/src + - js + +# Paths to spec (test) JavaScript files +spec_paths: + - coffee/spec/helpers.js + - coffee/spec + +# Paths to fixture files (optional) +# The fixture path will be set automatically when using jasmine-jquery. +# (https://github.com/velesin/jasmine-jquery) +# +# You can then access fixtures using paths relative to +# the test suite description: +# +# loadFixtures('path/to/fixture/fixture.html'); +# +fixture_paths: + - coffee/fixtures + +# Regular expressions used to exclude *.js files from +# appearing in the test runner page. +# Files are included by default, which means that they +# are loaded using a SnuggleTeX - ASCIIMathML Enrichment Demo + + + + + + + +

    SnuggleTeX (1.2.2)

    +
    + + +
    + +
    +

    ASCIIMathML Enrichment Demo

    +

    Input

    +

    + This demo is similar to the + MathML Semantic Enrichnment Demo + but uses + ASCIIMathML as + an alternative input format, which provides real-time feedback as you + type but can often generate MathML with odd semantics in it. + SnuggleTeX includes some functionality that can to convert this raw MathML into + something equivalent to its own MathML output, thereby allowing you to + semantically enrich it in + certain simple cases, making ASCIIMathML a possibly viable input format + for simple semantic maths. + +

    +

    + To try the demo, simply enter some some ASCIIMathML into the box below. + You should see a real time preview of this while you type. + Then hit Go! to use SnuggleTeX to semantically enrich your + input. + +

    +
    +
    + ASCIIMath Input: +
    +
    +

    Live Preview

    +

    + This is a MathML rendering of your input, generated by ASCIIMathML as you type. + +

    +
    +
    +
    +

    + This is the underlying MathML source generated by ASCIIMathML, again updated in real time. + +

    +
     
    +

    Enhanced Presentation MathML

    +

    + This shows the result of attempting to enrich the raw Presentation MathML + generated by ASCIIMathML: + +

    <math xmlns="http://www.w3.org/1998/Math/MathML">
    +   <mrow>
    +      <mrow>
    +         <mn>2</mn>
    +         <mo>*</mo>
    +         <mi>x</mi>
    +      </mrow>
    +      <mo>+</mo>
    +      <mrow>
    +         <mn>3</mn>
    +         <mo>*</mo>
    +         <mi>y</mi>
    +      </mrow>
    +   </mrow>
    +</math>

    Content MathML

    +

    + This shows the result of an attempted + conversion to Content MathML: + +

    <math xmlns="http://www.w3.org/1998/Math/MathML">
    +   <apply>
    +      <plus/>
    +      <apply>
    +         <times/>
    +         <cn>2</cn>
    +         <ci>x</ci>
    +      </apply>
    +      <apply>
    +         <times/>
    +         <cn>3</cn>
    +         <ci>y</ci>
    +      </apply>
    +   </apply>
    +</math>

    Maxima Input Form

    +

    + This shows the result of an attempted + conversion to Maxima Input syntax: + +

    (2 * x) + (3 * y)

    MathML Parallel Markup

    +

    + This shows the enhanced Presentation MathML with other forms encapsulated + as annotations: + +

    <math xmlns="http://www.w3.org/1998/Math/MathML">
    +   <semantics>
    +      <mrow>
    +         <mrow>
    +            <mn>2</mn>
    +            <mo>*</mo>
    +            <mi>x</mi>
    +         </mrow>
    +         <mo>+</mo>
    +         <mrow>
    +            <mn>3</mn>
    +            <mo>*</mo>
    +            <mi>y</mi>
    +         </mrow>
    +      </mrow>
    +      <annotation-xml encoding="MathML-Content">
    +         <apply>
    +            <plus/>
    +            <apply>
    +               <times/>
    +               <cn>2</cn>
    +               <ci>x</ci>
    +            </apply>
    +            <apply>
    +               <times/>
    +               <cn>3</cn>
    +               <ci>y</ci>
    +            </apply>
    +         </apply>
    +      </annotation-xml>
    +      <annotation encoding="ASCIIMathInput"/>
    +      <annotation encoding="Maxima">(2 * x) + (3 * y)</annotation>
    +   </semantics>
    +</math>
    +
    +
    +
    + + + \ No newline at end of file diff --git a/common/lib/capa/capa/tests/test_files/snuggletex_x+x+3y.xml b/common/lib/capa/capa/tests/test_files/snuggletex_x+x+3y.xml new file mode 100644 index 0000000000..09b5ae8312 --- /dev/null +++ b/common/lib/capa/capa/tests/test_files/snuggletex_x+x+3y.xml @@ -0,0 +1,225 @@ + + + + + + + + + + + + SnuggleTeX - ASCIIMathML Enrichment Demo + + + + + + + +

    SnuggleTeX (1.2.2)

    +
    + + +
    + +
    +

    ASCIIMathML Enrichment Demo

    +

    Input

    +

    + This demo is similar to the + MathML Semantic Enrichnment Demo + but uses + ASCIIMathML as + an alternative input format, which provides real-time feedback as you + type but can often generate MathML with odd semantics in it. + SnuggleTeX includes some functionality that can to convert this raw MathML into + something equivalent to its own MathML output, thereby allowing you to + semantically enrich it in + certain simple cases, making ASCIIMathML a possibly viable input format + for simple semantic maths. + +

    +

    + To try the demo, simply enter some some ASCIIMathML into the box below. + You should see a real time preview of this while you type. + Then hit Go! to use SnuggleTeX to semantically enrich your + input. + +

    +
    +
    + ASCIIMath Input: +
    +
    +

    Live Preview

    +

    + This is a MathML rendering of your input, generated by ASCIIMathML as you type. + +

    +
    +
    +
    +

    + This is the underlying MathML source generated by ASCIIMathML, again updated in real time. + +

    +
     
    +

    Enhanced Presentation MathML

    +

    + This shows the result of attempting to enrich the raw Presentation MathML + generated by ASCIIMathML: + +

    <math xmlns="http://www.w3.org/1998/Math/MathML">
    +   <mrow>
    +      <mi>x</mi>
    +      <mo>+</mo>
    +      <mi>x</mi>
    +      <mo>+</mo>
    +      <mrow>
    +         <mn>3</mn>
    +         <mo>*</mo>
    +         <mi>y</mi>
    +      </mrow>
    +   </mrow>
    +</math>

    Content MathML

    +

    + This shows the result of an attempted + conversion to Content MathML: + +

    <math xmlns="http://www.w3.org/1998/Math/MathML">
    +   <apply>
    +      <plus/>
    +      <ci>x</ci>
    +      <ci>x</ci>
    +      <apply>
    +         <times/>
    +         <cn>3</cn>
    +         <ci>y</ci>
    +      </apply>
    +   </apply>
    +</math>

    Maxima Input Form

    +

    + This shows the result of an attempted + conversion to Maxima Input syntax: + +

    x + x + (3 * y)

    MathML Parallel Markup

    +

    + This shows the enhanced Presentation MathML with other forms encapsulated + as annotations: + +

    <math xmlns="http://www.w3.org/1998/Math/MathML">
    +   <semantics>
    +      <mrow>
    +         <mi>x</mi>
    +         <mo>+</mo>
    +         <mi>x</mi>
    +         <mo>+</mo>
    +         <mrow>
    +            <mn>3</mn>
    +            <mo>*</mo>
    +            <mi>y</mi>
    +         </mrow>
    +      </mrow>
    +      <annotation-xml encoding="MathML-Content">
    +         <apply>
    +            <plus/>
    +            <ci>x</ci>
    +            <ci>x</ci>
    +            <apply>
    +               <times/>
    +               <cn>3</cn>
    +               <ci>y</ci>
    +            </apply>
    +         </apply>
    +      </annotation-xml>
    +      <annotation encoding="ASCIIMathInput"/>
    +      <annotation encoding="Maxima">x + x + (3 * y)</annotation>
    +   </semantics>
    +</math>
    +
    +
    +
    + + + \ No newline at end of file diff --git a/common/lib/capa/capa/tests/test_responsetypes.py b/common/lib/capa/capa/tests/test_responsetypes.py index fd056f884e..405c385ea9 100644 --- a/common/lib/capa/capa/tests/test_responsetypes.py +++ b/common/lib/capa/capa/tests/test_responsetypes.py @@ -9,6 +9,7 @@ import pyparsing import random import unittest import textwrap +import requests import mock from . import new_loncapa_problem, test_system @@ -199,9 +200,8 @@ class SymbolicResponseTest(ResponseTest): from capa.tests.response_xml_factory import SymbolicResponseXMLFactory xml_factory_class = SymbolicResponseXMLFactory - def test_grade_single_input(self): - problem = self.build_problem(math_display=True, - expect="2*x+3*y") + def test_grade_single_input_correct(self): + problem = self.build_problem(math_display=True, expect="2*x+3*y") # Correct answers correct_inputs = [ @@ -209,17 +209,27 @@ class SymbolicResponseTest(ResponseTest): 2*x+3*y - """)), + """), + 'snuggletex_2x+3y.xml'), ('x+x+3y', textwrap.dedent(""" x+x+3*y - """)), + """), + 'snuggletex_x+x+3y.xml'), ] - for (input_str, input_mathml) in correct_inputs: - self._assert_symbolic_grade(problem, input_str, input_mathml, 'correct') + for (input_str, input_mathml, server_fixture) in correct_inputs: + print "Testing input: {0}".format(input_str) + server_resp = self._load_fixture(server_fixture) + self._assert_symbolic_grade( + problem, input_str, input_mathml, + 'correct', snuggletex_resp=server_resp + ) + + def test_grade_single_input_incorrect(self): + problem = self.build_problem(math_display=True, expect="2*x+3*y") # Incorrect answers incorrect_inputs = [ @@ -234,112 +244,86 @@ class SymbolicResponseTest(ResponseTest): for (input_str, input_mathml) in incorrect_inputs: self._assert_symbolic_grade(problem, input_str, input_mathml, 'incorrect') - def test_complex_number_grade(self): + def test_complex_number_grade_correct(self): + problem = self.build_problem( + math_display=True, + expect="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]", + options=["matrix", "imaginary"] + ) + + correct_snuggletex = self._load_fixture('snuggletex_correct.html') + dynamath_input = self._load_fixture('dynamath_input.txt') + student_response = "cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]" + + self._assert_symbolic_grade( + problem, student_response, dynamath_input, + 'correct', + snuggletex_resp=correct_snuggletex + ) + + def test_complex_number_grade_incorrect(self): + problem = self.build_problem(math_display=True, expect="[[cos(theta),i*sin(theta)],[i*sin(theta),cos(theta)]]", options=["matrix", "imaginary"]) - # For LaTeX-style inputs, symmath_check() will try to contact - # a server to convert the input to MathML. - # We mock out the server, simulating the response that it would give - # for this input. - import requests - dirpath = os.path.dirname(__file__) - correct_snuggletex_response = open(os.path.join(dirpath, "test_files/snuggletex_correct.html")).read().decode('utf8') - wrong_snuggletex_response = open(os.path.join(dirpath, "test_files/snuggletex_wrong.html")).read().decode('utf8') + wrong_snuggletex = self._load_fixture('snuggletex_wrong.html') + dynamath_input = textwrap.dedent(""" + + 2 + + """) - # Correct answer - with mock.patch.object(requests, 'post') as mock_post: - - # Simulate what the LaTeX-to-MathML server would - # send for the correct response input - mock_post.return_value.text = correct_snuggletex_response - - self._assert_symbolic_grade(problem, - "cos(theta)*[[1,0],[0,1]] + i*sin(theta)*[[0,1],[1,0]]", - textwrap.dedent(""" - - - - cos - (θ) - - - - [ - - - 10 - - - 01 - - - ] - - + - i - - - sin - - (θ) - - - - - [ - - - 01 - - - 10 - - - ] - - - - """), - 'correct') - - # Incorrect answer - with mock.patch.object(requests, 'post') as mock_post: - - # Simulate what the LaTeX-to-MathML server would - # send for the incorrect response input - mock_post.return_value.text = wrong_snuggletex_response - - self._assert_symbolic_grade(problem, "2", - textwrap.dedent(""" - - 2 - - """), - 'incorrect') + self._assert_symbolic_grade( + problem, "2", dynamath_input, + 'incorrect', + snuggletex_resp=wrong_snuggletex, + ) def test_multiple_inputs_exception(self): # Should not allow multiple inputs, since we specify # only one "expect" value with self.assertRaises(Exception): - self.build_problem(math_display=True, - expect="2*x+3*y", - num_inputs=3) + self.build_problem(math_display=True, expect="2*x+3*y", num_inputs=3) - def _assert_symbolic_grade(self, problem, - student_input, - dynamath_input, - expected_correctness): + def _assert_symbolic_grade( + self, problem, student_input, dynamath_input, expected_correctness, + snuggletex_resp="" + ): + """ + Assert that the symbolic response has a certain grade. + + `problem` is the capa problem containing the symbolic response. + `student_input` is the text the student entered. + `dynamath_input` is the JavaScript rendered MathML from the page. + `expected_correctness` is either "correct" or "incorrect" + `snuggletex_resp` is the simulated response from the Snuggletex server + """ input_dict = {'1_2_1': str(student_input), '1_2_1_dynamath': str(dynamath_input)} - correct_map = problem.grade_answers(input_dict) + # Simulate what the Snuggletex server would respond + with mock.patch.object(requests, 'post') as mock_post: + mock_post.return_value.text = snuggletex_resp - self.assertEqual( - correct_map.get_correctness('1_2_1'), expected_correctness - ) + correct_map = problem.grade_answers(input_dict) + + self.assertEqual( + correct_map.get_correctness('1_2_1'), expected_correctness + ) + + @staticmethod + def _load_fixture(relpath): + """ + Return a `unicode` object representing the contents + of the fixture file at `relpath` (relative to the test files dir) + """ + abspath = os.path.join(os.path.dirname(__file__), 'test_files', relpath) + with open(abspath) as fixture_file: + contents = fixture_file.read() + + return contents.decode('utf8') class OptionResponseTest(ResponseTest): diff --git a/common/lib/xmodule/xmodule/tests/test_export.py b/common/lib/xmodule/xmodule/tests/test_export.py index 5c5d8307af..b0c1617580 100644 --- a/common/lib/xmodule/xmodule/tests/test_export.py +++ b/common/lib/xmodule/xmodule/tests/test_export.py @@ -6,6 +6,8 @@ from datetime import datetime, timedelta, tzinfo from tempfile import mkdtemp import unittest import shutil +from textwrap import dedent +import mock import pytz from fs.osfs import OSFS @@ -35,12 +37,23 @@ def strip_filenames(descriptor): class RoundTripTestCase(unittest.TestCase): - ''' Check that our test courses roundtrip properly. - Same course imported , than exported, then imported again. - And we compare original import with second import (after export). - Thus we make sure that export and import work properly. - ''' - def check_export_roundtrip(self, data_dir, course_dir): + """ + Check that our test courses roundtrip properly. + Same course imported , than exported, then imported again. + And we compare original import with second import (after export). + Thus we make sure that export and import work properly. + """ + + @mock.patch('xmodule.course_module.requests.get') + def check_export_roundtrip(self, data_dir, course_dir, mock_get): + + # Patch network calls to retrieve the textbook TOC + mock_get.return_value.text = dedent(""" + + + + """).strip() + root_dir = path(self.temp_dir) print("Copying test course to temp dir {0}".format(root_dir)) From e7626d2d845b287dd68767fadd37bf803d1768c5 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 27 Aug 2013 08:34:48 -0400 Subject: [PATCH 198/244] Teach rake test tasks how to re-run failed tests --- .gitignore | 1 + docs/internal/testing.md | 16 ++++++++++++---- rakelib/tests.rake | 29 +++++++++++++++++++++++------ requirements/edx/base.txt | 1 + setup.cfg | 3 +++ 5 files changed, 40 insertions(+), 10 deletions(-) diff --git a/.gitignore b/.gitignore index 4fd90cfe03..72de96e0c4 100644 --- a/.gitignore +++ b/.gitignore @@ -46,3 +46,4 @@ autodeploy.properties .ws_migrations_complete .vagrant/ logs +.testids/ diff --git a/docs/internal/testing.md b/docs/internal/testing.md index ae24e5dbbe..88ef52e16d 100644 --- a/docs/internal/testing.md +++ b/docs/internal/testing.md @@ -103,6 +103,10 @@ You can run tests using `rake` commands. For example, runs all the tests. It also runs `collectstatic`, which prepares the static files used by the site (for example, compiling Coffeescript to Javascript). +You can re-run all failed python tests by running (all JS tests will still run) + + rake test[--failed] + You can also run the tests without `collectstatic`, which tends to be faster: rake fasttest_lms @@ -128,6 +132,10 @@ To run a single django test: rake test_lms[courseware.tests.tests:TestViewAuth.test_dark_launch] +To re-run all failing django tests from lms or cms: + + rake test_lms[--failed] + To run a single nose test file: nosetests common/lib/xmodule/xmodule/tests/test_stringify.py @@ -142,18 +150,18 @@ To run a single test and get stdout, with proper env config: To run a single test and get stdout and get coverage: - python -m coverage run --rcfile=./common/lib/xmodule/.coveragerc which ./manage.py cms --settings test test --traceback --logging-clear-handlers --liveserver=localhost:8000-9000 contentstore.tests.test_import_nostatic -s # cms example + python -m coverage run --rcfile=./common/lib/xmodule/.coveragerc which ./manage.py cms --settings test test --traceback --logging-clear-handlers --liveserver=localhost:8000-9000 contentstore.tests.test_import_nostatic -s # cms example python -m coverage run --rcfile=./lms/.coveragerc which ./manage.py lms --settings test test --traceback --logging-clear-handlers --liveserver=localhost:8000-9000 courseware.tests.test_module_render -s # lms example generate coverage report: - coverage report --rcfile=./common/lib/xmodule/.coveragerc + coverage report --rcfile=./common/lib/xmodule/.coveragerc or to get html report: coverage html --rcfile=./common/lib/xmodule/.coveragerc - -then browse reports/common/lib/xmodule/cover/index.html + +then browse reports/common/lib/xmodule/cover/index.html Very handy: if you uncomment the `pdb=1` line in `setup.cfg`, it will drop you into pdb on error. This lets you go up and down the stack and see what the values of the variables are. Check out [the pdb documentation](http://docs.python.org/library/pdb.html) diff --git a/rakelib/tests.rake b/rakelib/tests.rake index fe5d0a351d..bed5d5b36a 100644 --- a/rakelib/tests.rake +++ b/rakelib/tests.rake @@ -4,6 +4,10 @@ CLOBBER.include(REPORT_DIR, 'test_root/*_repo', 'test_root/staticfiles') # Create the directory to hold coverage reports, if it doesn't already exist. directory REPORT_DIR +def test_id_dir(path) + return File.join(".testids", path.to_s) +end + def run_under_coverage(cmd, root) cmd0, cmd_rest = cmd.split(" ", 2) # We use "python -m coverage" so that the proper python will run the importable coverage @@ -14,9 +18,15 @@ end def run_tests(system, report_dir, test_id=nil, stop_on_failure=true) ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") + test_id_file = File.join(test_id_dir(system), "noseids") dirs = Dir["common/djangoapps/*"] + Dir["#{system}/djangoapps/*"] test_id = dirs.join(' ') if test_id.nil? or test_id == '' - cmd = django_admin(system, :test, 'test', '--logging-clear-handlers', '--liveserver=localhost:8000-9000', test_id) + cmd = django_admin( + system, :test, 'test', + '--logging-clear-handlers', + '--liveserver=localhost:8000-9000', + "--id-file=#{test_id_file}", + test_id) test_sh(run_under_coverage(cmd, system)) end @@ -64,14 +74,17 @@ TEST_TASK_DIRS = [] [:lms, :cms].each do |system| report_dir = report_dir_path(system) + test_id_dir = test_id_dir(system) - # Per System tasks + directory test_id_dir + + # Per System tasks/ desc "Run all django tests on our djangoapps for the #{system}" task "test_#{system}", [:test_id] => [:clean_test_files, :predjango, "#{system}:gather_assets:test", "fasttest_#{system}"] # Have a way to run the tests without running collectstatic -- useful when debugging without # messing with static files. - task "fasttest_#{system}", [:test_id] => [report_dir, :clean_reports_dir, :install_prereqs, :predjango] do |t, args| + task "fasttest_#{system}", [:test_id] => [test_id_dir, report_dir, :clean_reports_dir, :install_prereqs, :predjango] do |t, args| args.with_defaults(:test_id => nil) run_tests(system, report_dir, args.test_id) end @@ -97,12 +110,16 @@ end Dir["common/lib/*"].select{|lib| File.directory?(lib)}.each do |lib| report_dir = report_dir_path(lib) + test_id_dir = test_id_dir(lib) + test_ids = File.join(test_id_dir(lib), '.noseids') + + directory test_id_dir desc "Run tests for common lib #{lib}" - task "test_#{lib}", [:test_id] => [report_dir, :clean_reports_dir] do |t, args| + task "test_#{lib}", [:test_id] => [test_id_dir, report_dir, :clean_reports_dir] do |t, args| args.with_defaults(:test_id => lib) ENV['NOSE_XUNIT_FILE'] = File.join(report_dir, "nosetests.xml") - cmd = "nosetests #{args.test_id}" + cmd = "nosetests --id-file=#{test_ids} #{args.test_id}" test_sh(run_under_coverage(cmd, lib)) end TEST_TASK_DIRS << lib @@ -126,7 +143,7 @@ TEST_TASK_DIRS.each do |dir| end desc "Run all tests" -task :test => :test_docs +task :test, [:test_id] => :test_docs desc "Build the html, xml, and diff coverage reports" task :coverage => :report_dirs do diff --git a/requirements/edx/base.txt b/requirements/edx/base.txt index d700aaa195..7e65eda713 100644 --- a/requirements/edx/base.txt +++ b/requirements/edx/base.txt @@ -89,5 +89,6 @@ django-jasmine==0.3.2 django_debug_toolbar django-debug-toolbar-mongo nose-ignore-docstring +nose-exclude git+https://github.com/mfogel/django-settings-context-processor.git diff --git a/setup.cfg b/setup.cfg index 844df75ce8..da9525a300 100644 --- a/setup.cfg +++ b/setup.cfg @@ -3,6 +3,9 @@ logging-clear-handlers=1 with-xunit=1 rednose=1 with-ignore-docstrings=1 +with-id=1 +exclude-dir=lms/envs + cms/envs # Uncomment the following line to open pdb when a test fails #pdb=1 From 444f51d6de684d7f40c388d16e02b00a69e5391c Mon Sep 17 00:00:00 2001 From: Felix Sun Date: Tue, 27 Aug 2013 09:41:29 -0400 Subject: [PATCH 199/244] Fixed some pep/pylint violations. --- common/lib/capa/capa/responsetypes.py | 12 +++++------- common/lib/xmodule/xmodule/crowdsource_hinter.py | 4 ++-- .../xmodule/xmodule/tests/test_crowdsource_hinter.py | 8 +++++--- 3 files changed, 12 insertions(+), 12 deletions(-) diff --git a/common/lib/capa/capa/responsetypes.py b/common/lib/capa/capa/responsetypes.py index 550042d1df..b53f38fd90 100644 --- a/common/lib/capa/capa/responsetypes.py +++ b/common/lib/capa/capa/responsetypes.py @@ -915,16 +915,14 @@ class NumericalResponse(LoncapaResponse): else: return CorrectMap(self.answer_id, 'incorrect') - # TODO: add check_hint_condition(self, hxml_set, student_answers) - - def compare_answer(self, a, b): + def compare_answer(self, ans1, ans2): """ Outside-facing function that lets us compare two numerical answers, with this problem's tolerance. """ return compare_with_tolerance( - evaluator({}, {}, a), - evaluator({}, {}, b), + evaluator({}, {}, ans1), + evaluator({}, {}, ans2), self.tolerance ) @@ -1886,11 +1884,11 @@ class FormulaResponse(LoncapaResponse): else: return "incorrect" - def compare_answer(self, a, b): + def compare_answer(self, ans1, ans2): """ An external interface for comparing whether a and b are equal. """ - internal_result = self.check_formula(a, b, self.samples) + internal_result = self.check_formula(ans1, ans2, self.samples) return internal_result == "correct" def validate_answer(self, answer): diff --git a/common/lib/xmodule/xmodule/crowdsource_hinter.py b/common/lib/xmodule/xmodule/crowdsource_hinter.py index f2d21c459b..7e538efa24 100644 --- a/common/lib/xmodule/xmodule/crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/crowdsource_hinter.py @@ -229,12 +229,12 @@ class CrowdsourceHinterModule(CrowdsourceHinterFields, XModule): # The brackets surrounding the index are for backwards compatability purposes. # (It used to be that each answer was paired with multiple hints in a list.) self.previous_answers += [[best_hint_answer, [best_hint_index]]] - for i in xrange(min(2, n_hints - 1)): + for _ in xrange(min(2, n_hints - 1)): # Keep making random hints until we hit a target, or run out. while True: # random.choice randomly chooses an element from its input list. # (We then unpack the item, in this case data for a hint.) - (hint_index, (rand_hint, votes, hint_answer)) =\ + (hint_index, (rand_hint, _, hint_answer)) =\ random.choice(matching_hints.items()) if rand_hint not in hints: break diff --git a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py index d28d2ee06b..8347b71076 100644 --- a/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py +++ b/common/lib/xmodule/xmodule/tests/test_crowdsource_hinter.py @@ -120,9 +120,9 @@ class CHModuleFactory(object): return False responder.validate_answer = validate_answer - def compare_answer(a, b): + def compare_answer(ans1, ans2): """ A fake answer comparer """ - return a == b + return ans1 == ans2 responder.compare_answer = compare_answer capa_module.lcp.responders = {'responder0': responder} @@ -189,11 +189,13 @@ class VerticalWithModulesFactory(object): @staticmethod def next_num(): + """Increments a global counter for naming.""" CHModuleFactory.num += 1 return CHModuleFactory.num @staticmethod def create(): + """Make a vertical.""" model_data = {'data': VerticalWithModulesFactory.sample_problem_xml} system = get_test_system() descriptor = VerticalDescriptor.from_xml(VerticalWithModulesFactory.sample_problem_xml, system) @@ -532,7 +534,7 @@ class CrowdsourceHinterTest(unittest.TestCase): """ mock_module = CHModuleFactory.create() - def fake_get_hint(get): + def fake_get_hint(_): """ Creates a rendering dictionary, with which we can test the templates. From ca7c002ca9a928f935a1ce5701ed81239b455e9c Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 27 Aug 2013 11:08:22 -0400 Subject: [PATCH 200/244] Keep comments in capa XML from causing failures Comments (and processing instructions!) are handled oddly in lxml. This change will keep them from causing failures. They will be omitted from the HTML generated, which is fine, since they aren't needed there. --- common/lib/capa/capa/capa_problem.py | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/common/lib/capa/capa/capa_problem.py b/common/lib/capa/capa/capa_problem.py index c2bdeadc21..08a223f609 100644 --- a/common/lib/capa/capa/capa_problem.py +++ b/common/lib/capa/capa/capa_problem.py @@ -555,6 +555,13 @@ class LoncapaProblem(object): Used by get_html. ''' + if not isinstance(problemtree.tag, basestring): + # Comment and ProcessingInstruction nodes are not Elements, + # and we're ok leaving those behind. + # BTW: etree gives us no good way to distinguish these things + # other than to examine .tag to see if it's a string. :( + return + if (problemtree.tag == 'script' and problemtree.get('type') and 'javascript' in problemtree.get('type')): # leave javascript intact. From 2c544cb0bdffb3bfa716a3f116e7196af47aa438 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Tue, 27 Aug 2013 11:44:41 -0400 Subject: [PATCH 201/244] A test that our XML-comments fix works. --- .../lib/capa/capa/tests/test_html_render.py | 20 +++++++++++++++++++ 1 file changed, 20 insertions(+) diff --git a/common/lib/capa/capa/tests/test_html_render.py b/common/lib/capa/capa/tests/test_html_render.py index 9bc326d7b9..8e343ee1cf 100644 --- a/common/lib/capa/capa/tests/test_html_render.py +++ b/common/lib/capa/capa/tests/test_html_render.py @@ -226,6 +226,26 @@ class CapaHtmlRenderTest(unittest.TestCase): span_element = rendered_html.find('span') self.assertEqual(span_element.get('attr'), "TEST") + def test_xml_comments_and_other_odd_things(self): + # Comments and processing instructions should be skipped. + xml_str = textwrap.dedent("""\ + + + ]> + + + + + """) + + # Create the problem + problem = new_loncapa_problem(xml_str) + + # Render the HTML + the_html = problem.get_html() + self.assertRegexpMatches(the_html, r"
    \s+
    ") + def _create_test_file(self, path, content_str): test_fp = self.system.filestore.open(path, "w") test_fp.write(content_str) From 407b02b358c759d039a0105d099bb234cb128079 Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Wed, 14 Aug 2013 16:50:34 -0400 Subject: [PATCH 202/244] Centralize startup code, and execute in all contexts Inspired by: http://eldarion.com/blog/2013/02/14/entry-point-hook-django-projects/ Moves startup code to lms.startup and cms.startup, and calls the startup methods in wsgi.py and manage.py for both projects. --- CHANGELOG.rst | 3 ++ .../management/commands/check_course.py | 11 ------ .../management/commands/clone_course.py | 11 +----- .../management/commands/delete_course.py | 10 +----- .../contentstore/tests/test_contentstore.py | 9 +++-- cms/one_time_startup.py | 22 ------------ cms/startup.py | 25 +++++++++++++ cms/urls.py | 6 ++-- cms/wsgi.py | 12 +++++++ common/djangoapps/datadog/startup.py | 12 +++++++ common/djangoapps/terrain/browser.py | 5 --- common/lib/django_startup.py | 14 ++++++++ .../xmodule/xmodule/modulestore/__init__.py | 35 +++---------------- .../lib/xmodule/xmodule/modulestore/django.py | 22 ++++++++++++ .../lib/xmodule/xmodule/modulestore/mixed.py | 12 ++----- .../xmodule/xmodule/modulestore/mongo/base.py | 9 +++-- .../xmodule/modulestore/split_mongo/split.py | 5 ++- .../tests/test_mixed_modulestore.py | 28 +++++---------- common/lib/xmodule/xmodule/modulestore/xml.py | 4 +-- .../xmodule/xmodule/peer_grading_module.py | 13 +++++-- common/lib/xmodule/xmodule/template_module.py | 3 +- .../xmodule/xmodule/tests/test_capa_module.py | 3 +- common/lib/xmodule/xmodule/video_module.py | 3 -- common/test/data/uploads/test | 2 +- lms/djangoapps/courseware/tests/__init__.py | 3 +- .../courseware/tests/test_model_data.py | 2 -- .../instructor/tests/test_legacy_gradebook.py | 3 +- lms/one_time_startup.py | 18 ---------- lms/startup.py | 16 +++++++++ lms/urls.py | 3 -- lms/wsgi.py | 8 ++--- manage.py | 12 ++++--- 32 files changed, 170 insertions(+), 174 deletions(-) delete mode 100644 cms/one_time_startup.py create mode 100644 cms/startup.py create mode 100644 cms/wsgi.py create mode 100644 common/djangoapps/datadog/startup.py create mode 100644 common/lib/django_startup.py delete mode 100644 lms/one_time_startup.py create mode 100644 lms/startup.py diff --git a/CHANGELOG.rst b/CHANGELOG.rst index 47ffc2e313..ab5e17357b 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -11,6 +11,9 @@ It is hidden behind a false defaulted course level flag. Studio: Allow course authors to set their course image on the schedule and details page, with support for JPEG and PNG images. +LMS, Studio: Centralized startup code to manage.py and wsgi.py files. +Made studio runnable using wsgi. + Blades: Took videoalpha out of alpha, replacing the old video player Common: Allow instructors to input complicated expressions as answers to diff --git a/cms/djangoapps/contentstore/management/commands/check_course.py b/cms/djangoapps/contentstore/management/commands/check_course.py index 2f0b0b2a2c..13ac6af50c 100644 --- a/cms/djangoapps/contentstore/management/commands/check_course.py +++ b/cms/djangoapps/contentstore/management/commands/check_course.py @@ -3,11 +3,6 @@ from xmodule.modulestore.django import modulestore from xmodule.modulestore.xml_importer import check_module_metadata_editability from xmodule.course_module import CourseDescriptor -from request_cache.middleware import RequestCache - -from django.core.cache import get_cache - -CACHE = get_cache('mongo_metadata_inheritance') class Command(BaseCommand): help = '''Enumerates through the course and find common errors''' @@ -21,12 +16,6 @@ class Command(BaseCommand): loc = CourseDescriptor.id_to_location(loc_str) store = modulestore() - # setup a request cache so we don't throttle the DB with all the metadata inheritance requests - store.set_modulestore_configuration({ - 'metadata_inheritance_cache_subsystem': CACHE, - 'request_cache': RequestCache.get_request_cache() - }) - course = store.get_item(loc, depth=3) err_cnt = 0 diff --git a/cms/djangoapps/contentstore/management/commands/clone_course.py b/cms/djangoapps/contentstore/management/commands/clone_course.py index aa0e076f08..5ad0da09d8 100644 --- a/cms/djangoapps/contentstore/management/commands/clone_course.py +++ b/cms/djangoapps/contentstore/management/commands/clone_course.py @@ -9,14 +9,10 @@ from xmodule.course_module import CourseDescriptor from auth.authz import _copy_course_group + # # To run from command line: rake cms:clone SOURCE_LOC=MITx/111/Foo1 DEST_LOC=MITx/135/Foo3 # -from request_cache.middleware import RequestCache -from django.core.cache import get_cache - -CACHE = get_cache('mongo_metadata_inheritance') - class Command(BaseCommand): """Clone a MongoDB-backed course to another location""" help = 'Clone a MongoDB backed course to another location' @@ -32,11 +28,6 @@ class Command(BaseCommand): mstore = modulestore('direct') cstore = contentstore() - mstore.set_modulestore_configuration({ - 'metadata_inheritance_cache_subsystem': CACHE, - 'request_cache': RequestCache.get_request_cache() - }) - org, course_num, run = dest_course_id.split("/") mstore.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num)) diff --git a/cms/djangoapps/contentstore/management/commands/delete_course.py b/cms/djangoapps/contentstore/management/commands/delete_course.py index b0901ccfc9..50f9b82e80 100644 --- a/cms/djangoapps/contentstore/management/commands/delete_course.py +++ b/cms/djangoapps/contentstore/management/commands/delete_course.py @@ -9,14 +9,11 @@ from xmodule.course_module import CourseDescriptor from .prompt import query_yes_no from auth.authz import _delete_course_group -from request_cache.middleware import RequestCache -from django.core.cache import get_cache + # # To run from command line: rake cms:delete_course LOC=MITx/111/Foo1 # - -CACHE = get_cache('mongo_metadata_inheritance') class Command(BaseCommand): help = '''Delete a MongoDB backed course''' @@ -36,11 +33,6 @@ class Command(BaseCommand): ms = modulestore('direct') cs = contentstore() - ms.set_modulestore_configuration({ - 'metadata_inheritance_cache_subsystem': CACHE, - 'request_cache': RequestCache.get_request_cache() - }) - org, course_num, run = course_id.split("/") ms.ignore_write_events_on_courses.append('{0}/{1}'.format(org, course_num)) diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 7d5cd3cbcb..da80b25fa4 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -1139,12 +1139,15 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): wrapper = MongoCollectionFindWrapper(module_store.collection.find) module_store.collection.find = wrapper.find + print module_store.metadata_inheritance_cache_subsystem + print module_store.request_cache course = module_store.get_item(location, depth=2) # make sure we haven't done too many round trips to DB - # note we say 4 round trips here for 1) the course, 2 & 3) for the chapters and sequentials, and - # 4) because of the RT due to calculating the inherited metadata - self.assertEqual(wrapper.counter, 4) + # note we say 3 round trips here for 1) the course, and 2 & 3) for the chapters and sequentials + # Because we're querying from the top of the tree, we cache information needed for inheritance, + # so we don't need to make an extra query to compute it. + self.assertEqual(wrapper.counter, 3) # make sure we pre-fetched a known sequential which should be at depth=2 self.assertTrue(Location(['i4x', 'edX', 'toy', 'sequential', diff --git a/cms/one_time_startup.py b/cms/one_time_startup.py deleted file mode 100644 index 4198cf2637..0000000000 --- a/cms/one_time_startup.py +++ /dev/null @@ -1,22 +0,0 @@ -from dogapi import dog_http_api, dog_stats_api -from django.conf import settings -from xmodule.modulestore.django import modulestore -from django.dispatch import Signal -from request_cache.middleware import RequestCache - -from django.core.cache import get_cache - -CACHE = get_cache('mongo_metadata_inheritance') -for store_name in settings.MODULESTORE: - store = modulestore(store_name) - - store.set_modulestore_configuration({ - 'metadata_inheritance_cache_subsystem': CACHE, - 'request_cache': RequestCache.get_request_cache() - }) - - modulestore_update_signal = Signal(providing_args=['modulestore', 'course_id', 'location']) - store.modulestore_update_signal = modulestore_update_signal -if hasattr(settings, 'DATADOG_API'): - dog_http_api.api_key = settings.DATADOG_API - dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) diff --git a/cms/startup.py b/cms/startup.py new file mode 100644 index 0000000000..eb1098a707 --- /dev/null +++ b/cms/startup.py @@ -0,0 +1,25 @@ +""" +Module with code executed during Studio startup +""" +from django.conf import settings + +# Force settings to run so that the python path is modified +settings.INSTALLED_APPS # pylint: disable=W0104 + +from django_startup import autostartup + +# TODO: Remove this code once Studio/CMS runs via wsgi in all environments +INITIALIZED = False + + +def run(): + """ + Executed during django startup + """ + global INITIALIZED + if INITIALIZED: + return + + INITIALIZED = True + autostartup() + diff --git a/cms/urls.py b/cms/urls.py index 8f396d3742..1467c99b1c 100644 --- a/cms/urls.py +++ b/cms/urls.py @@ -1,9 +1,9 @@ from django.conf import settings from django.conf.urls import patterns, include, url -# Import this file so it can do its work, even though we don't use the name. -# pylint: disable=W0611 -from . import one_time_startup +# TODO: This should be removed once the CMS is running via wsgi on all production servers +import cms.startup as startup +startup.run() # There is a course creators admin table. from ratelimitbackend import admin diff --git a/cms/wsgi.py b/cms/wsgi.py new file mode 100644 index 0000000000..607d7ee709 --- /dev/null +++ b/cms/wsgi.py @@ -0,0 +1,12 @@ +import os + +os.environ.setdefault("DJANGO_SETTINGS_MODULE", "cms.envs.aws") + +import cms.startup as startup +startup.run() + +# This application object is used by the development server +# as well as any WSGI server configured to use this file. +from django.core.wsgi import get_wsgi_application +application = get_wsgi_application() + diff --git a/common/djangoapps/datadog/startup.py b/common/djangoapps/datadog/startup.py new file mode 100644 index 0000000000..41949c3a94 --- /dev/null +++ b/common/djangoapps/datadog/startup.py @@ -0,0 +1,12 @@ +from django.conf import settings +from dogapi import dog_http_api, dog_stats_api + +def run(): + """ + Initialize connection to datadog during django startup. + + Expects the datadog api key in the DATADOG_API settings key + """ + if hasattr(settings, 'DATADOG_API'): + dog_http_api.api_key = settings.DATADOG_API + dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) diff --git a/common/djangoapps/terrain/browser.py b/common/djangoapps/terrain/browser.py index 4bf1aea40c..75c0764b1b 100644 --- a/common/djangoapps/terrain/browser.py +++ b/common/djangoapps/terrain/browser.py @@ -16,11 +16,6 @@ from requests import put from base64 import encodestring from json import dumps -# Let the LMS and CMS do their one-time setup -# For example, setting up mongo caches -# These names aren't used, but do important work on import. -from lms import one_time_startup # pylint: disable=W0611 -from cms import one_time_startup # pylint: disable=W0611 from pymongo import MongoClient import xmodule.modulestore.django from xmodule.contentstore.django import _CONTENTSTORE diff --git a/common/lib/django_startup.py b/common/lib/django_startup.py new file mode 100644 index 0000000000..1987d02dd2 --- /dev/null +++ b/common/lib/django_startup.py @@ -0,0 +1,14 @@ +from importlib import import_module +from django.conf import settings + +def autostartup(): + """ + Execute app.startup:run() for all installed django apps + """ + for app in settings.INSTALLED_APPS: + try: + mod = import_module('{}.startup') + if hasattr(mod, 'run'): + mod.run() + except ImportError: + continue diff --git a/common/lib/xmodule/xmodule/modulestore/__init__.py b/common/lib/xmodule/xmodule/modulestore/__init__.py index 707390d759..f2b70ad365 100644 --- a/common/lib/xmodule/xmodule/modulestore/__init__.py +++ b/common/lib/xmodule/xmodule/modulestore/__init__.py @@ -386,13 +386,6 @@ class ModuleStore(object): """ raise NotImplementedError - def set_modulestore_configuration(self, config_dict): - ''' - Allows for runtime configuration of the modulestore. In particular this is how the - application (LMS/CMS) can pass down Django related configuration information, e.g. caches, etc. - ''' - raise NotImplementedError - def get_modulestore_type(self, course_id): """ Returns a type which identifies which modulestore is servicing the given @@ -405,13 +398,14 @@ class ModuleStoreBase(ModuleStore): ''' Implement interface functionality that can be shared. ''' - def __init__(self): + def __init__(self, metadata_inheritance_cache_subsystem=None, request_cache=None, modulestore_update_signal=None): ''' Set up the error-tracking logic. ''' self._location_errors = {} # location -> ErrorLog - self.modulestore_configuration = {} - self.modulestore_update_signal = None # can be set by runtime to route notifications of datastore changes + self.metadata_inheritance_cache_subsystem = metadata_inheritance_cache_subsystem + self.modulestore_update_signal = modulestore_update_signal + self.request_cache = request_cache def _get_errorlog(self, location): """ @@ -455,27 +449,6 @@ class ModuleStoreBase(ModuleStore): return c return None - @property - def metadata_inheritance_cache_subsystem(self): - """ - Exposes an accessor to the runtime configuration for the metadata inheritance cache - """ - return self.modulestore_configuration.get('metadata_inheritance_cache_subsystem', None) - - @property - def request_cache(self): - """ - Exposes an accessor to the runtime configuration for the request cache - """ - return self.modulestore_configuration.get('request_cache', None) - - def set_modulestore_configuration(self, config_dict): - """ - This is the base implementation of the interface, all we need to do is store - two possible configurations as attributes on the class - """ - self.modulestore_configuration = config_dict - def namedtuple_to_son(namedtuple, prefix=''): """ diff --git a/common/lib/xmodule/xmodule/modulestore/django.py b/common/lib/xmodule/xmodule/modulestore/django.py index b239e5f1d4..9ff82e1137 100644 --- a/common/lib/xmodule/xmodule/modulestore/django.py +++ b/common/lib/xmodule/xmodule/modulestore/django.py @@ -8,8 +8,17 @@ from __future__ import absolute_import from importlib import import_module from django.conf import settings +from django.core.cache import get_cache, InvalidCacheBackendError +from django.dispatch import Signal from xmodule.modulestore.loc_mapper_store import LocMapperStore +# We may not always have the request_cache module available +try: + from request_cache.middleware import RequestCache + HAS_REQUEST_CACHE = True +except ImportError: + HAS_REQUEST_CACHE = False + _MODULESTORES = {} FUNCTION_KEYS = ['render_template'] @@ -39,7 +48,20 @@ def create_modulestore_instance(engine, options): if key in _options and isinstance(_options[key], basestring): _options[key] = load_function(_options[key]) + if HAS_REQUEST_CACHE: + request_cache = RequestCache.get_request_cache() + else: + request_cache = None + + try: + metadata_inheritance_cache = get_cache('mongo_metadata_inheritance') + except InvalidCacheBackendError: + metadata_inheritance_cache = get_cache('default') + return class_( + metadata_inheritance_cache_subsystem=metadata_inheritance_cache, + request_cache=request_cache, + modulestore_update_signal=Signal(providing_args=['modulestore', 'course_id', 'location']), **_options ) diff --git a/common/lib/xmodule/xmodule/modulestore/mixed.py b/common/lib/xmodule/xmodule/modulestore/mixed.py index cc4ba7a699..8827f33360 100644 --- a/common/lib/xmodule/xmodule/modulestore/mixed.py +++ b/common/lib/xmodule/xmodule/modulestore/mixed.py @@ -17,12 +17,12 @@ class MixedModuleStore(ModuleStoreBase): """ ModuleStore that can be backed by either XML or Mongo """ - def __init__(self, mappings, stores): + def __init__(self, mappings, stores, **kwargs): """ Initialize a MixedModuleStore. Here we look into our passed in kwargs which should be a collection of other modulestore configuration informations """ - super(MixedModuleStore, self).__init__() + super(MixedModuleStore, self).__init__(**kwargs) self.modulestores = {} self.mappings = mappings @@ -132,14 +132,6 @@ class MixedModuleStore(ModuleStoreBase): """ return self._get_modulestore_for_courseid(course_id).get_parent_locations(location, course_id) - def set_modulestore_configuration(self, config_dict): - """ - This implementation of the interface method will pass along the configuration to all ModuleStore - instances - """ - for store in self.modulestores.values(): - store.set_modulestore_configuration(config_dict) - def get_modulestore_type(self, course_id): """ Returns a type which identifies which modulestore is servicing the given diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py index ad2732409d..3d10b7ab6c 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -270,15 +270,18 @@ class MongoModuleStore(ModuleStoreBase): def __init__(self, host, db, collection, fs_root, render_template, port=27017, default_class=None, error_tracker=null_error_tracker, - user=None, password=None, **kwargs): + user=None, password=None, mongo_options=None, **kwargs): - super(MongoModuleStore, self).__init__() + super(MongoModuleStore, self).__init__(**kwargs) + + if mongo_options is None: + mongo_options = {} self.collection = pymongo.connection.Connection( host=host, port=port, tz_aware=True, - **kwargs + **mongo_options )[db][collection] if user is not None and password is not None: diff --git a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py index 7e151a6649..c5f4c2404c 100644 --- a/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py +++ b/common/lib/xmodule/xmodule/modulestore/split_mongo/split.py @@ -50,15 +50,18 @@ class SplitMongoModuleStore(ModuleStoreBase): port=27017, default_class=None, error_tracker=null_error_tracker, user=None, password=None, + mongo_options=None, **kwargs): ModuleStoreBase.__init__(self) + if mongo_options is None: + mongo_options = {} self.db = pymongo.database.Database(pymongo.MongoClient( host=host, port=port, tz_aware=True, - **kwargs + **mongo_options ), db) self.course_index = self.db[collection + '.active_versions'] diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index da400e9f0a..22d1e40c3f 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -5,9 +5,16 @@ from uuid import uuid4 from xmodule.tests import DATA_DIR from xmodule.modulestore import Location, MONGO_MODULESTORE_TYPE, XML_MODULESTORE_TYPE from xmodule.modulestore.exceptions import ItemNotFoundError -from xmodule.modulestore.mixed import MixedModuleStore from xmodule.modulestore.xml_importer import import_from_xml +# Mixed modulestore depends on django, so we'll manually configure some django settings +# before importing the module +from django.conf import settings +if not settings.configured: + settings.configure() + +from xmodule.modulestore.mixed import MixedModuleStore + HOST = 'localhost' PORT = 27017 @@ -234,22 +241,3 @@ class TestMixedModuleStore(object): assert_equals(Location(parents[0]).org, 'edX') assert_equals(Location(parents[0]).course, 'toy') assert_equals(Location(parents[0]).name, '2012_Fall') - - # pylint: disable=W0212 - def test_set_modulestore_configuration(self): - config = {'foo': 'bar'} - self.store.set_modulestore_configuration(config) - assert_equals( - config, - self.store._get_modulestore_for_courseid(IMPORT_COURSEID).modulestore_configuration - ) - - assert_equals( - config, - self.store._get_modulestore_for_courseid(XML_COURSEID1).modulestore_configuration - ) - - assert_equals( - config, - self.store._get_modulestore_for_courseid(XML_COURSEID2).modulestore_configuration - ) diff --git a/common/lib/xmodule/xmodule/modulestore/xml.py b/common/lib/xmodule/xmodule/modulestore/xml.py index 4c2011a3e5..6527a5e34a 100644 --- a/common/lib/xmodule/xmodule/modulestore/xml.py +++ b/common/lib/xmodule/xmodule/modulestore/xml.py @@ -257,7 +257,7 @@ class XMLModuleStore(ModuleStoreBase): """ An XML backed ModuleStore """ - def __init__(self, data_dir, default_class=None, course_dirs=None, load_error_modules=True): + def __init__(self, data_dir, default_class=None, course_dirs=None, load_error_modules=True, **kwargs): """ Initialize an XMLModuleStore from data_dir @@ -269,7 +269,7 @@ class XMLModuleStore(ModuleStoreBase): course_dirs: If specified, the list of course_dirs to load. Otherwise, load all course dirs """ - super(XMLModuleStore, self).__init__() + super(XMLModuleStore, self).__init__(**kwargs) self.data_dir = path(data_dir) self.modules = defaultdict(dict) # course_id -> dict(location -> XModuleDescriptor) diff --git a/common/lib/xmodule/xmodule/peer_grading_module.py b/common/lib/xmodule/xmodule/peer_grading_module.py index bbfc444cdc..ae4a8b87de 100644 --- a/common/lib/xmodule/xmodule/peer_grading_module.py +++ b/common/lib/xmodule/xmodule/peer_grading_module.py @@ -8,10 +8,9 @@ from pkg_resources import resource_string from .capa_module import ComplexEncoder from .x_module import XModule from xmodule.raw_module import RawDescriptor -from xmodule.modulestore.django import modulestore from xmodule.modulestore.exceptions import ItemNotFoundError from .timeinfo import TimeInfo -from xblock.core import Dict, String, Scope, Boolean, Integer, Float +from xblock.core import Dict, String, Scope, Boolean, Float from xmodule.fields import Date, Timedelta from xmodule.open_ended_grading_classes.peer_grading_service import PeerGradingService, GradingServiceError, MockPeerGradingService @@ -104,7 +103,7 @@ class PeerGradingModule(PeerGradingFields, XModule): if self.use_for_single_location: try: - self.linked_problem = modulestore().get_instance(self.system.course_id, self.link_to_location) + self.linked_problem = self.system.get_module(self.link_to_location) except ItemNotFoundError: log.error("Linked location {0} for peer grading module {1} does not exist".format( self.link_to_location, self.location)) @@ -632,3 +631,11 @@ class PeerGradingDescriptor(PeerGradingFields, RawDescriptor): non_editable_fields = super(PeerGradingDescriptor, self).non_editable_metadata_fields non_editable_fields.extend([PeerGradingFields.due, PeerGradingFields.graceperiod]) return non_editable_fields + + def get_required_module_descriptors(self): + """Returns a list of XModuleDescritpor instances upon which this module depends, but are + not children of this module""" + if self.use_for_single_location: + return [self.system.load_item(self.link_to_location)] + else: + return [] diff --git a/common/lib/xmodule/xmodule/template_module.py b/common/lib/xmodule/xmodule/template_module.py index c28378210b..34ba8f6c69 100644 --- a/common/lib/xmodule/xmodule/template_module.py +++ b/common/lib/xmodule/xmodule/template_module.py @@ -2,7 +2,6 @@ from xmodule.x_module import XModule from xmodule.raw_module import RawDescriptor from lxml import etree from mako.template import Template -from xmodule.modulestore.django import modulestore class CustomTagModule(XModule): @@ -56,7 +55,7 @@ class CustomTagDescriptor(RawDescriptor): # cdodge: look up the template as a module template_loc = self.location.replace(category='custom_tag_template', name=template_name) - template_module = modulestore().get_instance(system.course_id, template_loc) + template_module = system.load_item(template_loc) template_module_data = template_module.data template = Template(template_module_data) return template.render(**params) diff --git a/common/lib/xmodule/xmodule/tests/test_capa_module.py b/common/lib/xmodule/xmodule/tests/test_capa_module.py index 80c4e41e8f..92d30fac8c 100644 --- a/common/lib/xmodule/xmodule/tests/test_capa_module.py +++ b/common/lib/xmodule/xmodule/tests/test_capa_module.py @@ -322,7 +322,8 @@ class CapaModuleTest(unittest.TestCase): # We have to set up Django settings in order to use QueryDict from django.conf import settings - settings.configure() + if not settings.configured: + settings.configure() # Valid GET param dict valid_get_dict = self._querydict_from_dict({'input_1': 'test', diff --git a/common/lib/xmodule/xmodule/video_module.py b/common/lib/xmodule/xmodule/video_module.py index be77cd2684..d846777360 100644 --- a/common/lib/xmodule/xmodule/video_module.py +++ b/common/lib/xmodule/xmodule/video_module.py @@ -24,9 +24,6 @@ from xmodule.editing_module import TabsEditingDescriptor from xmodule.raw_module import EmptyDataRawDescriptor from xmodule.xml_module import is_pointer_tag, name_to_pathname from xmodule.modulestore import Location -from xmodule.modulestore.mongo import MongoModuleStore -from xmodule.modulestore.django import modulestore -from xmodule.contentstore.content import StaticContent from xblock.core import Scope, String, Boolean, Float, List, Integer import datetime diff --git a/common/test/data/uploads/test b/common/test/data/uploads/test index 0424951e34..588e9fb125 100644 --- a/common/test/data/uploads/test +++ b/common/test/data/uploads/test @@ -1 +1 @@ -This is an arbitrary file for testing uploads +This is an arbitrary file for testing uploads \ No newline at end of file diff --git a/lms/djangoapps/courseware/tests/__init__.py b/lms/djangoapps/courseware/tests/__init__.py index 4b93e804bf..64845688fb 100644 --- a/lms/djangoapps/courseware/tests/__init__.py +++ b/lms/djangoapps/courseware/tests/__init__.py @@ -50,7 +50,8 @@ class BaseTestXmodule(ModuleStoreTestCase): self.course = CourseFactory.create(data=self.COURSE_DATA) # Turn off cache. - modulestore().set_modulestore_configuration({}) + modulestore().request_cache = None + modulestore().metadata_inheritance_cache_subsystem = None chapter = ItemFactory.create( parent_location=self.course.location, diff --git a/lms/djangoapps/courseware/tests/test_model_data.py b/lms/djangoapps/courseware/tests/test_model_data.py index 0368bb040b..d8682d3d5c 100644 --- a/lms/djangoapps/courseware/tests/test_model_data.py +++ b/lms/djangoapps/courseware/tests/test_model_data.py @@ -318,8 +318,6 @@ class StorageTestBase(object): self.assertEquals(exception.saved_field_names[0], 'existing_field') - - class TestSettingsStorage(StorageTestBase, TestCase): factory = SettingsFactory scope = Scope.settings diff --git a/lms/djangoapps/instructor/tests/test_legacy_gradebook.py b/lms/djangoapps/instructor/tests/test_legacy_gradebook.py index fd285d2e3f..bca7528a96 100644 --- a/lms/djangoapps/instructor/tests/test_legacy_gradebook.py +++ b/lms/djangoapps/instructor/tests/test_legacy_gradebook.py @@ -26,7 +26,8 @@ class TestGradebook(ModuleStoreTestCase): self.client.login(username=instructor.username, password='test') # remove the caches - modulestore().set_modulestore_configuration({}) + modulestore().request_cache = None + modulestore().metadata_inheritance_cache_subsystem = None kwargs = {} if self.grading_policy is not None: diff --git a/lms/one_time_startup.py b/lms/one_time_startup.py deleted file mode 100644 index 2cd2077c4e..0000000000 --- a/lms/one_time_startup.py +++ /dev/null @@ -1,18 +0,0 @@ -from dogapi import dog_http_api, dog_stats_api -from django.conf import settings -from xmodule.modulestore.django import modulestore -from request_cache.middleware import RequestCache - -from django.core.cache import get_cache - -cache = get_cache('mongo_metadata_inheritance') -for store_name in settings.MODULESTORE: - store = modulestore(store_name) - store.set_modulestore_configuration({ - 'metadata_inheritance_cache_subsystem': cache, - 'request_cache': RequestCache.get_request_cache() - }) - -if hasattr(settings, 'DATADOG_API'): - dog_http_api.api_key = settings.DATADOG_API - dog_stats_api.start(api_key=settings.DATADOG_API, statsd=True) diff --git a/lms/startup.py b/lms/startup.py new file mode 100644 index 0000000000..901a559edb --- /dev/null +++ b/lms/startup.py @@ -0,0 +1,16 @@ +""" +Module for code that should run during LMS startup +""" +from django.conf import settings + +# Force settings to run so that the python path is modified +settings.INSTALLED_APPS # pylint: disable=W0104 + +from django_startup import autostartup + + +def run(): + """ + Executed during django startup + """ + autostartup() diff --git a/lms/urls.py b/lms/urls.py index 53665f9ef6..ac3dfb2a9e 100644 --- a/lms/urls.py +++ b/lms/urls.py @@ -3,9 +3,6 @@ from django.conf.urls import patterns, include, url from ratelimitbackend import admin from django.conf.urls.static import static -# Not used, the work is done in the imported module. -from . import one_time_startup # pylint: disable=W0611 - import django.contrib.auth.views # Uncomment the next two lines to enable the admin: diff --git a/lms/wsgi.py b/lms/wsgi.py index 270b019add..683ef46892 100644 --- a/lms/wsgi.py +++ b/lms/wsgi.py @@ -2,13 +2,11 @@ import os os.environ.setdefault("DJANGO_SETTINGS_MODULE", "lms.envs.aws") +import lms.startup as startup +startup.run() + # This application object is used by the development server # as well as any WSGI server configured to use this file. from django.core.wsgi import get_wsgi_application application = get_wsgi_application() -from django.conf import settings -from xmodule.modulestore.django import modulestore - -for store_name in settings.MODULESTORE: - modulestore(store_name) diff --git a/manage.py b/manage.py index ebaebe8b66..a5c6e5fb2b 100755 --- a/manage.py +++ b/manage.py @@ -13,8 +13,7 @@ Any arguments not understood by this manage.py will be passed to django-admin.py import os import sys -import glob2 -import imp +import importlib from argparse import ArgumentParser def parse_args(): @@ -41,7 +40,8 @@ def parse_args(): lms.set_defaults( help_string=lms.format_help(), settings_base='lms/envs', - default_settings='lms.envs.dev' + default_settings='lms.envs.dev', + startup='lms.startup', ) cms = subparsers.add_parser( @@ -59,7 +59,8 @@ def parse_args(): help_string=cms.format_help(), settings_base='cms/envs', default_settings='cms.envs.dev', - service_variant='cms' + service_variant='cms', + startup='cms.startup', ) edx_args, django_args = parser.parse_known_args() @@ -86,6 +87,9 @@ if __name__ == "__main__": # This will trigger django-admin.py to print out its help django_args.append('--help') + startup = importlib.import_module(edx_args.startup) + startup.run() + from django.core.management import execute_from_command_line execute_from_command_line([sys.argv[0]] + django_args) From e50a2414b22c321887c0d71efc57ab43d2a69c22 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Tue, 27 Aug 2013 12:57:14 -0400 Subject: [PATCH 203/244] Studio ModuleStoreTestCase subclasses now use randomized Mongo collection names --- .../contentstore/tests/modulestore_config.py | 8 +++++ .../contentstore/tests/test_contentstore.py | 6 ++-- .../contentstore/tests/test_i18n.py | 3 ++ .../tests/test_import_nostatic.py | 3 +- cms/djangoapps/contentstore/tests/tests.py | 5 ++- cms/djangoapps/contentstore/tests/utils.py | 3 ++ .../xmodule/modulestore/tests/django_utils.py | 35 +++++++++++++++++++ 7 files changed, 59 insertions(+), 4 deletions(-) create mode 100644 cms/djangoapps/contentstore/tests/modulestore_config.py diff --git a/cms/djangoapps/contentstore/tests/modulestore_config.py b/cms/djangoapps/contentstore/tests/modulestore_config.py new file mode 100644 index 0000000000..234fa66f9f --- /dev/null +++ b/cms/djangoapps/contentstore/tests/modulestore_config.py @@ -0,0 +1,8 @@ +""" +Define test configuration for modulestores. +""" + +from xmodule.modulestore.tests.django_utils import studio_store_config +from django.conf import settings + +TEST_MODULESTORE = studio_store_config(settings.TEST_ROOT / "data") diff --git a/cms/djangoapps/contentstore/tests/test_contentstore.py b/cms/djangoapps/contentstore/tests/test_contentstore.py index 7d5cd3cbcb..0a99dc58d1 100644 --- a/cms/djangoapps/contentstore/tests/test_contentstore.py +++ b/cms/djangoapps/contentstore/tests/test_contentstore.py @@ -25,6 +25,7 @@ from contentstore.tests.utils import parse_json from auth.authz import add_user_to_creator_group from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from contentstore.tests.modulestore_config import TEST_MODULESTORE from xmodule.modulestore.tests.factories import CourseFactory, ItemFactory from xmodule.modulestore import Location, mongo @@ -68,7 +69,7 @@ class MongoCollectionFindWrapper(object): return self.original(query, *args, **kwargs) -@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE) class ContentStoreToyCourseTest(ModuleStoreTestCase): """ Tests that rely on the toy courses. @@ -1180,7 +1181,7 @@ class ContentStoreToyCourseTest(ModuleStoreTestCase): export_to_xml(module_store, content_store, location, root_dir, 'test_export') -@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE) class ContentStoreTest(ModuleStoreTestCase): """ Tests for the CMS ContentStore application. @@ -1707,6 +1708,7 @@ class ContentStoreTest(ModuleStoreTestCase): content_store.find(location) +@override_settings(MODULESTORE=TEST_MODULESTORE) class MetadataSaveTestCase(ModuleStoreTestCase): """Test that metadata is correctly cached and decached.""" diff --git a/cms/djangoapps/contentstore/tests/test_i18n.py b/cms/djangoapps/contentstore/tests/test_i18n.py index e6baf57213..9e7a2df8b2 100644 --- a/cms/djangoapps/contentstore/tests/test_i18n.py +++ b/cms/djangoapps/contentstore/tests/test_i18n.py @@ -3,10 +3,13 @@ from unittest import skip from django.core.urlresolvers import reverse from django.contrib.auth.models import User from django.test.client import Client +from django.test.utils import override_settings from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from contentstore.tests.modulestore_config import TEST_MODULESTORE +@override_settings(MODULESTORE=TEST_MODULESTORE) class InternationalizationTest(ModuleStoreTestCase): """ Tests to validate Internationalization. diff --git a/cms/djangoapps/contentstore/tests/test_import_nostatic.py b/cms/djangoapps/contentstore/tests/test_import_nostatic.py index 67f2202011..305d986372 100644 --- a/cms/djangoapps/contentstore/tests/test_import_nostatic.py +++ b/cms/djangoapps/contentstore/tests/test_import_nostatic.py @@ -12,6 +12,7 @@ import copy from django.contrib.auth.models import User from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from contentstore.tests.modulestore_config import TEST_MODULESTORE from xmodule.modulestore import Location from xmodule.modulestore.django import modulestore @@ -30,7 +31,7 @@ TEST_DATA_CONTENTSTORE = copy.deepcopy(settings.CONTENTSTORE) TEST_DATA_CONTENTSTORE['OPTIONS']['db'] = 'test_xcontent_%s' % uuid4().hex -@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE) +@override_settings(CONTENTSTORE=TEST_DATA_CONTENTSTORE, MODULESTORE=TEST_MODULESTORE) class ContentStoreImportNoStaticTest(ModuleStoreTestCase): """ Tests that rely on the toy and test_import_course courses. diff --git a/cms/djangoapps/contentstore/tests/tests.py b/cms/djangoapps/contentstore/tests/tests.py index 0cbc82cbf1..eddf5ab25a 100644 --- a/cms/djangoapps/contentstore/tests/tests.py +++ b/cms/djangoapps/contentstore/tests/tests.py @@ -1,15 +1,18 @@ from django.test.client import Client +from django.test.utils import override_settings from django.core.cache import cache from django.core.urlresolvers import reverse -from .utils import parse_json, user, registration +from contentstore.tests.utils import parse_json, user, registration from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from contentstore.tests.test_course_settings import CourseTestCase from xmodule.modulestore.tests.factories import CourseFactory +from contentstore.tests.modulestore_config import TEST_MODULESTORE import datetime from pytz import UTC +@override_settings(MODULESTORE=TEST_MODULESTORE) class ContentStoreTestCase(ModuleStoreTestCase): def _login(self, email, password): """ diff --git a/cms/djangoapps/contentstore/tests/utils.py b/cms/djangoapps/contentstore/tests/utils.py index a3f211a703..8b3f4cf4b1 100644 --- a/cms/djangoapps/contentstore/tests/utils.py +++ b/cms/djangoapps/contentstore/tests/utils.py @@ -7,9 +7,11 @@ import json from student.models import Registration from django.contrib.auth.models import User from django.test.client import Client +from django.test.utils import override_settings from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory +from contentstore.tests.modulestore_config import TEST_MODULESTORE def parse_json(response): @@ -27,6 +29,7 @@ def registration(email): return Registration.objects.get(user__email=email) +@override_settings(MODULESTORE=TEST_MODULESTORE) class CourseTestCase(ModuleStoreTestCase): def setUp(self): """ diff --git a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py index 1f856d7eba..47418823e5 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/django_utils.py @@ -110,6 +110,41 @@ def xml_store_config(data_dir): return store +def studio_store_config(data_dir): + """ + Defines modulestore structure used by Studio tests. + """ + options = { + 'default_class': 'xmodule.raw_module.RawDescriptor', + 'host': 'localhost', + 'db': 'test_xmodule', + 'collection': 'modulestore_%s' % uuid4().hex, + 'fs_root': data_dir, + 'render_template': 'mitxmako.shortcuts.render_to_string', + } + + store = { + 'default': { + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', + 'OPTIONS': options + }, + 'direct': { + 'ENGINE': 'xmodule.modulestore.mongo.MongoModuleStore', + 'OPTIONS': options + }, + 'draft': { + 'ENGINE': 'xmodule.modulestore.draft.DraftModuleStore', + 'OPTIONS': options + }, + 'split': { + 'ENGINE': 'xmodule.modulestore.split_mongo.SplitMongoModuleStore', + 'OPTIONS': options + } + } + + return store + + class ModuleStoreTestCase(TestCase): """ Subclass for any test case that uses a ModuleStore. From d7225f026afcc518696e38108f692cd0ced8eb51 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 26 Aug 2013 12:25:10 -0400 Subject: [PATCH 204/244] Only store certain bits of information behind a flag. --- lms/djangoapps/shoppingcart/models.py | 14 +++--- .../processors/tests/test_CyberSource.py | 4 -- .../shoppingcart/tests/test_models.py | 48 +++++++++++++++++++ .../shoppingcart/tests/test_views.py | 1 - lms/envs/common.py | 5 +- 5 files changed, 60 insertions(+), 12 deletions(-) diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index 1ad71ff625..ce80f47121 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -2,6 +2,7 @@ import pytz import logging from datetime import datetime from django.db import models +from django.conf import settings from django.core.exceptions import ObjectDoesNotExist from django.contrib.auth.models import User from django.utils.translation import ugettext as _ @@ -102,15 +103,16 @@ class Order(models.Model): self.purchase_time = datetime.now(pytz.utc) self.bill_to_first = first self.bill_to_last = last - self.bill_to_street1 = street1 - self.bill_to_street2 = street2 self.bill_to_city = city self.bill_to_state = state - self.bill_to_postalcode = postalcode self.bill_to_country = country - self.bill_to_ccnum = ccnum - self.bill_to_cardtype = cardtype - self.processor_reply_dump = processor_reply_dump + self.bill_to_postalcode = postalcode + if settings.MITX_FEATURES['STORE_BILLING_INFO']: + self.bill_to_street1 = street1 + self.bill_to_street2 = street2 + self.bill_to_ccnum = ccnum + self.bill_to_cardtype = cardtype + self.processor_reply_dump = processor_reply_dump # save these changes on the order, then we can tell when we are in an # inconsistent state self.save() diff --git a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py index de9e5939f0..c88d0ca5e0 100644 --- a/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py +++ b/lms/djangoapps/shoppingcart/processors/tests/test_CyberSource.py @@ -116,14 +116,10 @@ class CyberSourceTests(TestCase): order2 = Order.get_cart_for_user(student2) record_purchase(params_cc, order1) record_purchase(params_nocc, order2) - self.assertEqual(order1.bill_to_ccnum, '1234') - self.assertEqual(order1.bill_to_cardtype, 'Visa') self.assertEqual(order1.bill_to_first, student1.first_name) self.assertEqual(order1.status, 'purchased') order2 = Order.objects.get(user=student2) - self.assertEqual(order2.bill_to_ccnum, '####') - self.assertEqual(order2.bill_to_cardtype, 'MasterCard') self.assertEqual(order2.bill_to_first, student2.first_name) self.assertEqual(order2.status, 'purchased') diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 75789964b1..65483d7404 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -6,6 +6,7 @@ from factory import DjangoModelFactory from mock import patch from django.test import TestCase from django.test.utils import override_settings +from django.conf import settings from django.db import DatabaseError from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -87,6 +88,53 @@ class OrderTest(TestCase): # verify that we rolled back the entire transaction self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + def purchase_with_data(self, cart): + """ purchase a cart with billing information """ + CertificateItem.add_to_order(cart, self.course_id, self.cost, 'verified') + cart.purchase( + first='John', + last='Smith', + street1='11 Cambridge Center', + street2='Suite 101', + city='Cambridge', + state='MA', + postalcode='02412', + country='US', + ccnum='1111', + cardtype='001', + ) + + @patch.dict(settings.MITX_FEATURES, {'STORE_BILLING_INFO': True}) + def test_billing_info_storage_on(self): + cart = Order.get_cart_for_user(self.user) + self.purchase_with_data(cart) + self.assertNotEqual(cart.bill_to_first, '') + self.assertNotEqual(cart.bill_to_last, '') + self.assertNotEqual(cart.bill_to_street1, '') + self.assertNotEqual(cart.bill_to_street2, '') + self.assertNotEqual(cart.bill_to_postalcode, '') + self.assertNotEqual(cart.bill_to_ccnum, '') + self.assertNotEqual(cart.bill_to_cardtype, '') + self.assertNotEqual(cart.bill_to_city, '') + self.assertNotEqual(cart.bill_to_state, '') + self.assertNotEqual(cart.bill_to_country, '') + + @patch.dict(settings.MITX_FEATURES, {'STORE_BILLING_INFO': False}) + def test_billing_info_storage_off(self): + cart = Order.get_cart_for_user(self.user) + cart = Order.get_cart_for_user(self.user) + self.purchase_with_data(cart) + self.assertNotEqual(cart.bill_to_first, '') + self.assertNotEqual(cart.bill_to_last, '') + self.assertNotEqual(cart.bill_to_city, '') + self.assertNotEqual(cart.bill_to_state, '') + self.assertNotEqual(cart.bill_to_country, '') + self.assertNotEqual(cart.bill_to_postalcode, '') + # things we expect to be missing when the feature is off + self.assertEqual(cart.bill_to_street1, '') + self.assertEqual(cart.bill_to_street2, '') + self.assertEqual(cart.bill_to_ccnum, '') + self.assertEqual(cart.bill_to_cardtype, '') class OrderItemTest(TestCase): def setUp(self): diff --git a/lms/djangoapps/shoppingcart/tests/test_views.py b/lms/djangoapps/shoppingcart/tests/test_views.py index 25ee914ce6..3ff3a25524 100644 --- a/lms/djangoapps/shoppingcart/tests/test_views.py +++ b/lms/djangoapps/shoppingcart/tests/test_views.py @@ -191,7 +191,6 @@ class ShoppingCartViewsTests(ModuleStoreTestCase): resp = self.client.get(reverse('shoppingcart.views.show_receipt', args=[self.cart.id])) self.assertEqual(resp.status_code, 200) self.assertIn('FirstNameTesting123', resp.content) - self.assertIn('StreetTesting123', resp.content) self.assertIn('80.00', resp.content) ((template, context), _) = render_mock.call_args diff --git a/lms/envs/common.py b/lms/envs/common.py index 8181f97789..0e659a1ca1 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -156,7 +156,10 @@ MITX_FEATURES = { 'ENABLE_CHAT': False, # Toggle the availability of the shopping cart page - 'ENABLE_SHOPPING_CART': False + 'ENABLE_SHOPPING_CART': False, + + # Toggle storing detailed billing information + 'STORE_BILLING_INFO': False } # Used for A/B testing From c8949b99d1d0e7624b0216a37887e69e76271470 Mon Sep 17 00:00:00 2001 From: Jay Zoldak Date: Tue, 20 Aug 2013 08:54:49 -0400 Subject: [PATCH 205/244] Disable pylint violation E0611 when importing assert_* methods from nose.tools Cleaned up files with muliline imports Cleaned up files that do not use these imports Misread comment --- cms/djangoapps/contentstore/features/advanced-settings.py | 2 +- cms/djangoapps/contentstore/features/checklists.py | 2 +- cms/djangoapps/contentstore/features/common.py | 2 +- cms/djangoapps/contentstore/features/component.py | 2 +- .../features/component_settings_editor_helpers.py | 2 +- cms/djangoapps/contentstore/features/course-overview.py | 2 +- cms/djangoapps/contentstore/features/course-settings.py | 2 +- cms/djangoapps/contentstore/features/course-team.py | 2 +- cms/djangoapps/contentstore/features/problem-editor.py | 2 +- cms/djangoapps/contentstore/features/section.py | 2 +- cms/djangoapps/contentstore/features/subsection.py | 2 +- .../djangoapps/static_replace/test/test_static_replace.py | 2 +- common/djangoapps/terrain/steps.py | 2 +- common/djangoapps/terrain/ui_helpers.py | 2 +- .../lib/xmodule/xmodule/modulestore/tests/test_location.py | 2 +- .../xmodule/modulestore/tests/test_mixed_modulestore.py | 5 ++++- .../xmodule/xmodule/modulestore/tests/test_modulestore.py | 2 +- common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py | 6 ++++-- common/lib/xmodule/xmodule/modulestore/tests/test_xml.py | 2 +- .../lib/xmodule/xmodule/tests/test_combined_open_ended.py | 3 +-- common/lib/xmodule/xmodule/tests/test_date_utils.py | 2 +- common/lib/xmodule/xmodule/tests/test_stringify.py | 2 +- common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py | 2 +- common/lib/xmodule/xmodule/tests/test_xml_module.py | 2 +- lms/djangoapps/courseware/features/common.py | 1 - lms/djangoapps/courseware/features/high-level-tabs.py | 1 - lms/djangoapps/courseware/features/homepage.py | 2 +- lms/djangoapps/courseware/features/login.py | 1 - lms/djangoapps/courseware/features/openended.py | 2 +- lms/djangoapps/courseware/features/problems.py | 1 - lms/djangoapps/courseware/features/problems_setup.py | 1 - lms/djangoapps/django_comment_client/base/tests.py | 2 +- lms/djangoapps/django_comment_client/forum/tests.py | 2 +- lms/djangoapps/licenses/tests.py | 2 +- pylintrc | 5 ----- 35 files changed, 35 insertions(+), 41 deletions(-) diff --git a/cms/djangoapps/contentstore/features/advanced-settings.py b/cms/djangoapps/contentstore/features/advanced-settings.py index 18e179abdb..201ac49e52 100644 --- a/cms/djangoapps/contentstore/features/advanced-settings.py +++ b/cms/djangoapps/contentstore/features/advanced-settings.py @@ -2,7 +2,7 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_false, assert_equal, assert_regexp_matches +from nose.tools import assert_false, assert_equal, assert_regexp_matches # pylint: disable=E0611 from common import type_in_codemirror, press_the_notification_button KEY_CSS = '.key input.policy-key' diff --git a/cms/djangoapps/contentstore/features/checklists.py b/cms/djangoapps/contentstore/features/checklists.py index e8dcd755a3..1c41eed4d3 100644 --- a/cms/djangoapps/contentstore/features/checklists.py +++ b/cms/djangoapps/contentstore/features/checklists.py @@ -2,7 +2,7 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_true, assert_equal, assert_in +from nose.tools import assert_true, assert_equal, assert_in # pylint: disable=E0611 from terrain.steps import reload_the_page from selenium.common.exceptions import StaleElementReferenceException diff --git a/cms/djangoapps/contentstore/features/common.py b/cms/djangoapps/contentstore/features/common.py index 39f28ba249..a6f22db340 100644 --- a/cms/djangoapps/contentstore/features/common.py +++ b/cms/djangoapps/contentstore/features/common.py @@ -2,7 +2,7 @@ # pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_true +from nose.tools import assert_true # pylint: disable=E0611 from auth.authz import get_user_by_email, get_course_groupname_for_role from django.conf import settings diff --git a/cms/djangoapps/contentstore/features/component.py b/cms/djangoapps/contentstore/features/component.py index 15727dd992..d0c1fd59e7 100644 --- a/cms/djangoapps/contentstore/features/component.py +++ b/cms/djangoapps/contentstore/features/component.py @@ -2,7 +2,7 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_true +from nose.tools import assert_true # pylint: disable=E0611 DATA_LOCATION = 'i4x://edx/templates' diff --git a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py index 606e3dcee8..2971085081 100644 --- a/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py +++ b/cms/djangoapps/contentstore/features/component_settings_editor_helpers.py @@ -2,7 +2,7 @@ #pylint: disable=C0111 from lettuce import world -from nose.tools import assert_equal +from nose.tools import assert_equal # pylint: disable=E0611 from terrain.steps import reload_the_page diff --git a/cms/djangoapps/contentstore/features/course-overview.py b/cms/djangoapps/contentstore/features/course-overview.py index 3fcb134f5b..289dbec308 100644 --- a/cms/djangoapps/contentstore/features/course-overview.py +++ b/cms/djangoapps/contentstore/features/course-overview.py @@ -3,7 +3,7 @@ from lettuce import world, step from common import * -from nose.tools import assert_true, assert_false, assert_equal +from nose.tools import assert_true, assert_false, assert_equal # pylint: disable=E0611 from logging import getLogger logger = getLogger(__name__) diff --git a/cms/djangoapps/contentstore/features/course-settings.py b/cms/djangoapps/contentstore/features/course-settings.py index beaa1fbad4..7004b9f99e 100644 --- a/cms/djangoapps/contentstore/features/course-settings.py +++ b/cms/djangoapps/contentstore/features/course-settings.py @@ -7,7 +7,7 @@ from selenium.webdriver.common.keys import Keys from common import type_in_codemirror, upload_file from django.conf import settings -from nose.tools import assert_true, assert_false, assert_equal +from nose.tools import assert_true, assert_false, assert_equal # pylint: disable=E0611 TEST_ROOT = settings.COMMON_TEST_DATA_ROOT diff --git a/cms/djangoapps/contentstore/features/course-team.py b/cms/djangoapps/contentstore/features/course-team.py index 8b31d325e5..85044dbbad 100644 --- a/cms/djangoapps/contentstore/features/course-team.py +++ b/cms/djangoapps/contentstore/features/course-team.py @@ -5,7 +5,7 @@ from lettuce import world, step from common import create_studio_user from django.contrib.auth.models import Group from auth.authz import get_course_groupname_for_role, get_user_by_email -from nose.tools import assert_true +from nose.tools import assert_true # pylint: disable=E0611 PASSWORD = 'test' EMAIL_EXTENSION = '@edx.org' diff --git a/cms/djangoapps/contentstore/features/problem-editor.py b/cms/djangoapps/contentstore/features/problem-editor.py index d891789e4a..5e4fe6364d 100644 --- a/cms/djangoapps/contentstore/features/problem-editor.py +++ b/cms/djangoapps/contentstore/features/problem-editor.py @@ -2,7 +2,7 @@ #pylint: disable=C0111 from lettuce import world, step -from nose.tools import assert_equal +from nose.tools import assert_equal # pylint: disable=E0611 from common import type_in_codemirror DISPLAY_NAME = "Display Name" diff --git a/cms/djangoapps/contentstore/features/section.py b/cms/djangoapps/contentstore/features/section.py index 3ca8e1676d..3fea8637c6 100644 --- a/cms/djangoapps/contentstore/features/section.py +++ b/cms/djangoapps/contentstore/features/section.py @@ -3,7 +3,7 @@ from lettuce import world, step from common import * -from nose.tools import assert_equal +from nose.tools import assert_equal # pylint: disable=E0611 ############### ACTIONS #################### diff --git a/cms/djangoapps/contentstore/features/subsection.py b/cms/djangoapps/contentstore/features/subsection.py index 60a325f550..6d9612d9bd 100644 --- a/cms/djangoapps/contentstore/features/subsection.py +++ b/cms/djangoapps/contentstore/features/subsection.py @@ -3,7 +3,7 @@ from lettuce import world, step from common import * -from nose.tools import assert_equal +from nose.tools import assert_equal # pylint: disable=E0611 ############### ACTIONS #################### diff --git a/common/djangoapps/static_replace/test/test_static_replace.py b/common/djangoapps/static_replace/test/test_static_replace.py index b1bc05b895..43a199c22c 100644 --- a/common/djangoapps/static_replace/test/test_static_replace.py +++ b/common/djangoapps/static_replace/test/test_static_replace.py @@ -1,6 +1,6 @@ import re -from nose.tools import assert_equals, assert_true, assert_false +from nose.tools import assert_equals, assert_true, assert_false # pylint: disable=E0611 from static_replace import (replace_static_urls, replace_course_urls, _url_replace_regex) from mock import patch, Mock diff --git a/common/djangoapps/terrain/steps.py b/common/djangoapps/terrain/steps.py index f13b3ff932..c4783d4aca 100644 --- a/common/djangoapps/terrain/steps.py +++ b/common/djangoapps/terrain/steps.py @@ -15,7 +15,7 @@ from lettuce import world, step from .course_helpers import * from .ui_helpers import * from lettuce.django import django_url -from nose.tools import assert_equals +from nose.tools import assert_equals # pylint: disable=E0611 from logging import getLogger logger = getLogger(__name__) diff --git a/common/djangoapps/terrain/ui_helpers.py b/common/djangoapps/terrain/ui_helpers.py index 3ab7e11b47..7d308931b2 100644 --- a/common/djangoapps/terrain/ui_helpers.py +++ b/common/djangoapps/terrain/ui_helpers.py @@ -10,7 +10,7 @@ from selenium.webdriver.support import expected_conditions as EC from selenium.webdriver.common.by import By from selenium.webdriver.support.ui import WebDriverWait from lettuce.django import django_url -from nose.tools import assert_true +from nose.tools import assert_true # pylint: disable=E0611 @world.absorb diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py index 7e8ba1731b..c190559c73 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_location.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_location.py @@ -1,4 +1,4 @@ -from nose.tools import assert_equals, assert_raises, assert_not_equals +from nose.tools import assert_equals, assert_raises, assert_not_equals # pylint: disable=E0611 from xmodule.modulestore import Location from xmodule.modulestore.exceptions import InvalidLocationError diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py index 22d1e40c3f..d4557b3dc0 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mixed_modulestore.py @@ -1,4 +1,7 @@ -from nose.tools import assert_equals, assert_raises, assert_false, assert_true, assert_not_equals +# pylint: disable=E0611 +from nose.tools import assert_equals, assert_raises, assert_false, \ + assert_true, assert_not_equals +# pylint: enable=E0611 import pymongo from uuid import uuid4 diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py b/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py index 1e2035075a..36d4a3b8de 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_modulestore.py @@ -1,4 +1,4 @@ -from nose.tools import assert_equals, assert_raises +from nose.tools import assert_equals, assert_raises # pylint: disable=E0611 from xmodule.modulestore.exceptions import ItemNotFoundError from xmodule.modulestore.search import path_to_location diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py index 40b3c6fd83..25ad258668 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_mongo.py @@ -1,6 +1,8 @@ from pprint import pprint - -from nose.tools import assert_equals, assert_raises, assert_not_equals, assert_false +# pylint: disable=E0611 +from nose.tools import assert_equals, assert_raises, \ + assert_not_equals, assert_false +# pylint: enable=E0611 import pymongo from uuid import uuid4 diff --git a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py index ffbce40874..9e15440f11 100644 --- a/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py +++ b/common/lib/xmodule/xmodule/modulestore/tests/test_xml.py @@ -1,6 +1,6 @@ import os.path -from nose.tools import assert_raises, assert_equals +from nose.tools import assert_raises, assert_equals # pylint: disable=E0611 from xmodule.course_module import CourseDescriptor from xmodule.modulestore.xml import XMLModuleStore 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 c07fd7e187..26c248f71d 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -729,7 +729,6 @@ class OpenEndedModuleXmlTest(unittest.TestCase, DummyModulestore): #Mock a student submitting an assessment assessment_dict = MockQueryDict() assessment_dict.update({'assessment': sum(assessment), 'score_list[]': assessment}) - #from nose.tools import set_trace; set_trace() module.handle_ajax("save_assessment", assessment_dict) task_one_json = json.loads(module.task_states[0]) self.assertEqual(json.loads(task_one_json['child_history'][0]['post_assessment']), assessment) @@ -836,4 +835,4 @@ class OpenEndedModuleXmlAttemptTest(unittest.TestCase, DummyModulestore): #Try to reset, should fail because only 1 attempt is allowed reset_data = json.loads(module.handle_ajax("reset", {})) - self.assertEqual(reset_data['success'], False) \ No newline at end of file + self.assertEqual(reset_data['success'], False) diff --git a/common/lib/xmodule/xmodule/tests/test_date_utils.py b/common/lib/xmodule/xmodule/tests/test_date_utils.py index bacc5de91b..37f30e1b56 100644 --- a/common/lib/xmodule/xmodule/tests/test_date_utils.py +++ b/common/lib/xmodule/xmodule/tests/test_date_utils.py @@ -1,6 +1,6 @@ """Tests for xmodule.util.date_utils""" -from nose.tools import assert_equals, assert_false +from nose.tools import assert_equals, assert_false # pylint: disable=E0611 from xmodule.util.date_utils import get_default_time_display, almost_same_datetime from datetime import datetime, timedelta, tzinfo from pytz import UTC diff --git a/common/lib/xmodule/xmodule/tests/test_stringify.py b/common/lib/xmodule/xmodule/tests/test_stringify.py index 49852ee233..10abaf72f5 100644 --- a/common/lib/xmodule/xmodule/tests/test_stringify.py +++ b/common/lib/xmodule/xmodule/tests/test_stringify.py @@ -1,4 +1,4 @@ -from nose.tools import assert_equals +from nose.tools import assert_equals # pylint: disable=E0611 from lxml import etree from xmodule.stringify import stringify_children diff --git a/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py b/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py index c98f980c62..0a97728ec7 100644 --- a/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py +++ b/common/lib/xmodule/xmodule/tests/test_xblock_wrappers.py @@ -3,7 +3,7 @@ Tests for the wrapping layer that provides the XBlock API using XModule/Descript functionality """ -from nose.tools import assert_equal +from nose.tools import assert_equal # pylint: disable=E0611 from unittest.case import SkipTest from mock import Mock diff --git a/common/lib/xmodule/xmodule/tests/test_xml_module.py b/common/lib/xmodule/xmodule/tests/test_xml_module.py index fdcff8ade2..3463140555 100644 --- a/common/lib/xmodule/xmodule/tests/test_xml_module.py +++ b/common/lib/xmodule/xmodule/tests/test_xml_module.py @@ -7,7 +7,7 @@ from xmodule.fields import Date, Timedelta from xmodule.xml_module import XmlDescriptor, serialize_field, deserialize_field import unittest from .import get_test_system -from nose.tools import assert_equals +from nose.tools import assert_equals # pylint: disable=E0611 from mock import Mock diff --git a/lms/djangoapps/courseware/features/common.py b/lms/djangoapps/courseware/features/common.py index b69dd4c866..349ad7e79e 100644 --- a/lms/djangoapps/courseware/features/common.py +++ b/lms/djangoapps/courseware/features/common.py @@ -4,7 +4,6 @@ from __future__ import absolute_import from lettuce import world, step -from nose.tools import assert_equals, assert_in from django.contrib.auth.models import User from student.models import CourseEnrollment from xmodule.modulestore import Location diff --git a/lms/djangoapps/courseware/features/high-level-tabs.py b/lms/djangoapps/courseware/features/high-level-tabs.py index 4e6ebb70dd..774ac841c5 100644 --- a/lms/djangoapps/courseware/features/high-level-tabs.py +++ b/lms/djangoapps/courseware/features/high-level-tabs.py @@ -1,5 +1,4 @@ from lettuce import world, step -from nose.tools import assert_equals @step(u'I click on the tabs then the page title should contain the following titles:') diff --git a/lms/djangoapps/courseware/features/homepage.py b/lms/djangoapps/courseware/features/homepage.py index 51c3277e69..bb01982191 100644 --- a/lms/djangoapps/courseware/features/homepage.py +++ b/lms/djangoapps/courseware/features/homepage.py @@ -2,7 +2,7 @@ #pylint: disable=W0621 from lettuce import world, step -from nose.tools import assert_in, assert_equals +from nose.tools import assert_in, assert_equals # pylint: disable=E0611 @step(u'I should see the following Partners in the Partners section') diff --git a/lms/djangoapps/courseware/features/login.py b/lms/djangoapps/courseware/features/login.py index ed788b1bd8..7cdfcd45b6 100644 --- a/lms/djangoapps/courseware/features/login.py +++ b/lms/djangoapps/courseware/features/login.py @@ -3,7 +3,6 @@ from lettuce import step, world from django.contrib.auth.models import User -from nose.tools import assert_true @step('I am an unactivated user$') diff --git a/lms/djangoapps/courseware/features/openended.py b/lms/djangoapps/courseware/features/openended.py index f7265be23c..92a597f074 100644 --- a/lms/djangoapps/courseware/features/openended.py +++ b/lms/djangoapps/courseware/features/openended.py @@ -3,7 +3,7 @@ from lettuce import world, step from lettuce.django import django_url -from nose.tools import assert_equals, assert_in +from nose.tools import assert_equals, assert_in # pylint: disable=E0611 from logging import getLogger logger = getLogger(__name__) diff --git a/lms/djangoapps/courseware/features/problems.py b/lms/djangoapps/courseware/features/problems.py index 997f77c8f2..1465d3614a 100644 --- a/lms/djangoapps/courseware/features/problems.py +++ b/lms/djangoapps/courseware/features/problems.py @@ -9,7 +9,6 @@ from lettuce import world, step from lettuce.django import django_url from common import i_am_registered_for_the_course from problems_setup import PROBLEM_DICT, answer_problem, problem_has_answer, add_problem_to_course -from nose.tools import assert_equal @step(u'I am viewing a "([^"]*)" problem with "([^"]*)" attempt') diff --git a/lms/djangoapps/courseware/features/problems_setup.py b/lms/djangoapps/courseware/features/problems_setup.py index 2ddbbcdeb8..fc2d91e5f1 100644 --- a/lms/djangoapps/courseware/features/problems_setup.py +++ b/lms/djangoapps/courseware/features/problems_setup.py @@ -19,7 +19,6 @@ from capa.tests.response_xml_factory import OptionResponseXMLFactory, \ StringResponseXMLFactory, NumericalResponseXMLFactory, \ FormulaResponseXMLFactory, CustomResponseXMLFactory, \ CodeResponseXMLFactory, ChoiceTextResponseXMLFactory -from nose.tools import assert_true # Factories from capa.tests.response_xml_factory that we will use diff --git a/lms/djangoapps/django_comment_client/base/tests.py b/lms/djangoapps/django_comment_client/base/tests.py index e6ce3b2d25..45b4ecb5cc 100644 --- a/lms/djangoapps/django_comment_client/base/tests.py +++ b/lms/djangoapps/django_comment_client/base/tests.py @@ -11,7 +11,7 @@ from django.core.management import call_command from util.testing import UrlResetMixin from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -from nose.tools import assert_true, assert_equal +from nose.tools import assert_true, assert_equal # pylint: disable=E0611 from mock import patch log = logging.getLogger(__name__) diff --git a/lms/djangoapps/django_comment_client/forum/tests.py b/lms/djangoapps/django_comment_client/forum/tests.py index 2d889722a4..0fc5a14c70 100644 --- a/lms/djangoapps/django_comment_client/forum/tests.py +++ b/lms/djangoapps/django_comment_client/forum/tests.py @@ -7,7 +7,7 @@ from django.core.urlresolvers import reverse from util.testing import UrlResetMixin from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE -from nose.tools import assert_true +from nose.tools import assert_true # pylint: disable=E0611 from mock import patch, Mock import logging diff --git a/lms/djangoapps/licenses/tests.py b/lms/djangoapps/licenses/tests.py index a853955c83..4298bec81b 100644 --- a/lms/djangoapps/licenses/tests.py +++ b/lms/djangoapps/licenses/tests.py @@ -12,7 +12,7 @@ from django.test.client import Client from django.test.utils import override_settings from django.core.management import call_command from django.core.urlresolvers import reverse -from nose.tools import assert_true +from nose.tools import assert_true # pylint: disable=E0611 from courseware.tests.modulestore_config import TEST_DATA_MIXED_MODULESTORE from licenses.models import CourseSoftware, UserLicense diff --git a/pylintrc b/pylintrc index 48e301d23d..9525f04362 100644 --- a/pylintrc +++ b/pylintrc @@ -100,11 +100,6 @@ ignore-mixin-members=yes # (useful for classes with attributes dynamically set). ignored-classes=SQLObject -# See http://stackoverflow.com/questions/17156240/nose-tools-and-pylint -# Pylint does not like the way that nose tools inspects and makes available -# the assert classes -ignored-classes=nose.tools,nose.tools.trivial - # When zope mode is activated, add a predefined set of Zope acquired attributes # to generated-members. zope=no From 1b5fde9dae001ff1742effca7bdd9b7547ac60b1 Mon Sep 17 00:00:00 2001 From: Diana Huang Date: Mon, 26 Aug 2013 17:20:28 -0400 Subject: [PATCH 206/244] Send email on purchase success. --- lms/djangoapps/shoppingcart/models.py | 18 ++++++++++++++++-- .../shoppingcart/tests/test_models.py | 9 +++++++++ .../emails/order_confirmation_email.txt | 15 +++++++++++++++ 3 files changed, 40 insertions(+), 2 deletions(-) create mode 100644 lms/templates/emails/order_confirmation_email.txt diff --git a/lms/djangoapps/shoppingcart/models.py b/lms/djangoapps/shoppingcart/models.py index ce80f47121..a80388f971 100644 --- a/lms/djangoapps/shoppingcart/models.py +++ b/lms/djangoapps/shoppingcart/models.py @@ -1,5 +1,6 @@ import pytz import logging +import smtplib from datetime import datetime from django.db import models from django.conf import settings @@ -9,7 +10,9 @@ from django.utils.translation import ugettext as _ from django.db import transaction from model_utils.managers import InheritanceManager from courseware.courses import get_course_about_section +from django.core.mail import send_mail +from mitxmako.shortcuts import render_to_string from xmodule.modulestore.django import modulestore from xmodule.course_module import CourseDescriptor @@ -121,6 +124,17 @@ class Order(models.Model): orderitems = OrderItem.objects.filter(order=self).select_subclasses() for item in orderitems: item.purchase_item() + # send confirmation e-mail + subject = _("Order Payment Confirmation") + message = render_to_string('emails/order_confirmation_email.txt', { + 'order': self, + 'order_items': orderitems + }) + try: + send_mail(subject, message, + settings.DEFAULT_FROM_EMAIL, [self.user.email]) + except smtplib.SMTPException: + log.error('Failed sending confirmation e-mail for order %d', self.id) class OrderItem(models.Model): @@ -307,8 +321,8 @@ class CertificateItem(OrderItem): item.status = order.status item.qty = 1 item.unit_cost = cost - item.line_desc = "{mode} certificate for course {course_id}".format(mode=item.mode, - course_id=course_id) + item.line_desc = _("{mode} certificate for course {course_id}").format(mode=item.mode, + course_id=course_id) item.currency = currency order.currency = currency order.save() diff --git a/lms/djangoapps/shoppingcart/tests/test_models.py b/lms/djangoapps/shoppingcart/tests/test_models.py index 65483d7404..42df02267d 100644 --- a/lms/djangoapps/shoppingcart/tests/test_models.py +++ b/lms/djangoapps/shoppingcart/tests/test_models.py @@ -6,6 +6,7 @@ from factory import DjangoModelFactory from mock import patch from django.test import TestCase from django.test.utils import override_settings +from django.core import mail from django.conf import settings from django.db import DatabaseError from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase @@ -77,6 +78,12 @@ class OrderTest(TestCase): cart.purchase() self.assertTrue(CourseEnrollment.is_enrolled(self.user, self.course_id)) + # test e-mail sending + self.assertEquals(len(mail.outbox), 1) + self.assertEquals('Order Payment Confirmation', mail.outbox[0].subject) + self.assertIn(settings.PAYMENT_SUPPORT_EMAIL, mail.outbox[0].body) + self.assertIn(unicode(cart.total_cost), mail.outbox[0].body) + def test_purchase_item_failure(self): # once again, we're testing against the specific implementation of # CertificateItem @@ -87,6 +94,8 @@ class OrderTest(TestCase): cart.purchase() # verify that we rolled back the entire transaction self.assertFalse(CourseEnrollment.is_enrolled(self.user, self.course_id)) + # verify that e-mail wasn't sent + self.assertEquals(len(mail.outbox), 0) def purchase_with_data(self, cart): """ purchase a cart with billing information """ diff --git a/lms/templates/emails/order_confirmation_email.txt b/lms/templates/emails/order_confirmation_email.txt new file mode 100644 index 0000000000..4bbc70491b --- /dev/null +++ b/lms/templates/emails/order_confirmation_email.txt @@ -0,0 +1,15 @@ +<%! from django.utils.translation import ugettext as _ %> + +${_("Thank you for your order! Payment was successful, and you should be able to see the results on your dashboard.")} + +${_("Your order number is: {order_number}").format(order_number=order.id)} + +${_("Items in your order:")} + +${_("Quantity - Description - Price")} +%for order_item in order_items: + ${order_item.qty} - ${order_item.line_desc} - $(order_item.line_cost} +%endfor +${_("Total: {total_cost}").format(total_cost=order.total_cost)} + +${_("If you have any issues, please contact us at {billing_email}").format(billing_email=settings.PAYMENT_SUPPORT_EMAIL)} From d8345b4b3901b1572e7e15cba8ffca3360b044f6 Mon Sep 17 00:00:00 2001 From: ichuang Date: Tue, 27 Aug 2013 18:46:50 -0400 Subject: [PATCH 207/244] fix course tabs from being imported with display_name=None due to overwriting policy --- common/lib/xmodule/xmodule/modulestore/mongo/base.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/common/lib/xmodule/xmodule/modulestore/mongo/base.py b/common/lib/xmodule/xmodule/modulestore/mongo/base.py index e01606af37..b1fecec120 100644 --- a/common/lib/xmodule/xmodule/modulestore/mongo/base.py +++ b/common/lib/xmodule/xmodule/modulestore/mongo/base.py @@ -794,7 +794,7 @@ class MongoModuleStore(ModuleStoreBase): existing_tabs = course.tabs or [] for tab in existing_tabs: if tab.get('url_slug') == loc.name: - tab['name'] = metadata.get('display_name') + tab['name'] = tab.get('name', metadata.get('display_name')) break course.tabs = existing_tabs # Save the updates to the course to the MongoKeyValueStore From 4a706641f21657d996d6dda40d9a99180386cc18 Mon Sep 17 00:00:00 2001 From: ichuang Date: Tue, 27 Aug 2013 20:03:35 -0400 Subject: [PATCH 208/244] add test for tab names importing correctly --- cms/djangoapps/contentstore/tests/test_import_nostatic.py | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/cms/djangoapps/contentstore/tests/test_import_nostatic.py b/cms/djangoapps/contentstore/tests/test_import_nostatic.py index 305d986372..f0f65c9b07 100644 --- a/cms/djangoapps/contentstore/tests/test_import_nostatic.py +++ b/cms/djangoapps/contentstore/tests/test_import_nostatic.py @@ -127,3 +127,9 @@ class ContentStoreImportNoStaticTest(ModuleStoreTestCase): handouts = module_store.get_item(Location(['i4x', 'edX', 'toy', 'html', 'toyhtml', None])) self.assertIn('/static/', handouts.data) + + def test_tab_name_imports_correctly(self): + module_store, content_store, course, course_location = self.load_test_import_course() + print "course tabs = {0}".format(course.tabs) + self.assertEqual(course.tabs[2]['name'],'Syllabus') + From 493d8d26e592400230f51905851a23ccfc634d5b Mon Sep 17 00:00:00 2001 From: ichuang Date: Tue, 27 Aug 2013 20:04:55 -0400 Subject: [PATCH 209/244] add sample tabs to test_import_course --- common/test/data/test_import_course/tabs/resources.html | 1 + common/test/data/test_import_course/tabs/syllabus.html | 1 + 2 files changed, 2 insertions(+) create mode 100644 common/test/data/test_import_course/tabs/resources.html create mode 100644 common/test/data/test_import_course/tabs/syllabus.html diff --git a/common/test/data/test_import_course/tabs/resources.html b/common/test/data/test_import_course/tabs/resources.html new file mode 100644 index 0000000000..0f6bdf0984 --- /dev/null +++ b/common/test/data/test_import_course/tabs/resources.html @@ -0,0 +1 @@ +
    resources!
    diff --git a/common/test/data/test_import_course/tabs/syllabus.html b/common/test/data/test_import_course/tabs/syllabus.html new file mode 100644 index 0000000000..45c2a7fc8f --- /dev/null +++ b/common/test/data/test_import_course/tabs/syllabus.html @@ -0,0 +1 @@ +

    This is a syllabus

    From 4936b5044bf15f08943deba46ca1bc558f0ced3b Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Wed, 21 Aug 2013 14:33:21 +0300 Subject: [PATCH 210/244] Fix for bug BLD-277 "There is a white panel over a non-youtube video." When the captions file is not specified, we simply do not render the captions panel. --- .../xmodule/js/src/video/09_video_caption.js | 35 ++++++++++++++++--- 1 file changed, 31 insertions(+), 4 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index a34f33ba4c..d313a7f485 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -11,7 +11,14 @@ function () { state.videoCaption = {}; _makeFunctionsPublic(state); - state.videoCaption.renderElements(); + + // Depending on whether captions file could be loaded, the following + // function invocation can succeed or fail. If it fails, we do not + // go on with binding handlers to events. + if (!state.videoCaption.renderElements()) { + return; + } + state.videoCaption.bindHandlers(); }; @@ -71,7 +78,16 @@ function () { this.el.find('.video-wrapper').after(this.videoCaption.subtitlesEl); this.el.find('.video-controls .secondary-controls').append(this.videoCaption.hideSubtitlesEl); - this.videoCaption.fetchCaption(); + // Fetch the captions file. If no file was specified, then we hide + // the "CC" button, and exit from this module. No further caption + // initialization will happen. + if (!this.videoCaption.fetchCaption()) { + this.videoCaption.hideSubtitlesEl.hide(); + + // Abandon all further operations with captions panel. + return false; + } + this.videoCaption.setSubtitlesHeight(); if (this.videoType === 'html5') { @@ -80,6 +96,8 @@ function () { this.videoCaption.subtitlesEl.addClass('html5'); this.captionHideTimeout = setTimeout(this.videoCaption.autoHideCaptions, this.videoCaption.fadeOutTimeout); } + + return true; } // function bindHandlers() @@ -123,8 +141,12 @@ function () { this.videoCaption.hideCaptions(this.hide_captions); + // Check whether the captions file was specified. This is the point + // where we either stop with the caption panel (so that a white empty + // panel to the right of the video will not be shown), or carry on + // further. if (!this.youtubeId('1.0')) { - return; + return false; } $.ajaxWithPrefix({ @@ -137,13 +159,18 @@ function () { if (onTouchBasedDevice()) { _this.videoCaption.subtitlesEl.find('li').html( - gettext('Caption will be displayed when you start playing the video.') + gettext( + 'Caption will be displayed when ' + + 'you start playing the video.' + ) ); } else { _this.videoCaption.renderCaption(); } } }); + + return true; } function captionURL() { From e788d6ce37f9b7999fbc34ead6dae2ed5c322bbf Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Thu, 22 Aug 2013 12:10:03 +0300 Subject: [PATCH 211/244] Added Jasmine test for bug fix. Updated comments. --- .../js/spec/video/video_caption_spec.js | 16 +++++ .../xmodule/js/src/video/01_initialize.js | 10 ++- .../xmodule/js/src/video/09_video_caption.js | 62 ++++++++++++++++--- 3 files changed, 76 insertions(+), 12 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 9458b483da..eaf21b10fd 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 @@ -126,6 +126,22 @@ expect(videoCaption.rendered).toBeFalsy(); }); }); + + describe('when no captions file was specified', function () { + beforeEach(function () { + loadFixtures('video_all.html'); + + // Unspecify the captions file. + $('#example').find('#video_id').data('sub', ''); + + state = new Video('#example'); + videoCaption = state.videoCaption; + }); + + it('captions panel is not shown', function () { + expect(videoCaption.hideSubtitlesEl.css('display')).toBe('none'); + }); + }); }); describe('mouse movement', function() { diff --git a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js index 79bc16dbda..1efb1a1871 100644 --- a/common/lib/xmodule/xmodule/js/src/video/01_initialize.js +++ b/common/lib/xmodule/xmodule/js/src/video/01_initialize.js @@ -25,7 +25,9 @@ function (VideoPlayer) { * * Initialize module exports this function. * - * @param {Object} state A place for all properties, and methods of Video. + * @param {object} state The object containg the state of the video player. + * All other modules, their parameters, public variables, etc. are + * available via this object. * @param {DOM element} element Container of the entire Video DOM element. */ return function (state, element) { @@ -40,10 +42,12 @@ function (VideoPlayer) { /** * @function _makeFunctionsPublic * - * Functions which will be accessible via 'state' object. When called, these functions will get the 'state' + * Functions which will be accessible via 'state' object. When called, + * these functions will get the 'state' * object as a context. * - * @param {Object} state A place for all properties, and methods of Video. + * @param {object} state The object containg the state (properties, + * methods, modules) of the Video player. */ function _makeFunctionsPublic(state) { state.setSpeed = _.bind(setSpeed, state); diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index d313a7f485..69ec14d4ff 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -6,7 +6,20 @@ define( [], function () { - // VideoCaption() function - what this module "exports". + /** + * @desc VideoCaption module exports a function. + * + * @type {function} + * @access public + * + * @param {object} state - The object containg the state of the video + * player. All other modules, their parameters, public variables, etc. + * are available via this object. + * + * @this {object} The global window object. + * + * @returns {undefined} + */ return function (state) { state.videoCaption = {}; @@ -64,11 +77,25 @@ function () { // The magic private function that makes them available and sets up their context is makeFunctionsPublic(). // *************************************************************** - // function renderElements() - // - // Create any necessary DOM elements, attach them, and set their initial configuration. Also - // make the created DOM elements available via the 'state' object. Much easier to work this - // way - you don't have to do repeated jQuery element selects. + /** + * @desc Create any necessary DOM elements, attach them, and set their + * initial configuration. Also make the created DOM elements available + * via the 'state' object. Much easier to work this way - you don't + * have to do repeated jQuery element selects. + * + * @type {function} + * @access public + * + * @this {object} - The object containg the state of the video + * player. All other modules, their parameters, public variables, etc. + * are available via this object. + * + * @returns {boolean} + * true: The function fethched captions successfully, and compltely + * rendered everything related to captions. + * false: The captions were not fetched. Nothing will be rendered, + * and the CC button will be hidden. + */ function renderElements() { this.videoCaption.loaded = false; @@ -79,12 +106,10 @@ function () { this.el.find('.video-controls .secondary-controls').append(this.videoCaption.hideSubtitlesEl); // Fetch the captions file. If no file was specified, then we hide - // the "CC" button, and exit from this module. No further caption - // initialization will happen. + // the "CC" button, and return. if (!this.videoCaption.fetchCaption()) { this.videoCaption.hideSubtitlesEl.hide(); - // Abandon all further operations with captions panel. return false; } @@ -136,6 +161,25 @@ function () { } } + /** + * @desc Fetch the caption file specified by the user. Upn successful + * receival of the file, the captions will be rendered. + * + * @type {function} + * @access public + * + * @this {object} - The object containg the state of the video + * player. All other modules, their parameters, public variables, etc. + * are available via this object. + * + * @returns {boolean} + * true: The user specified a caption file. NOTE: if an error happens + * while the specified file is being retrieved (for example the + * file is missing on the server), this function will still return + * true. + * false: No caption file was specified, or an empty string was + * specified. + */ function fetchCaption() { var _this = this; From ba954ff1647b8f3e536ade87d6d9bd0e00a16621 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Tue, 27 Aug 2013 10:19:55 +0300 Subject: [PATCH 212/244] Fixing code as per suggestions on PR 771. --- .../xmodule/xmodule/js/src/video/09_video_caption.js | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index 69ec14d4ff..e597f2736c 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -28,11 +28,9 @@ function () { // Depending on whether captions file could be loaded, the following // function invocation can succeed or fail. If it fails, we do not // go on with binding handlers to events. - if (!state.videoCaption.renderElements()) { - return; + if (state.videoCaption.renderElements()) { + state.videoCaption.bindHandlers(); } - - state.videoCaption.bindHandlers(); }; // *************************************************************** @@ -102,9 +100,6 @@ function () { this.videoCaption.subtitlesEl = this.el.find('ol.subtitles'); this.videoCaption.hideSubtitlesEl = this.el.find('a.hide-subtitles'); - this.el.find('.video-wrapper').after(this.videoCaption.subtitlesEl); - this.el.find('.video-controls .secondary-controls').append(this.videoCaption.hideSubtitlesEl); - // Fetch the captions file. If no file was specified, then we hide // the "CC" button, and return. if (!this.videoCaption.fetchCaption()) { @@ -113,6 +108,9 @@ function () { return false; } + this.el.find('.video-wrapper').after(this.videoCaption.subtitlesEl); + this.el.find('.video-controls .secondary-controls').append(this.videoCaption.hideSubtitlesEl); + this.videoCaption.setSubtitlesHeight(); if (this.videoType === 'html5') { From 2a4f7f61c57dfac21f2951936eb5cbf586409203 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Wed, 28 Aug 2013 13:28:40 +0300 Subject: [PATCH 213/244] Remove caption panel when anything bad happens. Now the captions panel will be shown with captions only after successful retrieval of captions. --- .../xmodule/js/src/video/09_video_caption.js | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index e597f2736c..621a2ebfe4 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -25,12 +25,7 @@ function () { _makeFunctionsPublic(state); - // Depending on whether captions file could be loaded, the following - // function invocation can succeed or fail. If it fails, we do not - // go on with binding handlers to events. - if (state.videoCaption.renderElements()) { - state.videoCaption.bindHandlers(); - } + state.videoCaption.renderElements(); }; // *************************************************************** @@ -100,27 +95,7 @@ function () { this.videoCaption.subtitlesEl = this.el.find('ol.subtitles'); this.videoCaption.hideSubtitlesEl = this.el.find('a.hide-subtitles'); - // Fetch the captions file. If no file was specified, then we hide - // the "CC" button, and return. - if (!this.videoCaption.fetchCaption()) { - this.videoCaption.hideSubtitlesEl.hide(); - - return false; - } - - this.el.find('.video-wrapper').after(this.videoCaption.subtitlesEl); - this.el.find('.video-controls .secondary-controls').append(this.videoCaption.hideSubtitlesEl); - - this.videoCaption.setSubtitlesHeight(); - - if (this.videoType === 'html5') { - this.videoCaption.fadeOutTimeout = this.config.fadeOutTimeout; - - this.videoCaption.subtitlesEl.addClass('html5'); - this.captionHideTimeout = setTimeout(this.videoCaption.autoHideCaptions, this.videoCaption.fadeOutTimeout); - } - - return true; + this.videoCaption.fetchCaption(); } // function bindHandlers() @@ -181,8 +156,6 @@ function () { function fetchCaption() { var _this = this; - this.videoCaption.hideCaptions(this.hide_captions); - // Check whether the captions file was specified. This is the point // where we either stop with the caption panel (so that a white empty // panel to the right of the video will not be shown), or carry on @@ -191,10 +164,12 @@ function () { return false; } + // Fetch the captions file. If no file was specified, or if an error + // occurred, then we hide the captions panel, and the "CC" button $.ajaxWithPrefix({ url: _this.videoCaption.captionURL(), notifyOnError: false, - success: function(captions) { + success: function (captions) { _this.videoCaption.captions = captions.text; _this.videoCaption.start = captions.start; _this.videoCaption.loaded = true; @@ -209,6 +184,16 @@ function () { } else { _this.videoCaption.renderCaption(); } + }, + error: function (jqXHR, textStatus, errorThrown) { + console.log('ERROR while fetching captions.'); + console.log( + 'STATUS:', textStatus + ', MESSAGE:', '' + errorThrown + ); + console.log('arguments:', arguments); + + _this.videoCaption.hideCaptions(true); + _this.videoCaption.hideSubtitlesEl.hide(); } }); @@ -300,6 +285,22 @@ function () { _this = this; container = $('
      '); + this.el.find('.video-wrapper').after(this.videoCaption.subtitlesEl); + this.el.find('.video-controls .secondary-controls').append(this.videoCaption.hideSubtitlesEl); + + this.videoCaption.setSubtitlesHeight(); + + if (this.videoType === 'html5') { + this.videoCaption.fadeOutTimeout = this.config.fadeOutTimeout; + + this.videoCaption.subtitlesEl.addClass('html5'); + this.captionHideTimeout = setTimeout(this.videoCaption.autoHideCaptions, this.videoCaption.fadeOutTimeout); + } + + this.videoCaption.hideCaptions(this.hide_captions); + + this.videoCaption.bindHandlers(); + $.each(this.videoCaption.captions, function(index, text) { var liEl = $('
    1. '); From 33fa1e2c7f7ca6a9f0d64b9d64355610e549626b Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Wed, 28 Aug 2013 17:15:52 +0300 Subject: [PATCH 214/244] Applying code changes as suggested by Anton. Fixed 2 failing Jasmine tests. Removed unnecessary console.log() call. Initializing of variables in a single var statement. --- .../xmodule/xmodule/js/spec/video/video_caption_spec.js | 5 +++-- .../lib/xmodule/xmodule/js/src/video/09_video_caption.js | 9 +++++---- 2 files changed, 8 insertions(+), 6 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 eaf21b10fd..b4d31c66db 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 @@ -53,7 +53,8 @@ expect($.ajaxWithPrefix).toHaveBeenCalledWith({ url: videoCaption.captionURL(), notifyOnError: false, - success: jasmine.any(Function) + success: jasmine.any(Function), + error: jasmine.any(Function), }); }); }); @@ -462,7 +463,7 @@ }); // Temporarily disabled due to intermittent failures - // Fails with error: "InvalidStateError: An attempt was made to + // Fails with error: "InvalidStateError: An attempt was made to // use an object that is not, or is no longer, usable // Expected 0 to equal 14.91." // on Firefox diff --git a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js index 621a2ebfe4..2ebb73c692 100644 --- a/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js +++ b/common/lib/xmodule/xmodule/js/src/video/09_video_caption.js @@ -95,7 +95,10 @@ function () { this.videoCaption.subtitlesEl = this.el.find('ol.subtitles'); this.videoCaption.hideSubtitlesEl = this.el.find('a.hide-subtitles'); - this.videoCaption.fetchCaption(); + if (!this.videoCaption.fetchCaption()) { + this.videoCaption.hideCaptions(true); + this.videoCaption.hideSubtitlesEl.hide(); + } } // function bindHandlers() @@ -190,7 +193,6 @@ function () { console.log( 'STATUS:', textStatus + ', MESSAGE:', '' + errorThrown ); - console.log('arguments:', arguments); _this.videoCaption.hideCaptions(true); _this.videoCaption.hideSubtitlesEl.hide(); @@ -281,9 +283,8 @@ function () { } function renderCaption() { - var container, + var container = $('
        '), _this = this; - container = $('
          '); this.el.find('.video-wrapper').after(this.videoCaption.subtitlesEl); this.el.find('.video-controls .secondary-controls').append(this.videoCaption.hideSubtitlesEl); From 0a110b672dad85878de13d9381253b558a015212 Mon Sep 17 00:00:00 2001 From: Valera Rozuvan Date: Wed, 28 Aug 2013 17:23:42 +0300 Subject: [PATCH 215/244] Using native Jasmine-jQuery method to check for hidden el. --- common/lib/xmodule/xmodule/js/spec/video/video_caption_spec.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 b4d31c66db..0b26573568 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 @@ -140,7 +140,7 @@ }); it('captions panel is not shown', function () { - expect(videoCaption.hideSubtitlesEl.css('display')).toBe('none'); + expect(videoCaption.hideSubtitlesEl).toBeHidden(); }); }); }); From 7d79f4fe3757a08e676bd906a392dd4bdf20961d Mon Sep 17 00:00:00 2001 From: Calen Pennington Date: Tue, 27 Aug 2013 15:52:14 -0400 Subject: [PATCH 216/244] Move mitxmako initialization to a startup module, called by lms.startup and cms.startup for both management commands and when run via wsgi [DEVPAIN-7] --- common/djangoapps/mitxmako/middleware.py | 25 -------------- common/djangoapps/mitxmako/shortcuts.py | 8 ++--- common/djangoapps/mitxmako/startup.py | 33 +++++++++++++++++++ common/djangoapps/mitxmako/template.py | 9 ++--- .../management/commands/6002exportusers.py | 4 --- .../management/commands/6002importusers.py | 4 --- .../management/commands/assigngroups.py | 3 -- .../student/management/commands/emaillist.py | 4 --- .../student/management/commands/massemail.py | 8 ++--- .../management/commands/massemailtxt.py | 8 ++--- .../student/management/commands/userinfo.py | 3 -- common/lib/django_startup.py | 2 +- lms/djangoapps/certificates/queue.py | 9 ----- .../management/commands/check_course.py | 2 -- lms/djangoapps/courseware/tests/test_views.py | 5 +++ .../instructor_task/tasks_helper.py | 21 ------------ 16 files changed, 54 insertions(+), 94 deletions(-) create mode 100644 common/djangoapps/mitxmako/startup.py diff --git a/common/djangoapps/mitxmako/middleware.py b/common/djangoapps/mitxmako/middleware.py index 5646d2f4b5..daaddf6b87 100644 --- a/common/djangoapps/mitxmako/middleware.py +++ b/common/djangoapps/mitxmako/middleware.py @@ -12,36 +12,11 @@ # See the License for the specific language governing permissions and # limitations under the License. -from mako.lookup import TemplateLookup -import tempdir from django.template import RequestContext -from django.conf import settings requestcontext = None -lookup = {} - class MakoMiddleware(object): - def __init__(self): - """Setup mako variables and lookup object""" - # Set all mako variables based on django settings - template_locations = settings.MAKO_TEMPLATES - module_directory = getattr(settings, 'MAKO_MODULE_DIR', None) - - if module_directory is None: - module_directory = tempdir.mkdtemp_clean() - - for location in template_locations: - lookup[location] = TemplateLookup(directories=template_locations[location], - module_directory=module_directory, - output_encoding='utf-8', - input_encoding='utf-8', - default_filters=['decode.utf8'], - encoding_errors='replace', - ) - - import mitxmako - mitxmako.lookup = lookup def process_request(self, request): global requestcontext diff --git a/common/djangoapps/mitxmako/shortcuts.py b/common/djangoapps/mitxmako/shortcuts.py index 3c68fa85be..974eaefdd3 100644 --- a/common/djangoapps/mitxmako/shortcuts.py +++ b/common/djangoapps/mitxmako/shortcuts.py @@ -16,7 +16,7 @@ from django.template import Context from django.http import HttpResponse import logging -from . import middleware +import mitxmako from django.conf import settings from django.core.urlresolvers import reverse log = logging.getLogger(__name__) @@ -80,15 +80,15 @@ def render_to_string(template_name, dictionary, context=None, namespace='main'): context_instance['marketing_link'] = marketing_link # In various testing contexts, there might not be a current request context. - if middleware.requestcontext is not None: - for d in middleware.requestcontext: + if mitxmako.middleware.requestcontext is not None: + for d in mitxmako.middleware.requestcontext: context_dictionary.update(d) for d in context_instance: context_dictionary.update(d) if context: context_dictionary.update(context) # fetch and render template - template = middleware.lookup[namespace].get_template(template_name) + template = mitxmako.lookup[namespace].get_template(template_name) return template.render_unicode(**context_dictionary) diff --git a/common/djangoapps/mitxmako/startup.py b/common/djangoapps/mitxmako/startup.py new file mode 100644 index 0000000000..db9483b366 --- /dev/null +++ b/common/djangoapps/mitxmako/startup.py @@ -0,0 +1,33 @@ +""" +Initialize the mako template lookup +""" + +import tempdir +from django.conf import settings +from mako.lookup import TemplateLookup + +import mitxmako + + +def run(): + """Setup mako variables and lookup object""" + # Set all mako variables based on django settings + template_locations = settings.MAKO_TEMPLATES + module_directory = getattr(settings, 'MAKO_MODULE_DIR', None) + + if module_directory is None: + module_directory = tempdir.mkdtemp_clean() + + lookup = {} + + for location in template_locations: + lookup[location] = TemplateLookup( + directories=template_locations[location], + module_directory=module_directory, + output_encoding='utf-8', + input_encoding='utf-8', + default_filters=['decode.utf8'], + encoding_errors='replace', + ) + + mitxmako.lookup = lookup diff --git a/common/djangoapps/mitxmako/template.py b/common/djangoapps/mitxmako/template.py index 5becfbf1df..7dfc6de026 100644 --- a/common/djangoapps/mitxmako/template.py +++ b/common/djangoapps/mitxmako/template.py @@ -16,7 +16,8 @@ from django.conf import settings from mako.template import Template as MakoTemplate from mitxmako.shortcuts import marketing_link -from mitxmako import middleware +import mitxmako +import mitxmako.middleware django_variables = ['lookup', 'output_encoding', 'encoding_errors'] @@ -33,7 +34,7 @@ class Template(MakoTemplate): def __init__(self, *args, **kwargs): """Overrides base __init__ to provide django variable overrides""" if not kwargs.get('no_django', False): - overrides = dict([(k, getattr(middleware, k, None),) for k in django_variables]) + overrides = dict([(k, getattr(mitxmako, k, None),) for k in django_variables]) overrides['lookup'] = overrides['lookup']['main'] kwargs.update(overrides) super(Template, self).__init__(*args, **kwargs) @@ -47,8 +48,8 @@ class Template(MakoTemplate): context_dictionary = {} # In various testing contexts, there might not be a current request context. - if middleware.requestcontext is not None: - for d in middleware.requestcontext: + if mitxmako.middleware.requestcontext is not None: + for d in mitxmako.middleware.requestcontext: context_dictionary.update(d) for d in context_instance: context_dictionary.update(d) diff --git a/common/djangoapps/student/management/commands/6002exportusers.py b/common/djangoapps/student/management/commands/6002exportusers.py index a92bb0a60c..a36d6e84c6 100644 --- a/common/djangoapps/student/management/commands/6002exportusers.py +++ b/common/djangoapps/student/management/commands/6002exportusers.py @@ -16,10 +16,6 @@ from django.contrib.auth.models import User from student.models import UserProfile -import mitxmako.middleware as middleware - -middleware.MakoMiddleware() - class Command(BaseCommand): help = \ diff --git a/common/djangoapps/student/management/commands/6002importusers.py b/common/djangoapps/student/management/commands/6002importusers.py index 1f98bd7522..93d86dfc05 100644 --- a/common/djangoapps/student/management/commands/6002importusers.py +++ b/common/djangoapps/student/management/commands/6002importusers.py @@ -12,10 +12,6 @@ from django.contrib.auth.models import User from student.models import UserProfile -import mitxmako.middleware as middleware - -middleware.MakoMiddleware() - def import_user(u): user_info = u['u'] diff --git a/common/djangoapps/student/management/commands/assigngroups.py b/common/djangoapps/student/management/commands/assigngroups.py index 3e36bf3129..cbd5cfad22 100644 --- a/common/djangoapps/student/management/commands/assigngroups.py +++ b/common/djangoapps/student/management/commands/assigngroups.py @@ -1,7 +1,6 @@ from django.core.management.base import BaseCommand from django.contrib.auth.models import User -import mitxmako.middleware as middleware from student.models import UserTestGroup import random @@ -11,8 +10,6 @@ import datetime import json from pytz import UTC -middleware.MakoMiddleware() - def group_from_value(groups, v): ''' Given group: (('a',0.3),('b',0.4),('c',0.3)) And random value diff --git a/common/djangoapps/student/management/commands/emaillist.py b/common/djangoapps/student/management/commands/emaillist.py index d3911927ac..e69b072db2 100644 --- a/common/djangoapps/student/management/commands/emaillist.py +++ b/common/djangoapps/student/management/commands/emaillist.py @@ -1,10 +1,6 @@ from django.core.management.base import BaseCommand from django.contrib.auth.models import User -import mitxmako.middleware as middleware - -middleware.MakoMiddleware() - class Command(BaseCommand): help = \ diff --git a/common/djangoapps/student/management/commands/massemail.py b/common/djangoapps/student/management/commands/massemail.py index 1bb65fd169..a1864f048e 100644 --- a/common/djangoapps/student/management/commands/massemail.py +++ b/common/djangoapps/student/management/commands/massemail.py @@ -1,9 +1,7 @@ from django.core.management.base import BaseCommand from django.contrib.auth.models import User -import mitxmako.middleware as middleware - -middleware.MakoMiddleware() +import mitxmako class Command(BaseCommand): @@ -17,8 +15,8 @@ body, and an _subject.txt for the subject. ''' #text = open(args[0]).read() #subject = open(args[1]).read() users = User.objects.all() - text = middleware.lookup['main'].get_template('email/' + args[0] + ".txt").render() - subject = middleware.lookup['main'].get_template('email/' + args[0] + "_subject.txt").render().strip() + text = mitxmako.lookup['main'].get_template('email/' + args[0] + ".txt").render() + subject = mitxmako.lookup['main'].get_template('email/' + args[0] + "_subject.txt").render().strip() for user in users: if user.is_active: user.email_user(subject, text) diff --git a/common/djangoapps/student/management/commands/massemailtxt.py b/common/djangoapps/student/management/commands/massemailtxt.py index ae25430a85..0228acf923 100644 --- a/common/djangoapps/student/management/commands/massemailtxt.py +++ b/common/djangoapps/student/management/commands/massemailtxt.py @@ -4,15 +4,13 @@ import time from django.core.management.base import BaseCommand from django.conf import settings -import mitxmako.middleware as middleware +import mitxmako from django.core.mail import send_mass_mail import sys import datetime -middleware.MakoMiddleware() - def chunks(l, n): """ Yield successive n-sized chunks from l. @@ -41,8 +39,8 @@ rate -- messages per second users = [u.strip() for u in open(user_file).readlines()] - message = middleware.lookup['main'].get_template('emails/' + message_base + "_body.txt").render() - subject = middleware.lookup['main'].get_template('emails/' + message_base + "_subject.txt").render().strip() + message = mitxmako.lookup['main'].get_template('emails/' + message_base + "_body.txt").render() + subject = mitxmako.lookup['main'].get_template('emails/' + message_base + "_subject.txt").render().strip() rate = int(ratestr) self.log_file = open(logfilename, "a+", buffering=0) diff --git a/common/djangoapps/student/management/commands/userinfo.py b/common/djangoapps/student/management/commands/userinfo.py index 5467db1733..8656fb9183 100644 --- a/common/djangoapps/student/management/commands/userinfo.py +++ b/common/djangoapps/student/management/commands/userinfo.py @@ -1,13 +1,10 @@ from django.core.management.base import BaseCommand from django.contrib.auth.models import User -import mitxmako.middleware as middleware import json from student.models import UserProfile -middleware.MakoMiddleware() - class Command(BaseCommand): help = \ diff --git a/common/lib/django_startup.py b/common/lib/django_startup.py index 1987d02dd2..1432ea073f 100644 --- a/common/lib/django_startup.py +++ b/common/lib/django_startup.py @@ -7,7 +7,7 @@ def autostartup(): """ for app in settings.INSTALLED_APPS: try: - mod = import_module('{}.startup') + mod = import_module(app + '.startup') if hasattr(mod, 'run'): mod.run() except ImportError: diff --git a/lms/djangoapps/certificates/queue.py b/lms/djangoapps/certificates/queue.py index 78e786e884..bc7bbc0e86 100644 --- a/lms/djangoapps/certificates/queue.py +++ b/lms/djangoapps/certificates/queue.py @@ -3,7 +3,6 @@ from certificates.models import certificate_status_for_student from certificates.models import CertificateStatuses as status from certificates.models import CertificateWhitelist -from mitxmako.middleware import MakoMiddleware from courseware import grades, courses from django.test.client import RequestFactory from capa.xqueue_interface import XQueueInterface @@ -52,14 +51,6 @@ class XQueueCertInterface(object): """ def __init__(self, request=None): - # MakoMiddleware Note: - # Line below has the side-effect of writing to a module level lookup - # table that will allow problems to render themselves. If this is not - # present, problems that a student hasn't seen will error when loading, - # causing the grading system to under-count the possible score and - # inflate their grade. This dependency is bad and was probably recently - # introduced. This is the bandage until we can trace the root cause. - m = MakoMiddleware() # Get basic auth (username/password) for # xqueue connection if it's in the settings diff --git a/lms/djangoapps/courseware/management/commands/check_course.py b/lms/djangoapps/courseware/management/commands/check_course.py index 58f8933cd8..a4f2bcc475 100644 --- a/lms/djangoapps/courseware/management/commands/check_course.py +++ b/lms/djangoapps/courseware/management/commands/check_course.py @@ -10,8 +10,6 @@ from django.contrib.auth.models import User import xmodule -import mitxmako.middleware as middleware -middleware.MakoMiddleware() from xmodule.modulestore.django import modulestore from courseware.model_data import ModelDataCache from courseware.module_render import get_module diff --git a/lms/djangoapps/courseware/tests/test_views.py b/lms/djangoapps/courseware/tests/test_views.py index c6c382c8e4..b4e835d2d9 100644 --- a/lms/djangoapps/courseware/tests/test_views.py +++ b/lms/djangoapps/courseware/tests/test_views.py @@ -12,6 +12,7 @@ from django.core.urlresolvers import reverse from student.models import CourseEnrollment from student.tests.factories import AdminFactory +from mitxmako.middleware import MakoMiddleware from xmodule.modulestore.django import modulestore @@ -135,6 +136,10 @@ class ViewsTestCase(TestCase): def verify_end_date(self, course_id, expected_end_text=None): request = self.request_factory.get("foo") request.user = self.user + + # TODO: Remove the dependency on MakoMiddleware (by making the views explicitly supply a RequestContext) + MakoMiddleware().process_request(request) + result = views.course_about(request, course_id) if expected_end_text is not None: self.assertContains(result, "Classes End") diff --git a/lms/djangoapps/instructor_task/tasks_helper.py b/lms/djangoapps/instructor_task/tasks_helper.py index c5a9b4d177..6ee72ec7b9 100644 --- a/lms/djangoapps/instructor_task/tasks_helper.py +++ b/lms/djangoapps/instructor_task/tasks_helper.py @@ -20,7 +20,6 @@ from dogapi import dog_stats_api from xmodule.modulestore.django import modulestore -import mitxmako.middleware as middleware from track.views import task_track from courseware.models import StudentModule @@ -35,26 +34,6 @@ TASK_LOG = get_task_logger(__name__) UNKNOWN_TASK_ID = 'unknown-task_id' -def initialize_mako(sender=None, conf=None, **kwargs): - """ - Get mako templates to work on celery worker server's worker thread. - - The initialization of Mako templating is usually done when Django is - initializing middleware packages as part of processing a server request. - When this is run on a celery worker server, no such initialization is - called. - - To make sure that we don't load this twice (just in case), we look for the - result: the defining of the lookup paths for templates. - """ - if 'main' not in middleware.lookup: - TASK_LOG.info("Initializing Mako middleware explicitly") - middleware.MakoMiddleware() - -# Actually make the call to define the hook: -worker_process_init.connect(initialize_mako) - - class UpdateProblemModuleStateError(Exception): """ Error signaling a fatal condition while updating problem modules. From 30cf0b57e6dcd71e64c8d3dcc52a75fa552baa27 Mon Sep 17 00:00:00 2001 From: Ned Batchelder Date: Wed, 28 Aug 2013 16:04:43 -0400 Subject: [PATCH 217/244] Fix the requirements in the sandbox instructions --- common/lib/capa/capa/safe_exec/README.rst | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/common/lib/capa/capa/safe_exec/README.rst b/common/lib/capa/capa/safe_exec/README.rst index c61100f709..00b81ca15f 100644 --- a/common/lib/capa/capa/safe_exec/README.rst +++ b/common/lib/capa/capa/safe_exec/README.rst @@ -16,11 +16,11 @@ __ https://github.com/edx/codejail/blob/master/README.rst 1. At the instruction to install packages into the sandboxed code, you'll - need to install both `pre-sandbox-requirements.txt` and - `sandbox-requirements.txt`:: + need to install the requirements from requirements/edx-sandbox:: - $ sudo pip install -r pre-sandbox-requirements.txt - $ sudo pip install -r sandbox-requirements.txt + $ pip install -r requirements/edx-sandbox/base.txt + $ pip install -r requirements/edx-sandbox/local.txt + $ pip install -r requirements/edx-sandbox/post.txt 2. At the instruction to create the AppArmor profile, you'll need a line in the profile for the sandbox packages. is the full path to From 23805ca52ffcb65949bff6861adffe84d4983126 Mon Sep 17 00:00:00 2001 From: Will Daly Date: Wed, 28 Aug 2013 10:59:53 -0400 Subject: [PATCH 218/244] Studio JS fixture files no longer need individual symlinks --- cms/static/coffee/fixtures | 1 + cms/static/coffee/fixtures/edit-chapter.underscore | 1 - cms/static/coffee/fixtures/edit-textbook.underscore | 1 - cms/static/coffee/fixtures/metadata-editor.underscore | 1 - cms/static/coffee/fixtures/metadata-list-entry.underscore | 1 - cms/static/coffee/fixtures/metadata-number-entry.underscore | 1 - cms/static/coffee/fixtures/metadata-option-entry.underscore | 1 - cms/static/coffee/fixtures/metadata-string-entry.underscore | 1 - cms/static/coffee/fixtures/no-textbooks.underscore | 1 - cms/static/coffee/fixtures/section-name-edit.underscore | 1 - cms/static/coffee/fixtures/show-textbook.underscore | 1 - cms/static/coffee/fixtures/system-feedback.underscore | 1 - cms/static/coffee/fixtures/upload-dialog.underscore | 1 - .../lib/xmodule/xmodule/js}/fixtures/tabs-edit.html | 0 .../lib/xmodule/xmodule/js}/spec/tabs/edit.coffee | 0 15 files changed, 1 insertion(+), 12 deletions(-) create mode 120000 cms/static/coffee/fixtures delete mode 120000 cms/static/coffee/fixtures/edit-chapter.underscore delete mode 120000 cms/static/coffee/fixtures/edit-textbook.underscore delete mode 120000 cms/static/coffee/fixtures/metadata-editor.underscore delete mode 120000 cms/static/coffee/fixtures/metadata-list-entry.underscore delete mode 120000 cms/static/coffee/fixtures/metadata-number-entry.underscore delete mode 120000 cms/static/coffee/fixtures/metadata-option-entry.underscore delete mode 120000 cms/static/coffee/fixtures/metadata-string-entry.underscore delete mode 120000 cms/static/coffee/fixtures/no-textbooks.underscore delete mode 120000 cms/static/coffee/fixtures/section-name-edit.underscore delete mode 120000 cms/static/coffee/fixtures/show-textbook.underscore delete mode 120000 cms/static/coffee/fixtures/system-feedback.underscore delete mode 120000 cms/static/coffee/fixtures/upload-dialog.underscore rename {cms/static/coffee => common/lib/xmodule/xmodule/js}/fixtures/tabs-edit.html (100%) rename {cms/static/coffee => common/lib/xmodule/xmodule/js}/spec/tabs/edit.coffee (100%) diff --git a/cms/static/coffee/fixtures b/cms/static/coffee/fixtures new file mode 120000 index 0000000000..800ce1d60d --- /dev/null +++ b/cms/static/coffee/fixtures @@ -0,0 +1 @@ +../../templates/js/ \ No newline at end of file diff --git a/cms/static/coffee/fixtures/edit-chapter.underscore b/cms/static/coffee/fixtures/edit-chapter.underscore deleted file mode 120000 index 9e057ab233..0000000000 --- a/cms/static/coffee/fixtures/edit-chapter.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/edit-chapter.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/edit-textbook.underscore b/cms/static/coffee/fixtures/edit-textbook.underscore deleted file mode 120000 index 5bb17a2d43..0000000000 --- a/cms/static/coffee/fixtures/edit-textbook.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/edit-textbook.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-editor.underscore b/cms/static/coffee/fixtures/metadata-editor.underscore deleted file mode 120000 index 9696774d0a..0000000000 --- a/cms/static/coffee/fixtures/metadata-editor.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-editor.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-list-entry.underscore b/cms/static/coffee/fixtures/metadata-list-entry.underscore deleted file mode 120000 index 78fa4e2000..0000000000 --- a/cms/static/coffee/fixtures/metadata-list-entry.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-list-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-number-entry.underscore b/cms/static/coffee/fixtures/metadata-number-entry.underscore deleted file mode 120000 index 99138aa9c1..0000000000 --- a/cms/static/coffee/fixtures/metadata-number-entry.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-number-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-option-entry.underscore b/cms/static/coffee/fixtures/metadata-option-entry.underscore deleted file mode 120000 index c6cd499801..0000000000 --- a/cms/static/coffee/fixtures/metadata-option-entry.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-option-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/metadata-string-entry.underscore b/cms/static/coffee/fixtures/metadata-string-entry.underscore deleted file mode 120000 index f713ab5387..0000000000 --- a/cms/static/coffee/fixtures/metadata-string-entry.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/metadata-string-entry.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/no-textbooks.underscore b/cms/static/coffee/fixtures/no-textbooks.underscore deleted file mode 120000 index d2e1c9a71a..0000000000 --- a/cms/static/coffee/fixtures/no-textbooks.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/no-textbooks.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/section-name-edit.underscore b/cms/static/coffee/fixtures/section-name-edit.underscore deleted file mode 120000 index 89284ccf90..0000000000 --- a/cms/static/coffee/fixtures/section-name-edit.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/section-name-edit.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/show-textbook.underscore b/cms/static/coffee/fixtures/show-textbook.underscore deleted file mode 120000 index d2ba37d689..0000000000 --- a/cms/static/coffee/fixtures/show-textbook.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/show-textbook.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/system-feedback.underscore b/cms/static/coffee/fixtures/system-feedback.underscore deleted file mode 120000 index 10893f87c4..0000000000 --- a/cms/static/coffee/fixtures/system-feedback.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/system-feedback.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/upload-dialog.underscore b/cms/static/coffee/fixtures/upload-dialog.underscore deleted file mode 120000 index e5637e9a53..0000000000 --- a/cms/static/coffee/fixtures/upload-dialog.underscore +++ /dev/null @@ -1 +0,0 @@ -../../../templates/js/upload-dialog.underscore \ No newline at end of file diff --git a/cms/static/coffee/fixtures/tabs-edit.html b/common/lib/xmodule/xmodule/js/fixtures/tabs-edit.html similarity index 100% rename from cms/static/coffee/fixtures/tabs-edit.html rename to common/lib/xmodule/xmodule/js/fixtures/tabs-edit.html diff --git a/cms/static/coffee/spec/tabs/edit.coffee b/common/lib/xmodule/xmodule/js/spec/tabs/edit.coffee similarity index 100% rename from cms/static/coffee/spec/tabs/edit.coffee rename to common/lib/xmodule/xmodule/js/spec/tabs/edit.coffee From 940a337dd3f635788d0dada3611e789f0983dd89 Mon Sep 17 00:00:00 2001 From: Vik Paruchuri Date: Thu, 29 Aug 2013 12:14:30 -0400 Subject: [PATCH 219/244] Properly display student progress --- .../combined_open_ended_modulev1.py | 52 +++++++++++++------ .../xmodule/tests/test_combined_open_ended.py | 24 +++++++++ 2 files changed, 61 insertions(+), 15 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 6ab8db8334..becb0fd170 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 @@ -823,6 +823,16 @@ class CombinedOpenEndedV1Module(): """ return (self.state == self.DONE or self.ready_to_reset) and self.is_scored + def get_weight(self): + """ + Return the weight of the problem. The old default weight was None, so set to 1 in that case. + Output - int weight + """ + weight = self.weight + if weight is None: + weight = 1 + return weight + def get_score(self): """ Score the student received on the problem, or None if there is no @@ -837,9 +847,7 @@ class CombinedOpenEndedV1Module(): score = None #The old default was None, so set to 1 if it is the old default weight - weight = self.weight - if weight is None: - weight = 1 + weight = self.get_weight() if self.is_scored: # Finds the maximum score of all student attempts and keeps it. score_mat = [] @@ -879,27 +887,41 @@ class CombinedOpenEndedV1Module(): return score_dict def max_score(self): - ''' Maximum score. Two notes: - - * This is generic; in abstract, a problem could be 3/5 points on one - randomization, and 5/7 on another - ''' + """ + Maximum score possible in this module. Returns the max score if finished, None if not. + """ max_score = None if self.check_if_done_and_scored(): max_score = self._max_score return max_score def get_progress(self): - ''' Return a progress.Progress object that represents how far the + """ + Generate a progress object. Progress objects represent how far the student has gone in this module. Must be implemented to get correct - progress tracking behavior in nesting modules like sequence and - vertical. + progress tracking behavior in nested modules like sequence and + vertical. This behavior is consistent with capa. - If this module has no notion of progress, return None. - ''' - progress_object = Progress(self.current_task_number, len(self.task_xml)) + If the module is unscored, return None (consistent with capa). + """ - return progress_object + d = self.get_score() + score = d['score'] + total = d['total'] + + if total > 0 and self.is_scored: + if self.weight is not None: + # scale score and total by weight/total: + score = score * self.get_weight() / total + total = self.get_weight() + + try: + return Progress(score, total) + except (TypeError, ValueError): + log.exception("Got bad progress") + return None + + return None def out_of_sync_error(self, data, msg=''): """ 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 26c248f71d..d97d62d7d3 100644 --- a/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py +++ b/common/lib/xmodule/xmodule/tests/test_combined_open_ended.py @@ -22,6 +22,7 @@ from xmodule.open_ended_grading_classes.grading_service_module import GradingSer from xmodule.combined_open_ended_module import CombinedOpenEndedModule from xmodule.modulestore import Location from xmodule.tests import get_test_system, test_util_open_ended +from xmodule.progress import Progress from xmodule.tests.test_util_open_ended import (MockQueryDict, DummyModulestore, TEST_STATE_SA_IN, MOCK_INSTANCE_STATE, TEST_STATE_SA, TEST_STATE_AI, TEST_STATE_AI2, TEST_STATE_AI2_INVALID, TEST_STATE_SINGLE, TEST_STATE_PE_SINGLE) @@ -480,6 +481,29 @@ class CombinedOpenEndedModuleTest(unittest.TestCase): max_score = self.combinedoe_container.max_score() self.assertEqual(max_score, None) + def test_container_get_progress(self): + """ + See if we can get the progress from the actual xmodule + """ + progress = self.combinedoe_container.max_score() + self.assertEqual(progress, None) + + def test_get_progress(self): + """ + Test if we can get the correct progress from the combined open ended class + """ + self.combinedoe.update_task_states() + self.combinedoe.state = "done" + self.combinedoe.is_scored = True + progress = self.combinedoe.get_progress() + self.assertIsInstance(progress, Progress) + + #progress._a is the score of the xmodule, which is 0 right now + self.assertEqual(progress._a, 0) + + #progress._b is the max_score (which is 1), divided by the weight (which is 1) + self.assertEqual(progress._b, 1) + def test_container_weight(self): """ Check the problem weight in the container From 907bf6e1b78e375bbc9353ed58f4cca3a4711467 Mon Sep 17 00:00:00 2001 From: Kevin Luo Date: Thu, 25 Jul 2013 12:22:30 -0700 Subject: [PATCH 220/244] Add bulk email feature for instructors, with optout option Adds a new Email link to the instructor dashboard for frontend interface to send email to course members. Adds a feature flag ENABLE_INSTRUCTOR_EMAIL to toggle this. Creates a new djangoapp bulk_email that handles this action by getting the recipient list and batching the emails to different celery tasks to do the actual sending. Requires lynx package to convert HTML email to plaintext. Handles SMTP errors by retrying or falling through to the next email. Adds the option to opt out of course specific emails in the user dashboard with an Email Settings link for each course. Uses severable configurable settings with defaults. DEFAULT_BULK_FROM_EMAIL specifies the from address for email. EMAILS_PER_TASK specifies the number of emails each celery task takes on. EMAIL_HOST, EMAIL_PORT, EMAIL_HOST_USER, EMAIL_HOST_PASSWORD, and EMAIL_USE_TLS for the SMTP email backend settings. Co-authored-by: Akshay Jagadeesh --- AUTHORS | 2 + CHANGELOG.rst | 3 + common/djangoapps/student/views.py | 31 ++++ lms/djangoapps/bulk_email/__init__.py | 0 lms/djangoapps/bulk_email/admin.py | 11 ++ .../bulk_email/migrations/0001_initial.py | 105 +++++++++++++ .../bulk_email/migrations/__init__.py | 0 lms/djangoapps/bulk_email/models.py | 43 ++++++ lms/djangoapps/bulk_email/tasks.py | 145 ++++++++++++++++++ lms/djangoapps/bulk_email/tests/__init__.py | 0 lms/djangoapps/bulk_email/tests/fake_smtp.py | 80 ++++++++++ .../bulk_email/tests/smtp_server_thread.py | 39 +++++ .../bulk_email/tests/test_course_optout.py | 61 ++++++++ lms/djangoapps/bulk_email/tests/test_email.py | 91 +++++++++++ .../bulk_email/tests/test_err_handling.py | 91 +++++++++++ lms/djangoapps/bulk_email/tests/tests.py | 51 ++++++ lms/djangoapps/instructor/views/legacy.py | 36 +++++ lms/envs/aws.py | 11 +- lms/envs/common.py | 38 +++-- lms/envs/dev.py | 1 + lms/static/sass/course.scss.mako | 1 + lms/static/sass/course/instructor/_email.scss | 10 ++ lms/static/sass/multicourse/_dashboard.scss | 5 + .../courseware/instructor_dashboard.html | 44 ++++++ lms/templates/dashboard.html | 50 ++++++ lms/templates/emails/email_footer.html | 6 + lms/templates/emails/email_footer.txt | 7 + lms/urls.py | 1 + .../system/mac_os_x/brew-formulas.txt | 1 + requirements/system/ubuntu/apt-packages.txt | 1 + 30 files changed, 948 insertions(+), 17 deletions(-) create mode 100644 lms/djangoapps/bulk_email/__init__.py create mode 100644 lms/djangoapps/bulk_email/admin.py create mode 100644 lms/djangoapps/bulk_email/migrations/0001_initial.py create mode 100644 lms/djangoapps/bulk_email/migrations/__init__.py create mode 100644 lms/djangoapps/bulk_email/models.py create mode 100644 lms/djangoapps/bulk_email/tasks.py create mode 100644 lms/djangoapps/bulk_email/tests/__init__.py create mode 100755 lms/djangoapps/bulk_email/tests/fake_smtp.py create mode 100644 lms/djangoapps/bulk_email/tests/smtp_server_thread.py create mode 100644 lms/djangoapps/bulk_email/tests/test_course_optout.py create mode 100644 lms/djangoapps/bulk_email/tests/test_email.py create mode 100644 lms/djangoapps/bulk_email/tests/test_err_handling.py create mode 100644 lms/djangoapps/bulk_email/tests/tests.py create mode 100644 lms/static/sass/course/instructor/_email.scss create mode 100644 lms/templates/emails/email_footer.html create mode 100644 lms/templates/emails/email_footer.txt diff --git a/AUTHORS b/AUTHORS index 4b57d723d2..0391bd55f9 100644 --- a/AUTHORS +++ b/AUTHORS @@ -84,3 +84,5 @@ Mukul Goyal Robert Marks Yarko Tymciurak Miles Steele +Kevin Luo +Akshay Jagadeesh diff --git a/CHANGELOG.rst b/CHANGELOG.rst index ab5e17357b..89f084b3f4 100644 --- a/CHANGELOG.rst +++ b/CHANGELOG.rst @@ -33,6 +33,9 @@ logic has been consolidated into the model -- you should use new class methods to `enroll()`, `unenroll()`, and to check `is_enrolled()`, instead of creating CourseEnrollment objects or querying them directly. +LMS: Added bulk email for course feature, with option to optout of individual +course emails. + Studio: Email will be sent to admin address when a user requests course creator privileges for Studio (edge only). diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index 4d59b5cc66..e6d328740e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -54,6 +54,10 @@ from courseware.access import has_access from external_auth.models import ExternalAuthMap +from bulk_email.models import Optout + +import track.views + from statsd import statsd from pytz import UTC @@ -267,6 +271,8 @@ def dashboard(request): log.error("User {0} enrolled in non-existent course {1}" .format(user.username, enrollment.course_id)) + course_optouts = Optout.objects.filter(email=user.email).values_list('course_id', flat=True) + message = "" if not user.is_active: message = render_to_string('registration/activate_account_notice.html', {'email': user.email}) @@ -294,6 +300,7 @@ def dashboard(request): pass context = {'courses': courses, + 'course_optouts': course_optouts, 'message': message, 'external_auth_map': external_auth_map, 'staff_access': staff_access, @@ -1272,3 +1279,27 @@ def accept_name_change(request): raise Http404 return accept_name_change_by_id(int(request.POST['id'])) + + +@ensure_csrf_cookie +def change_email_settings(request): + """Modify logged-in user's setting for receiving emails from a course.""" + if request.method != "POST": + return HttpResponseNotAllowed(["POST"]) + + user = request.user + if not user.is_authenticated(): + return HttpResponseForbidden() + + course_id = request.POST.get("course_id") + receive_emails = request.POST.get("receive_emails") + if receive_emails: + Optout.objects.filter(email=user.email, course_id=course_id).delete() + log.info(u"User {0} ({1}) opted to receive emails from course {2}".format(user.username, user.email, course_id)) + track.views.server_track(request, "change-email-settings", {"receive_emails": "yes", "course": course_id}, page='dashboard') + else: + Optout.objects.get_or_create(email=request.user.email, course_id=course_id) + log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id)) + track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') + + return HttpResponse(json.dumps({'success': True})) diff --git a/lms/djangoapps/bulk_email/__init__.py b/lms/djangoapps/bulk_email/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py new file mode 100644 index 0000000000..fe151741bc --- /dev/null +++ b/lms/djangoapps/bulk_email/admin.py @@ -0,0 +1,11 @@ +from django.contrib import admin + +from bulk_email.models import CourseEmail, Optout + +admin.site.register(Optout) + + +class CourseEmailAdmin(admin.ModelAdmin): + readonly_fields = ('sender',) + +admin.site.register(CourseEmail, CourseEmailAdmin) diff --git a/lms/djangoapps/bulk_email/migrations/0001_initial.py b/lms/djangoapps/bulk_email/migrations/0001_initial.py new file mode 100644 index 0000000000..02de3b909c --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0001_initial.py @@ -0,0 +1,105 @@ +# -*- 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 'CourseEmail' + db.create_table('bulk_email_courseemail', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('sender', self.gf('django.db.models.fields.related.ForeignKey')(default=1, to=orm['auth.User'], null=True, blank=True)), + ('hash', self.gf('django.db.models.fields.CharField')(max_length=128, db_index=True)), + ('subject', self.gf('django.db.models.fields.CharField')(max_length=128, blank=True)), + ('html_message', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('created', self.gf('django.db.models.fields.DateTimeField')(auto_now_add=True, blank=True)), + ('modified', self.gf('django.db.models.fields.DateTimeField')(auto_now=True, blank=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('to', self.gf('django.db.models.fields.CharField')(default='myself', max_length=64)), + )) + db.send_create_signal('bulk_email', ['CourseEmail']) + + # Adding model 'Optout' + db.create_table('bulk_email_optout', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('email', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + ('course_id', self.gf('django.db.models.fields.CharField')(max_length=255, db_index=True)), + )) + db.send_create_signal('bulk_email', ['Optout']) + + # Adding unique constraint on 'Optout', fields ['email', 'course_id'] + db.create_unique('bulk_email_optout', ['email', 'course_id']) + + + def backwards(self, orm): + # Removing unique constraint on 'Optout', fields ['email', 'course_id'] + db.delete_unique('bulk_email_optout', ['email', 'course_id']) + + # Deleting model 'CourseEmail' + db.delete_table('bulk_email_courseemail') + + # Deleting model 'Optout' + db.delete_table('bulk_email_optout') + + + 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'}) + }, + 'bulk_email.courseemail': { + 'Meta': {'object_name': 'CourseEmail'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'hash': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'to': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('email', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['bulk_email'] \ No newline at end of file diff --git a/lms/djangoapps/bulk_email/migrations/__init__.py b/lms/djangoapps/bulk_email/migrations/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py new file mode 100644 index 0000000000..2804521841 --- /dev/null +++ b/lms/djangoapps/bulk_email/models.py @@ -0,0 +1,43 @@ +from django.db import models +from django.contrib.auth.models import User + + +class Email(models.Model): + """ + Abstract base class for common information for an email. + """ + sender = models.ForeignKey(User, default=1, blank=True, null=True) + hash = models.CharField(max_length=128, db_index=True) + subject = models.CharField(max_length=128, blank=True) + html_message = models.TextField(null=True, blank=True) + created = models.DateTimeField(auto_now_add=True) + modified = models.DateTimeField(auto_now=True) + + class Meta: + abstract = True + + +class CourseEmail(Email, models.Model): + """ + Stores information for an email to a course. + """ + TO_OPTIONS = (('myself', 'Myself'), + ('staff', 'Staff and instructors'), + ('all', 'All') + ) + course_id = models.CharField(max_length=255, db_index=True) + to = models.CharField(max_length=64, choices=TO_OPTIONS, default='myself') + + def __unicode__(self): + return self.subject + + +class Optout(models.Model): + """ + Stores emails that have opted out of receiving emails from a course. + """ + email = models.CharField(max_length=255, db_index=True) + course_id = models.CharField(max_length=255, db_index=True) + + class Meta: + unique_together = ('email', 'course_id') diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py new file mode 100644 index 0000000000..9047f4e569 --- /dev/null +++ b/lms/djangoapps/bulk_email/tasks.py @@ -0,0 +1,145 @@ +import logging +import math +import re +import time + +from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError +from subprocess import Popen, PIPE + +from django.conf import settings +from django.contrib.auth.models import User, Group +from django.core.mail import EmailMultiAlternatives, get_connection +from django.http import Http404 +from celery import task, current_task + +from bulk_email.models import CourseEmail, Optout +from courseware.access import _course_staff_group_name, _course_instructor_group_name +from courseware.courses import get_course_by_id +from mitxmako.shortcuts import render_to_string + +log = logging.getLogger(__name__) + + +@task() +def delegate_email_batches(hash_for_msg, recipient, course_id, course_url, user_id): + ''' + Delegates emails by querying for the list of recipients who should + get the mail, chopping up into batches of settings.EMAILS_PER_TASK size, + and queueing up worker jobs. + + Recipient is {'students', 'staff', or 'all'} + + Returns the number of batches (workers) kicked off. + ''' + try: + course = get_course_by_id(course_id) + except Http404 as exc: + log.error("get_course_by_id failed: " + exc.args[0]) + raise Exception("get_course_by_id failed: " + exc.args[0]) + + if recipient == "myself": + recipient_qset = User.objects.filter(id=user_id).values('profile__name', 'email') + else: + staff_grpname = _course_staff_group_name(course.location) + staff_group, _ = Group.objects.get_or_create(name=staff_grpname) + staff_qset = staff_group.user_set.values('profile__name', 'email') + instructor_grpname = _course_instructor_group_name(course.location) + instructor_group, _ = Group.objects.get_or_create(name=instructor_grpname) + instructor_qset = instructor_group.user_set.values('profile__name', 'email') + recipient_qset = staff_qset | instructor_qset + + if recipient == "all": + #Execute two queries per performance considerations for MySQL + #https://docs.djangoproject.com/en/1.2/ref/models/querysets/#in + course_optouts = Optout.objects.filter(course_id=course_id).values_list('email', flat=True) + enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id).exclude(email__in=list(course_optouts)).values('profile__name', 'email') + recipient_qset = recipient_qset | enrollment_qset + recipient_qset = recipient_qset.distinct() + + recipient_list = list(recipient_qset) + total_num_emails = recipient_qset.count() + num_workers = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_TASK))) + chunk = int(math.ceil(float(total_num_emails) / float(num_workers))) + + for i in range(num_workers): + to_list = recipient_list[i * chunk:i * chunk + chunk] + course_email.delay(hash_for_msg, to_list, course.display_name, course_url, False) + return num_workers + + +@task(default_retry_delay=15, max_retries=5) +def course_email(hash_for_msg, to_list, course_title, course_url, throttle=False): + """ + Takes a subject and an html formatted email and sends it from + sender to all addresses in the to_list, with each recipient + being the only "to". Emails are sent multipart, in both plain + text and html. + """ + try: + msg = CourseEmail.objects.get(hash=hash_for_msg) + except CourseEmail.DoesNotExist as exc: + log.exception(exc.args[0]) + raise exc + + subject = "[" + course_title + "] " + msg.subject + + process = Popen(['lynx', '-stdin', '-display_charset=UTF-8', '-assume_charset=UTF-8', '-dump'], stdin=PIPE, stdout=PIPE) + (plaintext, err_from_stderr) = process.communicate(input=msg.html_message.encode('utf-8')) # use lynx to get plaintext + + course_title_no_quotes = re.sub(r'"', '', course_title) + from_addr = '"%s" Course Staff <%s>' % (course_title_no_quotes, settings.DEFAULT_BULK_FROM_EMAIL) + + if err_from_stderr: + log.info(err_from_stderr) + + try: + connection = get_connection() + connection.open() + num_sent = 0 + num_error = 0 + + while to_list: + (name, email) = to_list[-1].values() + html_footer = render_to_string('emails/email_footer.html', + {'name': name, + 'email': email, + 'course_title': course_title, + 'course_url': course_url}) + plain_footer = render_to_string('emails/email_footer.txt', + {'name': name, + 'email': email, + 'course_title': course_title, + 'course_url': course_url}) + + email_msg = EmailMultiAlternatives(subject, plaintext + plain_footer.encode('utf-8'), from_addr, [email], connection=connection) + email_msg.attach_alternative(msg.html_message + html_footer.encode('utf-8'), 'text/html') + + if throttle or current_task.request.retries > 0: # throttle if we tried a few times and got the rate limiter + time.sleep(0.2) + + try: + connection.send_messages([email_msg]) + log.info('Email with hash ' + hash_for_msg + ' sent to ' + email) + num_sent += 1 + except SMTPDataError as exc: + #According to SMTP spec, we'll retry error codes in the 4xx range. 5xx range indicates hard failure + if exc.smtp_code >= 400 and exc.smtp_code < 500: + raise exc # this will cause the outer handler to catch the exception and retry the entire task + else: + #this will fall through and not retry the message, since it will be popped + log.warn('Email with hash ' + hash_for_msg + ' not delivered to ' + email + ' due to error: ' + exc.smtp_error) + num_error += 1 + + to_list.pop() + + connection.close() + return course_email_result(num_sent, num_error) + + except (SMTPDataError, SMTPConnectError, SMTPServerDisconnected) as exc: + #error caught here cause the email to be retried. The entire task is actually retried without popping the list + raise course_email.retry(arg=[hash_for_msg, to_list, course_title, course_url, current_task.request.retries > 0], exc=exc, countdown=(2 ** current_task.request.retries) * 15) + + +#This string format code is wrapped in this function to allow mocking for a unit test +def course_email_result(num_sent, num_error): + return "Sent %d, Fail %d" % (num_sent, num_error) diff --git a/lms/djangoapps/bulk_email/tests/__init__.py b/lms/djangoapps/bulk_email/tests/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/lms/djangoapps/bulk_email/tests/fake_smtp.py b/lms/djangoapps/bulk_email/tests/fake_smtp.py new file mode 100755 index 0000000000..cf5872c333 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/fake_smtp.py @@ -0,0 +1,80 @@ +""" +Fake SMTP Server used for testing error handling for sending email. +We could have mocked smptlib to raise connection errors, but this simulates +connection errors from an SMTP server. +""" +import smtpd +import socket +import asyncore +import asynchat +import errno + + +class FakeSMTPChannel(smtpd.SMTPChannel): + """ + A fake SMTPChannel for sending fake error response through socket. + This causes smptlib to raise an SMTPConnectError. + + Adapted from http://hg.python.org/cpython/file/2.7/Lib/smtpd.py + """ + # Disable pylint warnings that arise from subclassing SMTPChannel + # and calling init -- overriding SMTPChannel's init to return error + # message but keeping the rest of the class. + # pylint: disable=W0231, W0233 + def __init__(self, server, conn, addr): + asynchat.async_chat.__init__(self, conn) + self.__server = server + self.__conn = conn + self.__addr = addr + self.__line = [] + self.__state = self.COMMAND + self.__greeting = 0 + self.__mailfrom = None + self.__rcpttos = [] + self.__data = '' + self.__fqdn = socket.getfqdn() + try: + self.__peer = conn.getpeername() + except socket.error, err: + # a race condition may occur if the other end is closing + # before we can get the peername + self.close() + if err[0] != errno.ENOTCONN: + raise + return + self.push('421 SMTP Server error: too many concurrent sessions, please try again later.') + self.set_terminator('\r\n') + + +class FakeSMTPServer(smtpd.SMTPServer): + """A fake SMTP server for generating different smptlib exceptions.""" + def __init__(self, *args, **kwargs): + smtpd.SMTPServer.__init__(self, *args, **kwargs) + self.errtype = None + self.reply = None + + def set_errtype(self, errtype, reply=''): + self.errtype = errtype + self.reply = reply + + def handle_accept(self): + if self.errtype == "DISCONN": + self.accept() + elif self.errtype == "CONN": + pair = self.accept() + if pair is not None: + conn, addr = pair + _channel = FakeSMTPChannel(self, conn, addr) + else: + smtpd.SMTPServer.handle_accept(self) + + def process_message(self, *_args, **_kwargs): + if self.errtype == "DATA": + #after failing on the first email, succeed on rest + self.errtype = None + return self.reply + else: + return None + + def serve_forever(self): + asyncore.loop() diff --git a/lms/djangoapps/bulk_email/tests/smtp_server_thread.py b/lms/djangoapps/bulk_email/tests/smtp_server_thread.py new file mode 100644 index 0000000000..77b51509f1 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/smtp_server_thread.py @@ -0,0 +1,39 @@ +import threading +from bulk_email.tests.fake_smtp import FakeSMTPServer + + +class FakeSMTPServerThread(threading.Thread): + """ + Thread for running a fake SMTP server for testing email + """ + def __init__(self, host, port): + self.host = host + self.port = port + self.is_ready = threading.Event() + self.error = None + self.server = None + super(FakeSMTPServerThread, self).__init__() + + def start(self): + self.daemon = True + super(FakeSMTPServerThread, self).start() + self.is_ready.wait() + if self.error: + raise self.error + + def stop(self): + if hasattr(self, 'server'): + self.server.close() + self.join() + + def run(self): + """ + Sets up the test smtp server and handle requests + """ + try: + self.server = FakeSMTPServer((self.host, self.port), None) + self.is_ready.set() + self.server.serve_forever() + except Exception, e: + self.error = e + self.is_ready.set() diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py new file mode 100644 index 0000000000..e91b11c314 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -0,0 +1,61 @@ +""" +Unit tests for student optouts from course email +""" +import json + +from django.core import mail +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestOptoutCourseEmails(ModuleStoreTestCase): + def setUp(self): + self.course = CourseFactory.create() + self.instructor = AdminFactory.create() + self.student = UserFactory.create() + CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + + self.client.login(username=self.student.username, password="test") + + def test_optout_course(self): + """ + Make sure student does not receive course email after opting out. + """ + url = reverse('change_email_settings') + response = self.client.post(url, {'course_id': self.course.id}) + self.assertEquals(json.loads(response.content), {'success': True}) + + self.client.logout() + self.client.login(username=self.instructor.username, password="test") + + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) + self.assertContains(response, "Your email was successfully queued for sending.") + + #assert that self.student.email not in mail.to, outbox should be empty + self.assertEqual(len(mail.outbox), 0) + + def test_optin_course(self): + """ + Make sure student receives course email after opting in. + """ + url = reverse('change_email_settings') + response = self.client.post(url, {'course_id': self.course.id, 'receive_emails': 'on'}) + self.assertEquals(json.loads(response.content), {'success': True}) + + self.client.logout() + self.client.login(username=self.instructor.username, password="test") + + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) + self.assertContains(response, "Your email was successfully queued for sending.") + + #assert that self.student.email in mail.to + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox[0].to), 1) + self.assertEquals(mail.outbox[0].to[0], self.student.email) diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py new file mode 100644 index 0000000000..7aebd679f3 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -0,0 +1,91 @@ +""" +Unit tests for sending course email +""" + +from django.test.utils import override_settings +from django.core.urlresolvers import reverse +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory +from django.core import mail +from bulk_email.tasks import delegate_email_batches, course_email +from bulk_email.models import CourseEmail + +STAFF_COUNT = 3 +STUDENT_COUNT = 10 + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestEmail(ModuleStoreTestCase): + def setUp(self): + self.course = CourseFactory.create() + self.instructor = UserFactory.create(username="instructor", email="robot+instructor@edx.org") + #Create instructor group for course + instructor_group = GroupFactory.create(name="instructor_MITx/999/Robot_Super_Course") + instructor_group.user_set.add(self.instructor) + + #create staff + self.staff = [UserFactory() for _ in xrange(STAFF_COUNT)] + staff_group = GroupFactory() + for staff in self.staff: + staff_group.user_set.add(staff) + + #create students + self.students = [UserFactory() for _ in xrange(STUDENT_COUNT)] + for student in self.students: + CourseEnrollmentFactory.create(user=student, course_id=self.course.id) + + self.client.login(username=self.instructor.username, password="test") + + def test_send_to_self(self): + """ + Make sure email send to myself goes to myself. + """ + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + response = self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) + + self.assertContains(response, "Your email was successfully queued for sending.") + + self.assertEqual(len(mail.outbox), 1) + self.assertEqual(len(mail.outbox[0].to), 1) + self.assertEquals(mail.outbox[0].to[0], self.instructor.email) + self.assertEquals(mail.outbox[0].subject, '[' + self.course.display_name + ']' + ' test subject for myself') + + def test_send_to_staff(self): + """ + Make sure email send to staff and instructors goes there. + """ + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + response = self.client.post(url, {'action': 'Send email', 'to': 'staff', 'subject': 'test subject for staff', 'message': 'test message for subject'}) + + self.assertContains(response, "Your email was successfully queued for sending.") + + self.assertEquals(len(mail.outbox), 1 + len(self.staff)) + self.assertItemsEqual([e.to[0] for e in mail.outbox], [self.instructor.email] + [s.email for s in self.staff]) + + def test_send_to_all(self): + """ + Make sure email send to all goes there. + """ + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + response = self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) + + self.assertContains(response, "Your email was successfully queued for sending.") + + self.assertEquals(len(mail.outbox), 1 + len(self.staff) + len(self.students)) + self.assertItemsEqual([e.to[0] for e in mail.outbox], [self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students]) + + def test_get_course_exc(self): + """ + Make sure delegate_email_batches handles Http404 exception from get_course_by_id. + """ + with self.assertRaises(Exception): + delegate_email_batches("_", "_", "blah/blah/blah", "_", "_") + + def test_no_course_email_obj(self): + """ + Make sure course_email handles CourseEmail.DoesNotExist exception. + """ + with self.assertRaises(CourseEmail.DoesNotExist): + course_email("dummy hash", [], "_", "_", False) diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py new file mode 100644 index 0000000000..faf6d38a87 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -0,0 +1,91 @@ +""" +Unit tests for handling email sending errors +""" + +from django.test.utils import override_settings +from django.conf import settings +from django.core.urlresolvers import reverse +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory +from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory +from bulk_email.tests.smtp_server_thread import FakeSMTPServerThread + +from mock import patch +from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError + +TEST_SMTP_PORT = 1025 + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE, EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', EMAIL_HOST='localhost', EMAIL_PORT=TEST_SMTP_PORT) +class TestEmailErrors(ModuleStoreTestCase): + def setUp(self): + self.course = CourseFactory.create() + instructor = AdminFactory.create() + self.client.login(username=instructor.username, password="test") + + self.smtp_server_thread = FakeSMTPServerThread('localhost', TEST_SMTP_PORT) + self.smtp_server_thread.start() + + def tearDown(self): + self.smtp_server_thread.stop() + + @patch('bulk_email.tasks.course_email.retry') + def test_data_err_retry(self, retry): + """ + Test that celery handles transient SMTPDataErrors by retrying. + """ + self.smtp_server_thread.server.set_errtype("DATA", "454 Throttling failure: Daily message quota exceeded.") + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) + self.assertTrue(retry.called) + (_, kwargs) = retry.call_args + exc = kwargs['exc'] + self.assertTrue(type(exc) == SMTPDataError) + + @patch('bulk_email.tasks.course_email_result') + @patch('bulk_email.tasks.course_email.retry') + def test_data_err_fail(self, retry, result): + """ + Test that celery handles permanent SMTPDataErrors by failing and not retrying. + """ + self.smtp_server_thread.server.set_errtype("DATA", "554 Message rejected: Email address is not verified.") + students = [UserFactory() for _ in xrange(settings.EMAILS_PER_TASK)] + for student in students: + CourseEnrollmentFactory.create(user=student, course_id=self.course.id) + + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + self.client.post(url, {'action': 'Send email', 'to': 'all', 'subject': 'test subject for all', 'message': 'test message for all'}) + self.assertFalse(retry.called) + + #test that after the failed email, the rest send successfully + ((sent, fail), _) = result.call_args + self.assertEquals(fail, 1) + self.assertEquals(sent, settings.EMAILS_PER_TASK - 1) + + @patch('bulk_email.tasks.course_email.retry') + def test_disconn_err_retry(self, retry): + """ + Test that celery handles SMTPServerDisconnected by retrying. + """ + self.smtp_server_thread.server.set_errtype("DISCONN", "Server disconnected, please try again later.") + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) + self.assertTrue(retry.called) + (_, kwargs) = retry.call_args + exc = kwargs['exc'] + self.assertTrue(type(exc) == SMTPServerDisconnected) + + @patch('bulk_email.tasks.course_email.retry') + def test_conn_err_retry(self, retry): + """ + Test that celery handles SMTPConnectError by retrying. + """ + #SMTP reply is already specified in fake SMTP Channel created + self.smtp_server_thread.server.set_errtype("CONN") + url = reverse('instructor_dashboard', kwargs={'course_id': self.course.id}) + self.client.post(url, {'action': 'Send email', 'to': 'myself', 'subject': 'test subject for myself', 'message': 'test message for myself'}) + self.assertTrue(retry.called) + (_, kwargs) = retry.call_args + exc = kwargs['exc'] + self.assertTrue(type(exc) == SMTPConnectError) diff --git a/lms/djangoapps/bulk_email/tests/tests.py b/lms/djangoapps/bulk_email/tests/tests.py new file mode 100644 index 0000000000..71404a1c35 --- /dev/null +++ b/lms/djangoapps/bulk_email/tests/tests.py @@ -0,0 +1,51 @@ +""" +Unit tests for email feature flag in instructor dashboard +""" + +from django.test.utils import override_settings +from django.conf import settings +from django.core.urlresolvers import reverse + +from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE +from student.tests.factories import AdminFactory +from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase +from xmodule.modulestore.tests.factories import CourseFactory + +from mock import patch + + +@override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) +class TestInstructorDashboardEmailView(ModuleStoreTestCase): + """ + Check for email view displayed with flag + """ + def setUp(self): + self.course = CourseFactory.create() + + # Create instructor account + instructor = AdminFactory.create() + self.client.login(username=instructor.username, password="test") + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': True}) + def test_email_flag_true(self): + response = self.client.get(reverse('instructor_dashboard', + kwargs={'course_id': self.course.id})) + email_link = 'Email' + self.assertTrue(email_link in response.content) + + session = self.client.session + session['idash_mode'] = 'Email' + session.save() + response = self.client.get(reverse('instructor_dashboard', + kwargs={'course_id': self.course.id})) + selected_email_link = 'Email' + self.assertTrue(selected_email_link in response.content) + send_to_label = '' + self.assertTrue(send_to_label in response.content) + + @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False}) + def test_email_flag_false(self): + response = self.client.get(reverse('instructor_dashboard', + kwargs={'course_id': self.course.id})) + email_link = 'Email' + self.assertFalse(email_link in response.content) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 27ab17551c..da67444d4a 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -51,6 +51,11 @@ import track.views from mitxmako.shortcuts import render_to_string +from bulk_email.models import CourseEmail +import datetime +from hashlib import md5 +from bulk_email import tasks + log = logging.getLogger(__name__) # internal commands for managing forum roles: @@ -76,6 +81,9 @@ def instructor_dashboard(request, course_id): forum_admin_access = has_forum_access(request.user, course_id, FORUM_ROLE_ADMINISTRATOR) msg = '' + to = None + subject = None + html_message = None problems = [] plots = [] datatable = {} @@ -687,6 +695,31 @@ def instructor_dashboard(request, course_id): ret = _do_enroll_students(course, course_id, students, overload=overload) datatable = ret['datatable'] + #---------------------------------------- + # email + + elif action == 'Send email': + to = request.POST.get("to") + subject = request.POST.get("subject") + html_message = request.POST.get("message") + + email = CourseEmail(course_id=course_id, + sender=request.user, + to=to, + subject=subject, + html_message=html_message, + hash=md5((html_message + subject + datetime.datetime.isoformat(datetime.datetime.now())).encode('utf-8')).hexdigest()) + email.save() + + course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id})) + tasks.delegate_email_batches.delay(email.hash, email.to, course_id, course_url, request.user.id) + + if to == "all": + msg = "Your email was successfully queued for sending. Please note that for large public classe\ +s (~10k), it may take 1-2 hours to send all emails." + else: + msg = "Your email was successfully queued for sending." + #---------------------------------------- # psychometrics @@ -768,6 +801,9 @@ def instructor_dashboard(request, course_id): 'course_stats': course_stats, 'msg': msg, 'modeflag': {idash_mode: 'selectedmode'}, + 'to': to, # email + 'subject': subject, # email + 'message': html_message, # email 'problems': problems, # psychometrics 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), diff --git a/lms/envs/aws.py b/lms/envs/aws.py index f6eb45ec51..0d44674741 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -102,6 +102,11 @@ with open(ENV_ROOT / CONFIG_PREFIX + "env.json") as env_file: PLATFORM_NAME = ENV_TOKENS.get('PLATFORM_NAME', PLATFORM_NAME) EMAIL_BACKEND = ENV_TOKENS.get('EMAIL_BACKEND', EMAIL_BACKEND) EMAIL_FILE_PATH = ENV_TOKENS.get('EMAIL_FILE_PATH', None) +EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is localhost +EMAIL_PORT = ENV_TOKENS.get('EMAIL_PORT', 25) # django default is 25 +EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is False +EMAILS_PER_TASK = ENV_TOKENS.get('EMAILS_PER_TASK', 10) + SITE_NAME = ENV_TOKENS['SITE_NAME'] SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE) SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') @@ -122,6 +127,7 @@ CACHES = ENV_TOKENS['CACHES'] #Email overrides DEFAULT_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_FROM_EMAIL', DEFAULT_FROM_EMAIL) DEFAULT_FEEDBACK_EMAIL = ENV_TOKENS.get('DEFAULT_FEEDBACK_EMAIL', DEFAULT_FEEDBACK_EMAIL) +DEFAULT_BULK_FROM_EMAIL = ENV_TOKENS.get('DEFAULT_BULK_FROM_EMAIL', DEFAULT_BULK_FROM_EMAIL) ADMINS = ENV_TOKENS.get('ADMINS', ADMINS) SERVER_EMAIL = ENV_TOKENS.get('SERVER_EMAIL', SERVER_EMAIL) TECH_SUPPORT_EMAIL = ENV_TOKENS.get('TECH_SUPPORT_EMAIL', TECH_SUPPORT_EMAIL) @@ -197,7 +203,7 @@ SECRET_KEY = AUTH_TOKENS['SECRET_KEY'] AWS_ACCESS_KEY_ID = AUTH_TOKENS["AWS_ACCESS_KEY_ID"] AWS_SECRET_ACCESS_KEY = AUTH_TOKENS["AWS_SECRET_ACCESS_KEY"] -AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME','edxuploads') +AWS_STORAGE_BUCKET_NAME = AUTH_TOKENS.get('AWS_STORAGE_BUCKET_NAME', 'edxuploads') DATABASES = AUTH_TOKENS['DATABASES'] @@ -211,6 +217,9 @@ CONTENTSTORE = AUTH_TOKENS.get('CONTENTSTORE', CONTENTSTORE) OPEN_ENDED_GRADING_INTERFACE = AUTH_TOKENS.get('OPEN_ENDED_GRADING_INTERFACE', OPEN_ENDED_GRADING_INTERFACE) +EMAIL_HOST_USER = AUTH_TOKENS.get('EMAIL_HOST_USER', '') # django default is '' +EMAIL_HOST_PASSWORD = AUTH_TOKENS.get('EMAIL_HOST_PASSWORD', '') # django default is '' + PEARSON_TEST_USER = "pearsontest" PEARSON_TEST_PASSWORD = AUTH_TOKENS.get("PEARSON_TEST_PASSWORD") diff --git a/lms/envs/common.py b/lms/envs/common.py index 0e659a1ca1..bb19a70993 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -103,6 +103,8 @@ MITX_FEATURES = { # analytics experiments 'ENABLE_INSTRUCTOR_ANALYTICS': False, + 'ENABLE_INSTRUCTOR_EMAIL': False, + # enable analytics server. # WARNING: THIS SHOULD ALWAYS BE SET TO FALSE UNDER NORMAL # LMS OPERATION. See analytics.py for details about what @@ -289,11 +291,11 @@ WIKI_ENABLED = False COURSE_DEFAULT = '6.002x_Fall_2012' COURSE_SETTINGS = {'6.002x_Fall_2012': {'number': '6.002x', - 'title': 'Circuits and Electronics', - 'xmlpath': '6002x/', - 'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012', - } - } + 'title': 'Circuits and Electronics', + 'xmlpath': '6002x/', + 'location': 'i4x://edx/6002xs12/course/6.002x_Fall_2012', + } + } # IP addresses that are allowed to reload the course, etc. # TODO (vshnayder): Will probably need to change as we get real access control in. @@ -361,6 +363,8 @@ IGNORABLE_404_ENDS = ('favicon.ico') # Email EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'registration@edx.org' +DEFAULT_BULK_FROM_EMAIL = 'course-updates@edx.org' +EMAILS_PER_TASK = 10 DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org' SERVER_EMAIL = 'devops@edx.org' TECH_SUPPORT_EMAIL = 'technical@edx.org' @@ -538,17 +542,17 @@ courseware_js = ( # 'js/vendor/RequireJS.js' - Require JS wrapper. # See https://edx-wiki.atlassian.net/wiki/display/LMS/Integration+of+Require+JS+into+the+system main_vendor_js = [ - 'js/vendor/RequireJS.js', - 'js/vendor/json2.js', - 'js/vendor/jquery.min.js', - 'js/vendor/jquery-ui.min.js', - 'js/vendor/jquery.cookie.js', - 'js/vendor/jquery.qtip.min.js', - 'js/vendor/swfobject/swfobject.js', - 'js/vendor/jquery.ba-bbq.min.js', - 'js/vendor/annotator.min.js', - 'js/vendor/annotator.store.min.js', - 'js/vendor/annotator.tags.min.js' + 'js/vendor/RequireJS.js', + 'js/vendor/json2.js', + 'js/vendor/jquery.min.js', + 'js/vendor/jquery-ui.min.js', + 'js/vendor/jquery.cookie.js', + 'js/vendor/jquery.qtip.min.js', + 'js/vendor/swfobject/swfobject.js', + 'js/vendor/jquery.ba-bbq.min.js', + 'js/vendor/annotator.min.js', + 'js/vendor/annotator.store.min.js', + 'js/vendor/annotator.tags.min.js' ] discussion_js = sorted(rooted_glob(PROJECT_ROOT / 'static', 'coffee/src/discussion/**/*.js')) @@ -756,6 +760,7 @@ INSTALLED_APPS = ( 'psychometrics', 'licenses', 'course_groups', + 'bulk_email', # External auth (OpenID, shib) 'external_auth', @@ -813,6 +818,7 @@ MKTG_URL_LINK_MAP = { 'PRIVACY': 'privacy_edx', } + ############################### THEME ################################ def enable_theme(theme_name): """ diff --git a/lms/envs/dev.py b/lms/envs/dev.py index 554c72dd89..4bd0247694 100644 --- a/lms/envs/dev.py +++ b/lms/envs/dev.py @@ -28,6 +28,7 @@ MITX_FEATURES['ENABLE_MANUAL_GIT_RELOAD'] = True MITX_FEATURES['ENABLE_PSYCHOMETRICS'] = False # real-time psychometrics (eg item response theory analysis in instructor dashboard) MITX_FEATURES['ENABLE_INSTRUCTOR_ANALYTICS'] = True MITX_FEATURES['ENABLE_SERVICE_STATUS'] = True +MITX_FEATURES['ENABLE_INSTRUCTOR_EMAIL'] = True MITX_FEATURES['ENABLE_HINTER_INSTRUCTOR_VIEW'] = True MITX_FEATURES['ENABLE_INSTRUCTOR_BETA_DASHBOARD'] = True MITX_FEATURES['ENABLE_SHOPPING_CART'] = True diff --git a/lms/static/sass/course.scss.mako b/lms/static/sass/course.scss.mako index 7c04968f86..93b0b681d4 100644 --- a/lms/static/sass/course.scss.mako +++ b/lms/static/sass/course.scss.mako @@ -65,6 +65,7 @@ // instructor @import "course/instructor/instructor"; @import "course/instructor/instructor_2"; +@import "course/instructor/email"; // discussion @import "course/discussion/form-wmd-toolbar"; diff --git a/lms/static/sass/course/instructor/_email.scss b/lms/static/sass/course/instructor/_email.scss new file mode 100644 index 0000000000..ed33e985ab --- /dev/null +++ b/lms/static/sass/course/instructor/_email.scss @@ -0,0 +1,10 @@ +.submit-email-action { + margin-top: 10px; + line-height: 1.3; + + ul { + margin-top: 0; + margin-bottom: 10px; + } +} + diff --git a/lms/static/sass/multicourse/_dashboard.scss b/lms/static/sass/multicourse/_dashboard.scss index cd58d4d8e4..6b2e85b1c5 100644 --- a/lms/static/sass/multicourse/_dashboard.scss +++ b/lms/static/sass/multicourse/_dashboard.scss @@ -570,5 +570,10 @@ color: #333; } } + + a.email-settings { + @extend a.unenroll; + margin-right: 10px; + } } } diff --git a/lms/templates/courseware/instructor_dashboard.html b/lms/templates/courseware/instructor_dashboard.html index f045dfeb3b..71fc0646e6 100644 --- a/lms/templates/courseware/instructor_dashboard.html +++ b/lms/templates/courseware/instructor_dashboard.html @@ -118,6 +118,9 @@ function goto( mode) ${_("Enrollment")} | ${_("DataDump")} | ${_("Manage Groups")} + %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_EMAIL'): + | Email + %endif %if settings.MITX_FEATURES.get('ENABLE_INSTRUCTOR_ANALYTICS'): | ${_("Analytics")} %endif @@ -431,6 +434,47 @@ function goto( mode) %endif %endif +##----------------------------------------------------------------------------- + +%if modeflag.get('Email'): +

          + + + + %if subject: + + %else: + + %endif + + %if message: + + %else: + + %endif +

          +
          + Please try not to email students more than once a day. Important things to consider before sending: +
            +
          • Have you read over the email to make sure it says everything you want to say?
          • +
          • Have you sent the email to yourself first to make sure you're happy with how it's displayed?
          • +
          + +
          +%endif + ##----------------------------------------------------------------------------- diff --git a/lms/templates/dashboard.html b/lms/templates/dashboard.html index 5023345376..3d0d5e52ee 100644 --- a/lms/templates/dashboard.html +++ b/lms/templates/dashboard.html @@ -16,6 +16,14 @@ @@ -280,6 +306,7 @@ % endif % endif ${_('Unregister')} +
    @@ -313,6 +340,29 @@ + + @@ -343,13 +343,13 @@ From d341d6d26da54d317f621199ca90b53a698bac39 Mon Sep 17 00:00:00 2001 From: Jason Bau Date: Thu, 22 Aug 2013 17:04:38 -0700 Subject: [PATCH 228/244] Change optout to use user.id instead of email. Includes Data + Schema migrations for optout email -> user.id change. Note that migrations should be reversible. --- common/djangoapps/student/views.py | 11 ++- lms/djangoapps/bulk_email/admin.py | 2 +- .../bulk_email/migrations/0001_initial.py | 2 - .../migrations/0002_change_field_names.py | 6 -- .../migrations/0003_add_optout_user.py | 91 +++++++++++++++++++ .../migrations/0004_migrate_optout_user.py | 91 +++++++++++++++++++ .../migrations/0005_remove_optout_email.py | 78 ++++++++++++++++ lms/djangoapps/bulk_email/models.py | 10 +- lms/djangoapps/bulk_email/tasks.py | 64 ++++++++----- .../bulk_email/tests/test_course_optout.py | 3 + lms/djangoapps/bulk_email/tests/test_email.py | 58 +++++++++++- .../bulk_email/tests/test_err_handling.py | 3 +- lms/djangoapps/instructor/views/legacy.py | 41 ++++----- lms/envs/aws.py | 2 +- lms/envs/common.py | 3 +- 15 files changed, 397 insertions(+), 68 deletions(-) create mode 100644 lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py create mode 100644 lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py create mode 100644 lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py diff --git a/common/djangoapps/student/views.py b/common/djangoapps/student/views.py index ebd067942e..b1b8f6ff1e 100644 --- a/common/djangoapps/student/views.py +++ b/common/djangoapps/student/views.py @@ -1,3 +1,6 @@ +""" +Student Views +""" import datetime import feedparser import json @@ -271,7 +274,7 @@ def dashboard(request): log.error("User {0} enrolled in non-existent course {1}" .format(user.username, enrollment.course_id)) - course_optouts = Optout.objects.filter(email=user.email).values_list('course_id', flat=True) + course_optouts = Optout.objects.filter(user=user).values_list('course_id', flat=True) message = "" if not user.is_active: @@ -1289,13 +1292,13 @@ def change_email_settings(request): course_id = request.POST.get("course_id") receive_emails = request.POST.get("receive_emails") if receive_emails: - optout_object = Optout.objects.filter(email=user.email, course_id=course_id) + optout_object = Optout.objects.filter(user=user, course_id=course_id) if optout_object: optout_object.delete() - log.info(u"User {0} ({1}) opted to receive emails from course {2}".format(user.username, user.email, course_id)) + log.info(u"User {0} ({1}) opted in to receive emails from course {2}".format(user.username, user.email, course_id)) track.views.server_track(request, "change-email-settings", {"receive_emails": "yes", "course": course_id}, page='dashboard') else: - Optout.objects.get_or_create(email=request.user.email, course_id=course_id) + Optout.objects.get_or_create(user=user, course_id=course_id) log.info(u"User {0} ({1}) opted out of receiving emails from course {2}".format(user.username, user.email, course_id)) track.views.server_track(request, "change-email-settings", {"receive_emails": "no", "course": course_id}, page='dashboard') diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py index 40da36629c..3b40290f5d 100644 --- a/lms/djangoapps/bulk_email/admin.py +++ b/lms/djangoapps/bulk_email/admin.py @@ -13,7 +13,7 @@ class CourseEmailAdmin(admin.ModelAdmin): class OptoutAdmin(admin.ModelAdmin): """Admin for optouts.""" - list_display = ('email', 'course_id') + list_display = ('user', 'course_id') admin.site.register(CourseEmail, CourseEmailAdmin) diff --git a/lms/djangoapps/bulk_email/migrations/0001_initial.py b/lms/djangoapps/bulk_email/migrations/0001_initial.py index 99c99d4efc..c3672a6de8 100644 --- a/lms/djangoapps/bulk_email/migrations/0001_initial.py +++ b/lms/djangoapps/bulk_email/migrations/0001_initial.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- -import datetime from south.db import db from south.v2 import SchemaMigration -from django.db import models class Migration(SchemaMigration): diff --git a/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py b/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py index 95c0db339f..93fa33a544 100644 --- a/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py +++ b/lms/djangoapps/bulk_email/migrations/0002_change_field_names.py @@ -1,8 +1,6 @@ # -*- coding: utf-8 -*- -import datetime from south.db import db from south.v2 import SchemaMigration -from django.db import models class Migration(SchemaMigration): @@ -19,7 +17,6 @@ class Migration(SchemaMigration): self.gf('django.db.models.fields.TextField')(null=True, blank=True), keep_default=False) - def backwards(self, orm): # Renaming field 'CourseEmail.to_option' db.rename_column('bulk_email_courseemail', 'to_option', 'to') @@ -30,9 +27,6 @@ class Migration(SchemaMigration): # Deleting field 'CourseEmail.text_message' db.delete_column('bulk_email_courseemail', 'text_message') - - - models = { 'auth.group': { 'Meta': {'object_name': 'Group'}, diff --git a/lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py b/lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py new file mode 100644 index 0000000000..1bf344f6e9 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0003_add_optout_user.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Adding field 'Optout.user' + db.add_column('bulk_email_optout', 'user', + self.gf('django.db.models.fields.related.ForeignKey')(to=orm['auth.User'], null=True), + keep_default=False) + + # Removing unique constraint on 'Optout', fields ['course_id', 'email'] + db.delete_unique('bulk_email_optout', ['course_id', 'email']) + + # Adding unique constraint on 'Optout', fields ['course_id', 'user'] + db.create_unique('bulk_email_optout', ['course_id', 'user_id']) + + def backwards(self, orm): + + # Removing unique constraint on 'Optout', fields ['course_id', 'user'] + db.delete_unique('bulk_email_optout', ['course_id', 'user_id']) + + # Deleting field 'Optout.email' + db.delete_column('bulk_email_optout', 'user_id') + + # Creating unique constraint on 'Optout', fields ['course_id', 'email'] + db.create_unique('bulk_email_optout', ['course_id', 'email']) + + 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'}) + }, + 'bulk_email.courseemail': { + 'Meta': {'object_name': 'CourseEmail'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['bulk_email'] diff --git a/lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py b/lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py new file mode 100644 index 0000000000..6dd2129466 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0004_migrate_optout_user.py @@ -0,0 +1,91 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import DataMigration +from django.core.exceptions import ObjectDoesNotExist + + +class Migration(DataMigration): + + def forwards(self, orm): + + # forwards data migration to copy over existing emails to associated ids + if not db.dry_run: + for optout in orm.Optout.objects.all(): + try: + user = orm['auth.User'].objects.get(email=optout.email) + optout.user = user + optout.save() + except ObjectDoesNotExist: + # if user is not found (because they have already changed their email) + # then delete the optout, as it's no longer useful. + optout.delete() + + def backwards(self, orm): + + # backwards data migration to copy over emails of students to old email slot + if not db.dry_run: + for optout in orm.Optout.objects.all(): + if optout.user is not None: + optout.email = optout.user.email + optout.save() + + 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'}) + }, + 'bulk_email.courseemail': { + 'Meta': {'object_name': 'CourseEmail'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'email': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['bulk_email'] diff --git a/lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py b/lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py new file mode 100644 index 0000000000..3639d1e473 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0005_remove_optout_email.py @@ -0,0 +1,78 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration + + +class Migration(SchemaMigration): + + def forwards(self, orm): + + # Deleting field 'Optout.email' + db.delete_column('bulk_email_optout', 'email') + + def backwards(self, orm): + + # Adding field 'Optout.email' + db.add_column('bulk_email_optout', 'email', + self.gf('django.db.models.fields.CharField')(max_length=255, null=True, blank=True), + keep_default=False) + + 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'}) + }, + 'bulk_email.courseemail': { + 'Meta': {'object_name': 'CourseEmail'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['bulk_email'] diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py index 8fa864b955..72c9569cc1 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -30,7 +30,7 @@ class Email(models.Model): created = models.DateTimeField(auto_now_add=True) modified = models.DateTimeField(auto_now=True) - class Meta: + class Meta: # pylint: disable=C0111 abstract = True @@ -61,10 +61,10 @@ class CourseEmail(Email, models.Model): class Optout(models.Model): """ - Stores emails that have opted out of receiving emails from a course. + Stores users that have opted out of receiving emails from a course. """ - email = models.CharField(max_length=255, db_index=True) + user = models.ForeignKey(User, db_index=True, null=True) course_id = models.CharField(max_length=255, db_index=True) - class Meta: - unique_together = ('email', 'course_id') + class Meta: # pylint: disable=C0111 + unique_together = ('user', 'course_id') diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 35c57b9dad..7afd286570 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -2,10 +2,10 @@ This module contains celery task functions for handling the sending of bulk email to a course. """ -import logging import math import re import time +import gc from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError @@ -14,13 +14,14 @@ from django.contrib.auth.models import User, Group from django.core.mail import EmailMultiAlternatives, get_connection from django.http import Http404 from celery import task, current_task +from celery.utils.log import get_task_logger from bulk_email.models import CourseEmail, Optout from courseware.access import _course_staff_group_name, _course_instructor_group_name from courseware.courses import get_course_by_id from mitxmako.shortcuts import render_to_string -log = logging.getLogger(__name__) +log = get_task_logger(__name__) @task(default_retry_delay=10, max_retries=5) # pylint: disable=E1102 @@ -47,37 +48,42 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): raise delegate_email_batches.retry(arg=[email_id, to_option, course_id, course_url, user_id], exc=exc) if to_option == "myself": - recipient_qset = User.objects.filter(id=user_id).values('profile__name', 'email') - + recipient_qset = User.objects.filter(id=user_id) elif to_option == "all" or to_option == "staff": staff_grpname = _course_staff_group_name(course.location) staff_group, _ = Group.objects.get_or_create(name=staff_grpname) - staff_qset = staff_group.user_set.values('profile__name', 'email') + staff_qset = staff_group.user_set.all() instructor_grpname = _course_instructor_group_name(course.location) instructor_group, _ = Group.objects.get_or_create(name=instructor_grpname) - instructor_qset = instructor_group.user_set.values('profile__name', 'email') + instructor_qset = instructor_group.user_set.all() recipient_qset = staff_qset | instructor_qset if to_option == "all": - # Two queries are executed per performance considerations for MySQL. - # See https://docs.djangoproject.com/en/1.2/ref/models/querysets/#in. - course_optouts = Optout.objects.filter(course_id=course_id).values_list('email', flat=True) - enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id).exclude(email__in=list(course_optouts)).values('profile__name', 'email') + enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id, + courseenrollment__is_active=True) recipient_qset = recipient_qset | enrollment_qset recipient_qset = recipient_qset.distinct() - else: log.error("Unexpected bulk email TO_OPTION found: %s", to_option) raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option)) - recipient_list = list(recipient_qset) - total_num_emails = len(recipient_list) - num_workers = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_TASK))) - chunk = int(math.ceil(float(total_num_emails) / float(num_workers))) - - for i in range(num_workers): - to_list = recipient_list[i * chunk:i * chunk + chunk] - course_email.delay(email_id, to_list, course.display_name, course_url, False) + recipient_qset = recipient_qset.order_by('pk') + total_num_emails = recipient_qset.count() + num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY))) + last_pk = recipient_qset[0].pk - 1 + num_workers = 0 + for j in range(num_queries): + recipient_sublist = list(recipient_qset.order_by('pk').filter(pk__gt=last_pk) + .values('profile__name', 'email', 'pk')[:settings.EMAILS_PER_QUERY]) + last_pk = recipient_sublist[-1]['pk'] + num_emails_this_query = len(recipient_sublist) + num_tasks_this_query = int(math.ceil(float(num_emails_this_query) / float(settings.EMAILS_PER_TASK))) + chunk = int(math.ceil(float(num_emails_this_query) / float(num_tasks_this_query))) + for i in range(num_tasks_this_query): + to_list = recipient_sublist[i * chunk:i * chunk + chunk] + course_email.delay(email_id, to_list, course.display_name, course_url, False) + num_workers += num_tasks_this_query + gc.collect() return num_workers @@ -89,12 +95,22 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): being the only "to". Emails are sent multipart, in both plain text and html. """ + try: msg = CourseEmail.objects.get(id=email_id) except CourseEmail.DoesNotExist as exc: log.exception(exc.args[0]) raise exc + # exclude optouts + optouts = Optout.objects.filter(course_id=msg.course_id, + user__email__in=[i['email'] for i in to_list])\ + .values_list('user__email', flat=True) + + num_optout = len(optouts) + + to_list = filter(lambda x: x['email'] not in optouts, to_list) + subject = "[" + course_title + "] " + msg.subject course_title_no_quotes = re.sub(r'"', '', course_title) @@ -114,9 +130,9 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): } while to_list: - (name, email) = to_list[-1].values() - email_context['name'] = name + email = to_list[-1]['email'] email_context['email'] = email + email_context['name'] = to_list[-1]['profile__name'] html_footer = render_to_string( 'emails/email_footer.html', @@ -157,7 +173,7 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): to_list.pop() connection.close() - return course_email_result(num_sent, num_error) + return course_email_result(num_sent, num_error, num_optout) except (SMTPDataError, SMTPConnectError, SMTPServerDisconnected) as exc: # Error caught here cause the email to be retried. The entire task is actually retried without popping the list @@ -175,6 +191,6 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): # This string format code is wrapped in this function to allow mocking for a unit test -def course_email_result(num_sent, num_error): +def course_email_result(num_sent, num_error, num_optout): """Return the formatted result of course_email sending.""" - return "Sent {0}, Fail {1}".format(num_sent, num_error) + return "Sent {0}, Fail {1}, Optout {2}".format(num_sent, num_error, num_optout) diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py index 499ccb0b95..18f04cebc1 100644 --- a/lms/djangoapps/bulk_email/tests/test_course_optout.py +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -10,6 +10,7 @@ from django.test.utils import override_settings from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory +from student.models import CourseEnrollment from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory @@ -94,6 +95,8 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): self.client.logout() + self.assertTrue(CourseEnrollment.is_enrolled(self.student, self.course.id)) + self.client.login(username=self.instructor.username, password="test") self.navigate_to_email_view() diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index 67f49d1f51..b0cf8dc06e 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -2,7 +2,6 @@ """ Unit tests for sending course email """ - from django.test.utils import override_settings from django.conf import settings from django.core import mail @@ -14,12 +13,29 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from bulk_email.tasks import delegate_email_batches, course_email -from bulk_email.models import CourseEmail +from bulk_email.models import CourseEmail, Optout from mock import patch STAFF_COUNT = 3 STUDENT_COUNT = 10 +LARGE_NUM_EMAILS = 137 + + +class MockCourseEmailResult(object): + """ + A small closure-like class to keep count of emails sent over all tasks, recorded + by mock object side effects + """ + emails_sent = 0 + + def get_mock_course_email_result(self): + """Wrapper for mock email function.""" + def mock_course_email_result(sent, failed, output, **kwargs): # pylint: disable=W0613 + """Increments count of number of emails sent.""" + self.emails_sent += sent + return True + return mock_course_email_result @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @@ -110,6 +126,7 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): self.assertContains(response, "Your email was successfully queued for sending.") + # the 1 is for the instructor in this test and others self.assertEquals(len(mail.outbox), 1 + len(self.staff)) self.assertItemsEqual( [e.to[0] for e in mail.outbox], @@ -225,6 +242,43 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): [self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] ) + @override_settings(EMAILS_PER_TASK=3, EMAILS_PER_QUERY=7) + @patch('bulk_email.tasks.course_email_result') + def test_chunked_queries_send_numerous_emails(self, email_mock): + """ + Test sending a large number of emails, to test the chunked querying + """ + mock_factory = MockCourseEmailResult() + email_mock.side_effect = mock_factory.get_mock_course_email_result() + added_users = [] + for _ in xrange(LARGE_NUM_EMAILS): + user = UserFactory() + added_users.append(user) + CourseEnrollmentFactory.create(user=user, course_id=self.course.id) + + optouts = [] + for i in [1, 3, 9, 10, 18]: # 5 random optouts + user = added_users[i] + optouts.append(user) + optout = Optout(user=user, course_id=self.course.id) + optout.save() + + test_email = { + 'action': 'Send email', + 'to_option': 'all', + 'subject': 'test subject for all', + 'message': 'test message for all' + } + response = self.client.post(self.url, test_email) + self.assertContains(response, "Your email was successfully queued for sending.") + self.assertEquals(mock_factory.emails_sent, + 1 + len(self.staff) + len(self.students) + LARGE_NUM_EMAILS - len(optouts)) + self.assertItemsEqual( + [e.to[0] for e in mail.outbox], + [self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] + + [s.email for s in added_users if s not in optouts] + ) + @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) class TestEmailSendExceptions(ModuleStoreTestCase): diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index e7d2e62d4a..606a0bef88 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -94,7 +94,8 @@ class TestEmailErrors(ModuleStoreTestCase): # We shouldn't retry when hitting a 5xx error self.assertFalse(retry.called) # Test that after the rejected email, the rest still successfully send - ((sent, fail), _) = result.call_args + ((sent, fail, optouts), _) = result.call_args + self.assertEquals(optouts, 0) self.assertEquals(fail, 1) self.assertEquals(sent, settings.EMAILS_PER_TASK - 1) diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index a2ccf0a5bc..15b0df59c1 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -56,7 +56,6 @@ from mitxmako.shortcuts import render_to_string from bulk_email.models import CourseEmail from html_to_text import html_to_text -import datetime from bulk_email import tasks log = logging.getLogger(__name__) @@ -66,11 +65,11 @@ FORUM_ROLE_ADD = 'add' FORUM_ROLE_REMOVE = 'remove' -def split_by_comma_and_whitespace(s): +def split_by_comma_and_whitespace(a_str): """ - Return string s, split by , or whitespace + Return string a_str, split by , or whitespace """ - return re.split(r'[\s,]', s) + return re.split(r'[\s,]', a_str) @ensure_csrf_cookie @@ -124,13 +123,13 @@ def instructor_dashboard(request, course_id): datatable['data'] = data return datatable - def return_csv(fn, datatable, fp=None): + def return_csv(func, datatable, file_pointer=None): """Outputs a CSV file from the contents of a datatable.""" - if fp is None: + if file_pointer is None: response = HttpResponse(mimetype='text/csv') - response['Content-Disposition'] = 'attachment; filename={0}'.format(fn) + response['Content-Disposition'] = 'attachment; filename={0}'.format(func) else: - response = fp + response = file_pointer writer = csv.writer(response, dialect='excel', quotechar='"', quoting=csv.QUOTE_ALL) writer.writerow(datatable['header']) for datarow in datatable['data']: @@ -279,11 +278,11 @@ def instructor_dashboard(request, course_id): msg += 'Failed to create a background task for rescoring "{0}".'.format(problem_url) else: track.views.server_track(request, "rescore-all-submissions", {"problem": problem_url, "course": course_id}, page="idashboard") - except ItemNotFoundError as e: + except ItemNotFoundError as err: msg += 'Failed to create a background task for rescoring "{0}": problem not found.'.format(problem_url) - except Exception as e: - log.error("Encountered exception from rescore: {0}".format(e)) - msg += 'Failed to create a background task for rescoring "{0}": {1}.'.format(problem_url, e.message) + except Exception as err: + log.error("Encountered exception from rescore: {0}".format(err)) + msg += 'Failed to create a background task for rescoring "{0}": {1}.'.format(problem_url, err.message) elif "Reset ALL students' attempts" in action: problem_urlname = request.POST.get('problem_for_all_students', '') @@ -294,12 +293,12 @@ def instructor_dashboard(request, course_id): msg += 'Failed to create a background task for resetting "{0}".'.format(problem_url) else: track.views.server_track(request, "reset-all-attempts", {"problem": problem_url, "course": course_id}, page="idashboard") - except ItemNotFoundError as e: - log.error('Failure to reset: unknown problem "{0}"'.format(e)) + except ItemNotFoundError as err: + log.error('Failure to reset: unknown problem "{0}"'.format(err)) msg += 'Failed to create a background task for resetting "{0}": problem not found.'.format(problem_url) - except Exception as e: - log.error("Encountered exception from reset: {0}".format(e)) - msg += 'Failed to create a background task for resetting "{0}": {1}.'.format(problem_url, e.message) + except Exception as err: + log.error("Encountered exception from reset: {0}".format(err)) + msg += 'Failed to create a background task for resetting "{0}": {1}.'.format(problem_url, err.message) elif "Show Background Task History for Student" in action: # put this before the non-student case, since the use of "in" will cause this to be missed @@ -475,10 +474,10 @@ def instructor_dashboard(request, course_id): return return_csv('grades %s.csv' % aname, datatable) elif 'remote gradebook' in action: - fp = StringIO() - return_csv('', datatable, fp=fp) - fp.seek(0) - files = {'datafile': fp} + file_pointer = StringIO() + return_csv('', datatable, file_pointer=file_pointer) + file_pointer.seek(0) + files = {'datafile': file_pointer} msg2, _ = _do_remote_gradebook(request.user, course, 'post-grades', files=files) msg += msg2 diff --git a/lms/envs/aws.py b/lms/envs/aws.py index 45738e6d31..a22fbc5bb6 100644 --- a/lms/envs/aws.py +++ b/lms/envs/aws.py @@ -106,7 +106,7 @@ EMAIL_HOST = ENV_TOKENS.get('EMAIL_HOST', 'localhost') # django default is loca EMAIL_PORT = ENV_TOKENS.get('EMAIL_PORT', 25) # django default is 25 EMAIL_USE_TLS = ENV_TOKENS.get('EMAIL_USE_TLS', False) # django default is False EMAILS_PER_TASK = ENV_TOKENS.get('EMAILS_PER_TASK', 100) - +EMAILS_PER_QUERY = ENV_TOKENS.get('EMAILS_PER_QUERY', 1000) SITE_NAME = ENV_TOKENS['SITE_NAME'] SESSION_ENGINE = ENV_TOKENS.get('SESSION_ENGINE', SESSION_ENGINE) SESSION_COOKIE_DOMAIN = ENV_TOKENS.get('SESSION_COOKIE_DOMAIN') diff --git a/lms/envs/common.py b/lms/envs/common.py index 51fba25e69..5ad81d5f03 100644 --- a/lms/envs/common.py +++ b/lms/envs/common.py @@ -364,7 +364,8 @@ IGNORABLE_404_ENDS = ('favicon.ico') EMAIL_BACKEND = 'django.core.mail.backends.console.EmailBackend' DEFAULT_FROM_EMAIL = 'registration@edx.org' DEFAULT_BULK_FROM_EMAIL = 'course-updates@edx.org' -EMAILS_PER_TASK = 10 +EMAILS_PER_TASK = 100 +EMAILS_PER_QUERY = 1000 DEFAULT_FEEDBACK_EMAIL = 'feedback@edx.org' SERVER_EMAIL = 'devops@edx.org' TECH_SUPPORT_EMAIL = 'technical@edx.org' From 8f93051d303d402947ca41a0c54cbeb8a17fc5ce Mon Sep 17 00:00:00 2001 From: Brian Wilson Date: Thu, 8 Aug 2013 16:44:36 -0400 Subject: [PATCH 229/244] Add editable templates for bulk email Adds the edX Marketing-approved template as html default. --- lms/djangoapps/bulk_email/admin.py | 43 ++- .../fixtures/course_email_template.json | 10 + .../plain-html-no-newlines-or-tabs.txt | 1 + .../fixtures/plain-html-no-newlines.txt | 1 + .../bulk_email/fixtures/plain-html.txt | 268 ++++++++++++++++++ lms/djangoapps/bulk_email/forms.py | 42 +++ .../0006_add_course_email_template.py | 87 ++++++ .../0007_load_course_email_template.py | 81 ++++++ lms/djangoapps/bulk_email/models.py | 99 ++++++- lms/djangoapps/bulk_email/tasks.py | 100 ++++--- .../bulk_email/tests/test_course_optout.py | 4 + lms/djangoapps/bulk_email/tests/test_email.py | 25 +- .../bulk_email/tests/test_err_handling.py | 4 + lms/djangoapps/instructor/views/legacy.py | 8 +- lms/static/images/bulk_email/FacebookIcon.png | Bin 0 -> 550 bytes .../images/bulk_email/GooglePlusIcon.png | Bin 0 -> 1286 bytes lms/static/images/bulk_email/LinkedInIcon.png | Bin 0 -> 751 bytes lms/static/images/bulk_email/MeetupIcon.png | Bin 0 -> 1283 bytes lms/static/images/bulk_email/TwitterIcon.png | Bin 0 -> 998 bytes .../images/bulk_email/VKontakteIcon.png | Bin 0 -> 2480 bytes .../images/bulk_email/edXHeaderImage.jpg | Bin 0 -> 25814 bytes 21 files changed, 719 insertions(+), 54 deletions(-) create mode 100644 lms/djangoapps/bulk_email/fixtures/course_email_template.json create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt create mode 100644 lms/djangoapps/bulk_email/fixtures/plain-html.txt create mode 100644 lms/djangoapps/bulk_email/forms.py create mode 100644 lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py create mode 100644 lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py create mode 100644 lms/static/images/bulk_email/FacebookIcon.png create mode 100644 lms/static/images/bulk_email/GooglePlusIcon.png create mode 100644 lms/static/images/bulk_email/LinkedInIcon.png create mode 100644 lms/static/images/bulk_email/MeetupIcon.png create mode 100644 lms/static/images/bulk_email/TwitterIcon.png create mode 100644 lms/static/images/bulk_email/VKontakteIcon.png create mode 100644 lms/static/images/bulk_email/edXHeaderImage.jpg diff --git a/lms/djangoapps/bulk_email/admin.py b/lms/djangoapps/bulk_email/admin.py index 3b40290f5d..1505af9ce4 100644 --- a/lms/djangoapps/bulk_email/admin.py +++ b/lms/djangoapps/bulk_email/admin.py @@ -3,7 +3,8 @@ Django admin page for bulk email models """ from django.contrib import admin -from bulk_email.models import CourseEmail, Optout +from bulk_email.models import CourseEmail, Optout, CourseEmailTemplate +from bulk_email.forms import CourseEmailTemplateForm class CourseEmailAdmin(admin.ModelAdmin): @@ -16,5 +17,45 @@ class OptoutAdmin(admin.ModelAdmin): list_display = ('user', 'course_id') +class CourseEmailTemplateAdmin(admin.ModelAdmin): + form = CourseEmailTemplateForm + fieldsets = ( + (None, { + # make the HTML template display above the plain template: + 'fields': ('html_template', 'plain_template'), + 'description': ''' +Enter template to be used by course staff when sending emails to enrolled students. + +The HTML template is for HTML email, and may contain HTML markup. The plain template is +for plaintext email. Both templates should contain the string '{{message_body}}' (with +two curly braces on each side), to indicate where the email text is to be inserted. + +Other tags that may be used (surrounded by one curly brace on each side): +{platform_name} : the name of the platform +{course_title} : the name of the course +{course_url} : the course's full URL +{email} : the user's email address +{account_settings_url} : URL at which users can change email preferences +{course_image_url} : URL for the course's course image. + Will return a broken link if course doesn't have a course image set. + +Note that there is currently NO validation on tags, so be careful. Typos or use of +unsupported tags will cause email sending to fail. +''' + }), + ) + # Turn off the action bar (we have no bulk actions) + actions = None + + def has_add_permission(self, request): + """Disables the ability to add new templates, as we want to maintain a Singleton.""" + return False + + def has_delete_permission(self, request, obj=None): + """Disables the ability to remove existing templates, as we want to maintain a Singleton.""" + return False + + admin.site.register(CourseEmail, CourseEmailAdmin) admin.site.register(Optout, OptoutAdmin) +admin.site.register(CourseEmailTemplate, CourseEmailTemplateAdmin) diff --git a/lms/djangoapps/bulk_email/fixtures/course_email_template.json b/lms/djangoapps/bulk_email/fixtures/course_email_template.json new file mode 100644 index 0000000000..076dedbd14 --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/course_email_template.json @@ -0,0 +1,10 @@ +[ + { + "pk": 1, + "model": "bulk_email.courseemailtemplate", + "fields": { + "plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX: Facebook (http://facebook.com/edxonline)\nTwitter (http://twitter.com/edxonline)\nGoogle+ (https://plus.google.com/108235383044095082735)\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\n This email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", + "html_template": " Update from {course_title}

    edX
    Connect with edX:        

    {course_title}


    {{message_body}}
           
    Copyright © 2013 edX, All rights reserved.


    Our mailing address is:
    edX
    11 Cambridge Center, Suite 101
    Cambridge, MA, USA 02142


    This email was automatically sent from {platform_name}.
    You are receiving this email at address {email} because you are enrolled in {course_title}.
    To stop receiving email like this, update your course email settings here.
    " + } + } +] diff --git a/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt b/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt new file mode 100644 index 0000000000..e1c3688e85 --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines-or-tabs.txt @@ -0,0 +1 @@ + Update from {course_title}

    edX
    Connect with edX:        

    {course_title}


    {{message_body}}
           
    Copyright © 2013 edX, All rights reserved.


    Our mailing address is:
    edX
    11 Cambridge Center, Suite 101
    Cambridge, MA, USA 02142


    This email was automatically sent from {platform_name}.
    You are receiving this email at address {email} because you are enrolled in {course_title}.
    To stop receiving email like this, update your course email settings here.
    \ No newline at end of file diff --git a/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt b/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt new file mode 100644 index 0000000000..740d390a3d --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/plain-html-no-newlines.txt @@ -0,0 +1 @@ + Update from {course_title}

    edX
    Connect with edX:        

    {course_title}


    {{message_body}}
           
    Copyright © 2013 edX, All rights reserved.


    Our mailing address is:
    edX
    11 Cambridge Center, Suite 101
    Cambridge, MA, USA 02142


    This email was automatically sent from {platform_name}.
    You are receiving this email at address {email} because you are enrolled in {course_title}.
    To stop receiving email like this, update your course email settings here.
    \ No newline at end of file diff --git a/lms/djangoapps/bulk_email/fixtures/plain-html.txt b/lms/djangoapps/bulk_email/fixtures/plain-html.txt new file mode 100644 index 0000000000..9806e51d79 --- /dev/null +++ b/lms/djangoapps/bulk_email/fixtures/plain-html.txt @@ -0,0 +1,268 @@ + + + + + + Update from {course_title} + + +
    + + + + +
    + + + + + + + + + + + + + + +
    + + + + + +
    + + + + +
    + + + + + +
    + + + + + + +
    + +
    + +
    + +
    +
    + +
    + + + + + +
    + + + + +
    + + + + + +
    + + + + +
    + + + edX + + +
    +
    + + + + + +
    + + + + + + +
    + +
    + Connect with edX:        
    + +
    + +
    +
    + +
    + + + + + +
    + + + + +
    + + + + + +
    + + + + + + +
    + + + + +
    + + + + + +
    + + + + +
    +

    + {course_title}

    + +
    +
    +
    + + + + + +
    + + + + + +
    + + + + + + +
    + {{message_body}} + +
    + +
    + + + + + +
    + + + + +
    + +
    +
    + + + + + +
    + + + + + + +
    + +
    +        
    + +
    + +
    +
    + +
    + + + + + +
    + + + + +
    + + + + + +
    + + + + + + +
    + + Copyright © 2013 edX, All rights reserved.
    +
    +
    + Our mailing address is:
    + edX
    + 11 Cambridge Center, Suite 101
    + Cambridge, MA, USA 02142
    +
    +
    +This email was automatically sent from {platform_name}.
    +You are receiving this email at address {email} because you are enrolled in {course_title}.
    +To stop receiving email like this, update your course email settings here.
    +
    + +
    +
    + +
    + +
    +
    + diff --git a/lms/djangoapps/bulk_email/forms.py b/lms/djangoapps/bulk_email/forms.py new file mode 100644 index 0000000000..2ccdd72d16 --- /dev/null +++ b/lms/djangoapps/bulk_email/forms.py @@ -0,0 +1,42 @@ +import logging + +from django import forms +from django.core.exceptions import ValidationError + +from bulk_email.models import CourseEmailTemplate, COURSE_EMAIL_MESSAGE_BODY_TAG + +log = logging.getLogger(__name__) + + +class CourseEmailTemplateForm(forms.ModelForm): + """Form providing validation of CourseEmail templates.""" + + class Meta: + model = CourseEmailTemplate + + def _validate_template(self, template): + """Check the template for required tags.""" + index = template.find(COURSE_EMAIL_MESSAGE_BODY_TAG) + if index < 0: + msg = 'Missing tag: "{}"'.format(COURSE_EMAIL_MESSAGE_BODY_TAG) + log.warning(msg) + raise ValidationError(msg) + if template.find(COURSE_EMAIL_MESSAGE_BODY_TAG, index + 1) >= 0: + msg = 'Multiple instances of tag: "{}"'.format(COURSE_EMAIL_MESSAGE_BODY_TAG) + log.warning(msg) + raise ValidationError(msg) + # TODO: add more validation here, including the set of known tags + # for which values will be supplied. (Email will fail if the template + # uses tags for which values are not supplied.) + + def clean_html_template(self): + """Validate the HTML template.""" + template = self.cleaned_data["html_template"] + self._validate_template(template) + return template + + def clean_plain_template(self): + """Validate the plaintext template.""" + template = self.cleaned_data["plain_template"] + self._validate_template(template) + return template diff --git a/lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py b/lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py new file mode 100644 index 0000000000..69ec3fe3b3 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0006_add_course_email_template.py @@ -0,0 +1,87 @@ +# -*- coding: utf-8 -*- +from south.db import db +from south.v2 import SchemaMigration +from django.db import models + + +class Migration(SchemaMigration): + + def forwards(self, orm): + # Adding model 'CourseEmailTemplate' + db.create_table('bulk_email_courseemailtemplate', ( + ('id', self.gf('django.db.models.fields.AutoField')(primary_key=True)), + ('html_template', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + ('plain_template', self.gf('django.db.models.fields.TextField')(null=True, blank=True)), + )) + db.send_create_signal('bulk_email', ['CourseEmailTemplate']) + + def backwards(self, orm): + # Deleting model 'CourseEmailTemplate' + db.delete_table('bulk_email_courseemailtemplate') + + + 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'}) + }, + 'bulk_email.courseemail': { + 'Meta': {'object_name': 'CourseEmail'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.courseemailtemplate': { + 'Meta': {'object_name': 'CourseEmailTemplate'}, + 'html_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'plain_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['bulk_email'] diff --git a/lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py b/lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py new file mode 100644 index 0000000000..7ccaaf07f9 --- /dev/null +++ b/lms/djangoapps/bulk_email/migrations/0007_load_course_email_template.py @@ -0,0 +1,81 @@ +# -*- coding: utf-8 -*- +from south.v2 import DataMigration + + +class Migration(DataMigration): + + def forwards(self, orm): + "Load data from fixture." + from django.core.management import call_command + call_command("loaddata", "course_email_template.json") + + def backwards(self, orm): + "Perform a no-op to go backwards." + 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'}) + }, + 'bulk_email.courseemail': { + 'Meta': {'object_name': 'CourseEmail'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'created': ('django.db.models.fields.DateTimeField', [], {'auto_now_add': 'True', 'blank': 'True'}), + 'html_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'modified': ('django.db.models.fields.DateTimeField', [], {'auto_now': 'True', 'blank': 'True'}), + 'sender': ('django.db.models.fields.related.ForeignKey', [], {'default': '1', 'to': "orm['auth.User']", 'null': 'True', 'blank': 'True'}), + 'slug': ('django.db.models.fields.CharField', [], {'max_length': '128', 'db_index': 'True'}), + 'subject': ('django.db.models.fields.CharField', [], {'max_length': '128', 'blank': 'True'}), + 'text_message': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'to_option': ('django.db.models.fields.CharField', [], {'default': "'myself'", 'max_length': '64'}) + }, + 'bulk_email.courseemailtemplate': { + 'Meta': {'object_name': 'CourseEmailTemplate'}, + 'html_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'plain_template': ('django.db.models.fields.TextField', [], {'null': 'True', 'blank': 'True'}) + }, + 'bulk_email.optout': { + 'Meta': {'unique_together': "(('user', 'course_id'),)", 'object_name': 'Optout'}, + 'course_id': ('django.db.models.fields.CharField', [], {'max_length': '255', 'db_index': 'True'}), + 'id': ('django.db.models.fields.AutoField', [], {'primary_key': 'True'}), + 'user': ('django.db.models.fields.related.ForeignKey', [], {'to': "orm['auth.User']", 'null': 'True'}) + }, + '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'}) + } + } + + complete_apps = ['bulk_email'] + symmetrical = True diff --git a/lms/djangoapps/bulk_email/models.py b/lms/djangoapps/bulk_email/models.py index 72c9569cc1..9d32dbd70c 100644 --- a/lms/djangoapps/bulk_email/models.py +++ b/lms/djangoapps/bulk_email/models.py @@ -10,13 +10,13 @@ file and check it in at the same time as your model changes. To do that, 2. ./manage.py lms schemamigration bulk_email --auto description_of_your_change 3. Add the migration file created in edx-platform/lms/djangoapps/bulk_email/migrations/ - -ASSUMPTIONS: modules have unique IDs, even across different module_types - """ +import logging from django.db import models from django.contrib.auth.models import User +log = logging.getLogger(__name__) + class Email(models.Model): """ @@ -33,6 +33,10 @@ class Email(models.Model): class Meta: # pylint: disable=C0111 abstract = True +SEND_TO_MYSELF = 'myself' +SEND_TO_STAFF = 'staff' +SEND_TO_ALL = 'all' + class CourseEmail(Email, models.Model): """ @@ -48,12 +52,12 @@ class CourseEmail(Email, models.Model): # (student, staff, or instructor) # TO_OPTIONS = ( - ('myself', 'Myself'), - ('staff', 'Staff and instructors'), - ('all', 'All') + (SEND_TO_MYSELF, 'Myself'), + (SEND_TO_STAFF, 'Staff and instructors'), + (SEND_TO_ALL, 'All') ) course_id = models.CharField(max_length=255, db_index=True) - to_option = models.CharField(max_length=64, choices=TO_OPTIONS, default='myself') + to_option = models.CharField(max_length=64, choices=TO_OPTIONS, default=SEND_TO_MYSELF) def __unicode__(self): return self.subject @@ -63,8 +67,89 @@ class Optout(models.Model): """ Stores users that have opted out of receiving emails from a course. """ + # Allowing null=True to support data migration from email->user. + # We need to first create the 'user' column with some sort of default in order to run the data migration, + # and given the unique index, 'null' is the best default value. user = models.ForeignKey(User, db_index=True, null=True) course_id = models.CharField(max_length=255, db_index=True) class Meta: # pylint: disable=C0111 unique_together = ('user', 'course_id') + + +# Defines the tag that must appear in a template, to indicate +# the location where the email message body is to be inserted. +COURSE_EMAIL_MESSAGE_BODY_TAG = '{{message_body}}' + + +class CourseEmailTemplate(models.Model): + """ + Stores templates for all emails to a course to use. + + This is expected to be a singleton, to be shared across all courses. + Initialization takes place in a migration that in turn loads a fixture. + The admin console interface disables add and delete operations. + Validation is handled in the CourseEmailTemplateForm class. + """ + html_template = models.TextField(null=True, blank=True) + plain_template = models.TextField(null=True, blank=True) + + @staticmethod + def get_template(): + """ + Fetch the current template + + If one isn't stored, an exception is thrown. + """ + return CourseEmailTemplate.objects.get() + + @staticmethod + def _render(format_string, message_body, context): + """ + Create a text message using a template, message body and context. + + Convert message body (`message_body`) into an email message + using the provided template. The template is a format string, + which is rendered using format() with the provided `context` dict. + + This doesn't insert user's text into template, until such time we can + support proper error handling due to errors in the message body + (e.g. due to the use of curly braces). + + Instead, for now, we insert the message body *after* the substitutions + have been performed, so that anything in the message body that might + interfere will be innocently returned as-is. + + Output is returned as a unicode string. It is not encoded as utf-8. + Such encoding is left to the email code, which will use the value + of settings.DEFAULT_CHARSET to encode the message. + """ + # If we wanted to support substitution, we'd call: + # format_string = format_string.replace(COURSE_EMAIL_MESSAGE_BODY_TAG, message_body) + result = format_string.format(**context) + # Note that the body tag in the template will now have been + # "formatted", so we need to do the same to the tag being + # searched for. + message_body_tag = COURSE_EMAIL_MESSAGE_BODY_TAG.format() + result = result.replace(message_body_tag, message_body, 1) + + # finally, return the result, without converting to an encoded byte array. + return result + + def render_plaintext(self, plaintext, context): + """ + Create plain text message. + + Convert plain text body (`plaintext`) into plaintext email message using the + stored plain template and the provided `context` dict. + """ + return CourseEmailTemplate._render(self.plain_template, plaintext, context) + + def render_htmltext(self, htmltext, context): + """ + Create HTML text message. + + Convert HTML text body (`htmltext`) into HTML email message using the + stored HTML template and the provided `context` dict. + """ + return CourseEmailTemplate._render(self.html_template, htmltext, context) diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 7afd286570..2153090844 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -5,7 +5,6 @@ to a course. import math import re import time -import gc from smtplib import SMTPServerDisconnected, SMTPDataError, SMTPConnectError @@ -15,11 +14,14 @@ from django.core.mail import EmailMultiAlternatives, get_connection from django.http import Http404 from celery import task, current_task from celery.utils.log import get_task_logger +from django.core.urlresolvers import reverse -from bulk_email.models import CourseEmail, Optout +from bulk_email.models import ( + CourseEmail, Optout, CourseEmailTemplate, + SEND_TO_MYSELF, SEND_TO_STAFF, SEND_TO_ALL, +) from courseware.access import _course_staff_group_name, _course_instructor_group_name -from courseware.courses import get_course_by_id -from mitxmako.shortcuts import render_to_string +from courseware.courses import get_course_by_id, course_image_url log = get_task_logger(__name__) @@ -44,12 +46,30 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): try: CourseEmail.objects.get(id=email_id) except CourseEmail.DoesNotExist as exc: + # The retry behavior here is necessary because of a race condition between the commit of the transaction + # that creates this CourseEmail row and the celery pipeline that starts this task. + # We might possibly want to move the blocking into the view function rather than have it in this task. log.warning("Failed to get CourseEmail with id %s, retry %d", email_id, current_task.request.retries) - raise delegate_email_batches.retry(arg=[email_id, to_option, course_id, course_url, user_id], exc=exc) + raise delegate_email_batches.retry(arg=[email_id, user_id], exc=exc) - if to_option == "myself": + to_option = email_obj.to_option + course_id = email_obj.course_id + + try: + course = get_course_by_id(course_id, depth=1) + except Http404 as exc: + log.exception("get_course_by_id failed: %s", exc.args[0]) + raise Exception("get_course_by_id failed: " + exc.args[0]) + + course_url = 'https://{}{}'.format( + settings.SITE_NAME, + reverse('course_root', kwargs={'course_id': course_id}) + ) + image_url = 'https://{}{}'.format(settings.SITE_NAME, course_image_url(course)) + + if to_option == SEND_TO_MYSELF: recipient_qset = User.objects.filter(id=user_id) - elif to_option == "all" or to_option == "staff": + elif to_option == SEND_TO_ALL or to_option == SEND_TO_STAFF: staff_grpname = _course_staff_group_name(course.location) staff_group, _ = Group.objects.get_or_create(name=staff_grpname) staff_qset = staff_group.user_set.all() @@ -58,7 +78,7 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): instructor_qset = instructor_group.user_set.all() recipient_qset = staff_qset | instructor_qset - if to_option == "all": + if to_option == SEND_TO_ALL: enrollment_qset = User.objects.filter(courseenrollment__course_id=course_id, courseenrollment__is_active=True) recipient_qset = recipient_qset | enrollment_qset @@ -67,12 +87,13 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): log.error("Unexpected bulk email TO_OPTION found: %s", to_option) raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option)) + image_url = course_image_url(course) recipient_qset = recipient_qset.order_by('pk') total_num_emails = recipient_qset.count() num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY))) last_pk = recipient_qset[0].pk - 1 num_workers = 0 - for j in range(num_queries): + for _ in range(num_queries): recipient_sublist = list(recipient_qset.order_by('pk').filter(pk__gt=last_pk) .values('profile__name', 'email', 'pk')[:settings.EMAILS_PER_QUERY]) last_pk = recipient_sublist[-1]['pk'] @@ -81,76 +102,86 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): chunk = int(math.ceil(float(num_emails_this_query) / float(num_tasks_this_query))) for i in range(num_tasks_this_query): to_list = recipient_sublist[i * chunk:i * chunk + chunk] - course_email.delay(email_id, to_list, course.display_name, course_url, False) + course_email.delay( + email_id, + to_list, + course.display_name, + course_url, + image_url, + False + ) num_workers += num_tasks_this_query - gc.collect() return num_workers @task(default_retry_delay=15, max_retries=5) # pylint: disable=E1102 -def course_email(email_id, to_list, course_title, course_url, throttle=False): +def course_email(email_id, to_list, course_title, course_url, image_url, throttle=False): """ - Takes a subject and an html formatted email and sends it from - sender to all addresses in the to_list, with each recipient - being the only "to". Emails are sent multipart, in both plain + Takes a primary id for a CourseEmail object and a 'to_list' of recipient objects--keys are + 'profile__name', 'email' (address), and 'pk' (in the user table). + course_title, course_url, and image_url are to memoize course properties and save lookups. + + Sends to all addresses contained in to_list. Emails are sent multi-part, in both plain text and html. """ - try: msg = CourseEmail.objects.get(id=email_id) - except CourseEmail.DoesNotExist as exc: - log.exception(exc.args[0]) - raise exc + except CourseEmail.DoesNotExist: + log.exception("Could not find email id:{} to send.".format(email_id)) + raise # exclude optouts - optouts = Optout.objects.filter(course_id=msg.course_id, - user__email__in=[i['email'] for i in to_list])\ - .values_list('user__email', flat=True) + optouts = (Optout.objects.filter(course_id=msg.course_id, + user__in=[i['pk'] for i in to_list]) + .values_list('user__email', flat=True)) num_optout = len(optouts) - to_list = filter(lambda x: x['email'] not in optouts, to_list) + to_list = filter(lambda x: x['email'] not in set(optouts), to_list) subject = "[" + course_title + "] " + msg.subject course_title_no_quotes = re.sub(r'"', '', course_title) from_addr = '"{0}" Course Staff <{1}>'.format(course_title_no_quotes, settings.DEFAULT_BULK_FROM_EMAIL) + course_email_template = CourseEmailTemplate.get_template() + try: connection = get_connection() connection.open() num_sent = 0 num_error = 0 + # Define context values to use in all course emails: email_context = { 'name': '', 'email': '', 'course_title': course_title, - 'course_url': course_url + 'course_url': course_url, + 'course_image_url': image_url, + 'account_settings_url': 'https://{}{}'.format(settings.SITE_NAME, reverse('dashboard')), + 'platform_name': settings.PLATFORM_NAME, } while to_list: + # Update context with user-specific values: email = to_list[-1]['email'] email_context['email'] = email email_context['name'] = to_list[-1]['profile__name'] - html_footer = render_to_string( - 'emails/email_footer.html', - email_context - ) + # Construct message content using templates and context: + plaintext_msg = course_email_template.render_plaintext(msg.text_message, email_context) + html_msg = course_email_template.render_htmltext(msg.html_message, email_context) - plain_footer = render_to_string( - 'emails/email_footer.txt', - email_context - ) + # Create email: email_msg = EmailMultiAlternatives( subject, - msg.text_message + plain_footer.encode('utf-8'), + plaintext_msg, from_addr, [email], connection=connection ) - email_msg.attach_alternative(msg.html_message + html_footer.encode('utf-8'), 'text/html') + email_msg.attach_alternative(html_msg, 'text/html') # Throttle if we tried a few times and got the rate limiter if throttle or current_task.request.retries > 0: @@ -183,6 +214,7 @@ def course_email(email_id, to_list, course_title, course_url, throttle=False): to_list, course_title, course_url, + image_url, current_task.request.retries > 0 ], exc=exc, diff --git a/lms/djangoapps/bulk_email/tests/test_course_optout.py b/lms/djangoapps/bulk_email/tests/test_course_optout.py index 18f04cebc1..0adf119527 100644 --- a/lms/djangoapps/bulk_email/tests/test_course_optout.py +++ b/lms/djangoapps/bulk_email/tests/test_course_optout.py @@ -4,6 +4,7 @@ Unit tests for student optouts from course email import json from django.core import mail +from django.core.management import call_command from django.core.urlresolvers import reverse from django.conf import settings from django.test.utils import override_settings @@ -30,6 +31,9 @@ class TestOptoutCourseEmails(ModuleStoreTestCase): self.student = UserFactory.create() CourseEnrollmentFactory.create(user=self.student, course_id=self.course.id) + # load initial content (since we don't run migrations as part of tests): + call_command("loaddata", "course_email_template.json") + self.client.login(username=self.student.username, password="test") def tearDown(self): diff --git a/lms/djangoapps/bulk_email/tests/test_email.py b/lms/djangoapps/bulk_email/tests/test_email.py index b0cf8dc06e..ba2633f263 100644 --- a/lms/djangoapps/bulk_email/tests/test_email.py +++ b/lms/djangoapps/bulk_email/tests/test_email.py @@ -2,10 +2,11 @@ """ Unit tests for sending course email """ -from django.test.utils import override_settings from django.conf import settings from django.core import mail from django.core.urlresolvers import reverse +from django.core.management import call_command +from django.test.utils import override_settings from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE from student.tests.factories import UserFactory, GroupFactory, CourseEnrollmentFactory @@ -63,6 +64,9 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): for student in self.students: CourseEnrollmentFactory.create(user=student, course_id=self.course.id) + # load initial content (since we don't run migrations as part of tests): + call_command("loaddata", "course_email_template.json") + self.client.login(username=self.instructor.username, password="test") # Pull up email view on instructor dashboard @@ -208,10 +212,8 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): [self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] ) - self.assertIn( - uni_message, - mail.outbox[0].body - ) + message_body = mail.outbox[0].body + self.assertIn(uni_message, message_body) def test_unicode_students_send_to_all(self): """ @@ -273,11 +275,12 @@ class TestEmailSendFromDashboard(ModuleStoreTestCase): self.assertContains(response, "Your email was successfully queued for sending.") self.assertEquals(mock_factory.emails_sent, 1 + len(self.staff) + len(self.students) + LARGE_NUM_EMAILS - len(optouts)) - self.assertItemsEqual( - [e.to[0] for e in mail.outbox], - [self.instructor.email] + [s.email for s in self.staff] + [s.email for s in self.students] + - [s.email for s in added_users if s not in optouts] - ) + outbox_contents = [e.to[0] for e in mail.outbox] + should_send_contents = ([self.instructor.email] + + [s.email for s in self.staff] + + [s.email for s in self.students] + + [s.email for s in added_users if s not in optouts]) + self.assertItemsEqual(outbox_contents, should_send_contents) @override_settings(MODULESTORE=TEST_DATA_MONGO_MODULESTORE) @@ -294,4 +297,4 @@ class TestEmailSendExceptions(ModuleStoreTestCase): def test_no_course_email_obj(self): # Make sure course_email handles CourseEmail.DoesNotExist exception. with self.assertRaises(CourseEmail.DoesNotExist): - course_email(101, [], "_", "_", False) + course_email(101, [], "_", "_", "_", False) diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index 606a0bef88..e8874ea18e 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -4,6 +4,7 @@ Unit tests for handling email sending errors from django.test.utils import override_settings from django.conf import settings +from django.core.management import call_command from django.core.urlresolvers import reverse from courseware.tests.tests import TEST_DATA_MONGO_MODULESTORE @@ -35,6 +36,9 @@ class TestEmailErrors(ModuleStoreTestCase): instructor = AdminFactory.create() self.client.login(username=instructor.username, password="test") + # load initial content (since we don't run migrations as part of tests): + call_command("loaddata", "course_email_template.json") + self.smtp_server_thread = FakeSMTPServerThread('localhost', TEST_SMTP_PORT) self.smtp_server_thread.start() diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 15b0df59c1..7326fe2e98 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -718,7 +718,13 @@ def instructor_dashboard(request, course_id): email.save() course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id})) - tasks.delegate_email_batches.delay(email.id, email.to_option, course_id, course_url, request.user.id) + tasks.delegate_email_batches.delay( + email.id, + email.to_option, + course_id, + course_url, + request.user.id + ) if to_option == "all": email_msg = '

    Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.

    ' diff --git a/lms/static/images/bulk_email/FacebookIcon.png b/lms/static/images/bulk_email/FacebookIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..7d2d8c4468ae2d1c1c9c7d2922d23dabf38b063c GIT binary patch literal 550 zcmeAS@N?(olHy`uVBq!ia0vp^J|N7&1|*M957Y)yk|nMYCBgY=CFO}lsSJ)O`AMk? zp1FzXsX?iUDV2pMQ*9U+7*BY*IEGZ*dUNN!Z?l8M@sEcs7}i&|N|!L-=1pNrY+5E9 zp`J0Pp^V9jDbGP&;QohQ%0K2Ls6;)~>-zKi-=y7JGM#7n_nkic^+4cP6<3YFQMa5X z_Rm==yEHfC!Q8o0VLG)P{>pbwt=zEfl6}IZS$70=!sGUM6o1S(A8H!kn98`Vb>HGJ_>9xPZg06ULZr8dcUd(jagZDu4gNkMC#8y))6 z(Z%xMgyu~-Td%Mx)<)0z1rf=tr+*)k(vG+yeof+F&_OTFn6sZfmgXCmf3?|lEG+Cs zvTCdT-?>@w3SXCVlx;e~wY2|vjaS+EBR9&E}Ns+DElwx=Q5kVpBpD7rE?wA%&gR8m6&Xo(7f`R8B0`K%L=ly9$mdtt$F2mjGKg09N{!=eRe1rSSnQ0Rq#Y<~Nu8dKQx7 zZTaU|2bhW^S!e}N!7w@dV~r1R!%tK+tSkqK`1Wi?W7I$mgjV^KOj54KjIBASRB{s| zVHP8yc`Q%r48s};SR-ma?9?|+e`pjwX~@$ar9Lk{hGOj;2Pg8~f*8Tp*;!zZ2G6lk+;5## z3C;mSbMy?J!xNc$m6oM0XqimIY$S?m8XF9wle*LylQiBI5m8q~Ua5C0&Y@wsmPHEt zy)k*KTrDA1m0&nArD3-6p|R-hR>{saleFnWR8owTvLBNHvstPqe{7R8chuq!*Gms0W8SPo|pqt4GbgZ!~nEu$>_n> zm0D7*_(s3e1RBg~yq3TkTo$lzXm>S}+Ei%P{1RZ*1C|%&_@?}xmA@8)Krzn%W8jdb z56-~?>1A^e&_|cun4QvR;fjMV(l8ua!vPY{GOU%$uue@9%;Ym$;9vt>v*8v}CRsEb z3(ozWnV4(E2k_yd&pMTxiXzw-H(XQ$dJJdFs%p;g5@1yUJC(6C8mjf@|F8>@rf-d2 z*p*3BV0**+T|BVl+BC5#Ftx!IoG@H>!0Pm`u%3*r9az2S7YwHi`)gMVEUaA>V9k2( zY9KH%j8eZ3kCnqcBR&ge3z#q3HFcq-VhV75ZNNY_q9MpMz|CFqYgMIDDsruS^=+Pe zr3L#kO=uAZ4#y?mRFnaWj=B`*bIjn^;scmy%m}}3%iB9(PS31pQRz7a7glT;v4y3_ zz!sTSsb~|X5F@euI=U&@G#@zgrPc!=46UaC$QKFfSi`^U8NM~zX+ry?=F@rw7YlGq z{%Dcjq6Cb*A9R7CMs1j#Y3D>}TT&C+j1d}AIsSF0P+XX86OgYhFNd~eL`K%z`gJGp zX@PP;Ko_y40Sq;3)55t^0d41!mO*Ls*wVQ%bx>axNTO@oCvXiLMQFPYSRH7XSnoSv z^*SCMuoH1&D45dP#Ibf??To002w~1^@s6B}+P} z0u>lKkP3_yXeuzNK&=2%fpid3L44vhq=Mv*JCzZIJA_g0{(olCNgTj0yLY?G^e=Jua!)1Ul7*S~w7(JbK)MNu^>-yjg-ptvpMF z-w-V7Wnmy!g9QN=U;!3j0Ty6El6q*V;7p2{E?||#08RH5d7&WRcP=Kn73EFCkX7M+ z2^3S{WYp&Q@443*4T}K?b0}2`9PW9B!&=pXIs#ki(1E>&Uou+Cr0Z6Z%hFf1p0z9lSnbG~F!%5Hjl zp~&{7uNb9&Cy=IR+e625$A_XnN{3>09-9`1ELHITah-TWGUy*H+4_IgASx-%C)pWw@87THV@|*BwoikyJ{AW=*Z!mUcdcxu`78T>nOc|KsSZMVG zlqbBXP7@KP2E*b@0c2}Ym0IvuM_4KVRGud7bcCg%Az{Q(5GZPua9Dw}0kCRN)coTB&v*BDBn_54YpfmN hYv$Si3uXTT3;>e$qS@nD4OsvH002ovPDHLkV1nW0MT-Cc literal 0 HcmV?d00001 diff --git a/lms/static/images/bulk_email/MeetupIcon.png b/lms/static/images/bulk_email/MeetupIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..9af4655ff2e6f9703ec62efe735c2da855c06a05 GIT binary patch literal 1283 zcmV+e1^oJnP)002w~1^@s6B}+Pe34~EebXu-E2nqmg(&7sjH?SPmo0wg_ zbWp241Vz345RGaFOB{L&SRw*TUcw*XEjwP4zu&W0@Fit`85Dr4_&1tS zpPAaCkiZ%<3z+tfm=+J(!U`%P;IP76jNdBT{@O8x3E4HD74;S&gFiHN1jLLs#EkMl zp|U45=34F+X6V>!&Ahuu9urv1>y_Dm-(ognhsjb61tlY|Y2iwZiwenqXt^qcKe$jaP@7p|*5}YgeCi za!{&o)O)hzVD$b8@jn`!y52Tk#e*#@T9-RF`pMdVUH}CqXmTxTDG#pXw5`utT73^x zuHyGSRtchl78y9v9@?%Q5qV7Ro0K%jfa(|eSFwG;9SzH&+DFw-91UHubAdBO$Wse5 z-C2_k-j{N)ChJz^Xy&tA>H#Boz}m+_O%7S2iOBmiHflmNPj5SG&aGI3C!`aQpQ$YG zF;k0=?AUZ9zh<6(_L$Ar%sGW&Kkw9MMO$fpHYfgSg@ZDvNQY*1pz0^mquzz(GXT#JNVL(JiiaBo;@TAh^WpHq3X2TeuVgv_r*(J8y1c-&zcE}T1n`Sx{f`w+eLn>)B?)e{;2B? t7)%&pZ_`kqWdgwuwmdz-j-EdP3;>D#lVm#t=oSC~002ovPDHLkV1jiXNdN!< literal 0 HcmV?d00001 diff --git a/lms/static/images/bulk_email/TwitterIcon.png b/lms/static/images/bulk_email/TwitterIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..8b9896a353eb22509e5c253a14792291815ea82d GIT binary patch literal 998 zcmV2Dm06p=nRFK@>?RlhxFG=?Wv;UdH7#KMHZg+3B zchZ2>mF=GS<2`R7BPo@<{orlI;!!m~Pyv73@D>rDgDQBN4)LEQ!8$46lMT3af%|QV z$9Vz^n-PX~zyiY14j74m5ikNqzz7(LfDtePM!*Od0VB21rwi^o6!dc5e%D^R<84B~ zno4;7q~->qiw^Y#vIhcL0F)8RPpxApWPyqx+(+?@0Bhs zY=NWsY7o{`z&Ka(vr}$yOLW_uzm}sFLb*9{1q?=XjU8bIpjEA5HNidZaoNs&9BOsP zcm(GDpc&X>XIT5=TYYt`Z~^S+KO>roSR*Z?uS)zr9k3>7Sp^R2Z5Lo>X32O|@D7f# zgMFs?7ma<&84md+Taz`;SKB5w5!?$Vkd{rJW_x{zOY&{tc& z=lp;xjB)Qwm3aR%pFak!v z2pCBKs|Z6oU~9tA2AI6yd_^eEQm$ectmqWP07L%^ U){vp+yZ`_I07*qoM6N<$f@l`a^8f$< literal 0 HcmV?d00001 diff --git a/lms/static/images/bulk_email/VKontakteIcon.png b/lms/static/images/bulk_email/VKontakteIcon.png new file mode 100644 index 0000000000000000000000000000000000000000..80312af6cfd9a99e5c7a41fd940a73c8712ab443 GIT binary patch literal 2480 zcmb7`YdjMQAIDd6kFqim=5oSxaZQ=a+}T26E(f_BXDp=^N_IAvYMDmJD#|4^5vIr` ztBrGph~rXiWwg_@D3_TmquGx0e4h8ud0za#zu$}B`|m$Dz~5U@%B|-(qrq8k{e|UANgcGeWQF_6 zrvfkz8>bANKAFh|SQZ9OwnhC})NDGDV+A!A&YL{&ZyW^fY{Z<{9cx_Q5Y%hDM-G>}39QMHH zUxIv|?f2^4e@rdB{N_&nM@m<(AknxFF<7kA@8QK&#pvzF!{QC{p4_~9t1h!q$!bEm zA2%~PXiV5U7Xz}-jSkOU?|h$gJz*k$H_QriYHjh#)@JVMC-AE}q7sWj|pU3ymWF*xaT>nF%ebGpN zhOU`)l;u`d*+fl(M6Bmgj3Nze_F?wA@Q_*C&+97K}rf zf0QkpfE?tDOmtd7y&bl7`^p5w+18TuR{v*vWUlKKRnMcSbN4NYw?em+e))hrg|+aw zujJrUzckgnTY4x$8pFm?M37wC`@k-g|S_jqSP>>>UTdHEkXG{$nQTUud3NF)s^OCa3p=?^-Jx%nrx5rbmO3Sx;vT=sDNUDqys>F*EJ>Rd*Cyc2$y1oo*A44;CA< z|DhfR#Hw|n{;|2)hymf1~( zl3h#hONa%M<45tH+CLqx2@W0BH*#La%m{MEA;5^cCuo!eqQG0~TFXr-XPccKjG z4Dv@i%A5W;ProNUFfW{}e#HIS7!0!3b|--#j;)sE3wtW86V_CZ&#f3pzdd7k$m+*b zcJfNh*Md5yrwlW~6!vO%2iiKSmVFNs%&Z0q??J(^ZQ~S^ImSrba0=UD)ZOn3bCie6 zh^7ODtKQXgM>F9u&Xy}m^#Lb?)E}i{Vtm(&nhpGzt0|^ooeQ*i5nqXJaY>}|W{=U} zvw_!dRKZvC%7|1XdJXZ*ipx(%2Kw{nR; z;3*N1ngmNvM-(-(cyT}&LiK0K20tECjq$$&$n zF`z>es};oNWiIVx&T_Uw%SDc&sT3i7GF4#Fmc8hQu3mY8Smzqiy*Ao8Q^2*V*m0Qh zY}1_#PH{Pb%2qW3+Bf;=(rwbt~Ci=Ixh_ q7zE`}Y>a-Cb3SuU4W~#X{Q@n@( literal 0 HcmV?d00001 diff --git a/lms/static/images/bulk_email/edXHeaderImage.jpg b/lms/static/images/bulk_email/edXHeaderImage.jpg new file mode 100644 index 0000000000000000000000000000000000000000..fc5e4d68de92d4f4c06b10cf2a623f4c2c2bf7af GIT binary patch literal 25814 zcmb@s1#}%tt|+=4GsljZnPX;*nVC6e-e!y;W@ct)ika=0DdufvW@i3AGiT=Bzt;S> z-oNi@SMSxWQmLeBRdto5_l5UO0G6nmg((0aDM0sI%dp8!xqoQw|$IZY1bfh;hwly@NH?p;1a5J!DV4`Pa0PqU9*%=sFnK%&{nwVJt`AE*& zx=4sDjQL2^*<=}I?SxItEhIb~Oq4z3RE#{VjJS+R1o(+~-MHOs>}*V&42axptbvZ) zZhR#FV9xz<{}-Bpgy(7By8(oLc~VT^3{luiHV4vot}x6jh&r| zj)<9&iJ5`%qp*KvV&!II=Vs<0`Wr|-dUG%~t^Z{K3g)&>wvOhuc0|I;>_k+u21XXZ zzYx@ay`X>UD`Mhc;bLMe=3r|>^p6qdw)hVchzW9tF)?wlGIIP=b53SvK`uctR*?^5 zViV=~kp7=-jsGX3#_%Bw!(Zm{-^}G-s1F-OKq|MBqe>0<)?m@^I^ljHpifbO%YXm?1o&U@@ect7@o_;zLO?)5!$AK9Fz_(29{>XnkMI}3BO)RpAtIvw z1!(^UFi1#97#Nt(@bI6}kdTo6jsM@idH)SSfdMpr(f|iT0enIM14jXS?*oi}$Oi!q z2KHZT2;dVq1SA*~Gz{zq_M_&%*MWW1K|w-)27Ceo|MZ~-BqS^XG&CgO0}nufAckZT zM1@i`_+sx%0v*dNM2ePM(>dZAj>fw z6C!tCX}t#vq72UwQg+;$$c5kFRxv13NahKasQFD>8x<`$-5qH=^zN5XC5_`GRXmk2 z4r7o}zm6m}*K#|ZFtEl+Px91z_702R+#e(wel>^(2*B)UI=>{ktyw$`O*l^G&D!v~ z^IzNbt=;|EoDEGzTa(zx&TCYXC*9Ir(rr2w%~MS71Eo@>+yeH6oKkOt;U^<0)GVRD z@E5{N6BjnXQ$^Ol9Kll|)IZtJ6|{yDNWL8CiiA^0%xmG4psaauKAYi8Da%EwU-hc9I`jCS`%o7$_B5=YI<` zQlF8KrT5LTLZ@JSL9@vHGcJuYghwSpz>$bqa8PSlByE%c*JNe?McGw2G7CvQZ*c)G z)~Ss%2q$dng1DquhuH&ANBI-K?RU8?*N+o zCGV!r>N17yEl1!JdHGsHQ{fX*XrrszXuC-5_uX8h+|UVOKX-Jt3e(iR^PA_nOmf0( zUvsm4H7+M?ofytEKm0mMqAvsf--3pq0!k1%CFTMXpm3=iKi;-4hIJfL6Zm*hU_L5Y zDh=e|jDmHJU_7fou#!eL0j2UD)6?3k4$Efwt5IzK)=%9XD@QoC5@66|gL1~1J}+rZ z!cyZ_uitRYE9vrd)5|SRRG=v>yQ=uLqgQOz!^0oWo1feAwyILrO~GHjFLjz401;w< zzt~YIH-{shV*Q^Be|_gmuKZqR!l_J}Lo7+8xnWQA9b4zKYC;r^SO892BtDV}@#ozV zZ4MSo3li(>xvY@p4wUgVQpvcj5u^l`*!AO#Yp`!<|I=c)^^=|oVa06{5-fZYjFrq% ze8+szJg<(z858p74(#QtxfK-~%SdtX)din{D zQOuE0FAQ%bk@Aaylz2LQeHFvl9SY%PAJQ*qdGf@y!O@%sVS61fm_;{F{(#Ojghpi} zYD0(C_Rlxf(?{|P}iXkqx6Hk|%hRC|6ySBj~qhiH-=PxioCFuJTBn6A!3If=*l zR+H3`O64JI-Ii-tLNYR*8@+%o)!JN$0pzpfkbAwVs%w7l?E+<7+dXbH+$wfs+SpKL z`KYZ!62|Z1^=ie#iNTm+CEa!3_LbCovEn%*BPC6&jZM}|Q(hI-M4A!3S*j@J?&fY; zai~T#@0klK-vK`qg>yw_c-C885%Vp~mW)t=nn=WfDY~R|h$~Spc#tBQNa+|lYtS&O z_8gK|WWy^I))9Mi7KOkf%HM83XvvOT<*lcwQ`b0PdIsuqug`}2r{XTJIQ8zX%E`>O zFdk-z9{e?;NE*X(Tv28_^=)^Fam9q=(SlzVn*&_g4=)cLZwM_aGb|le6Y*s`wacLh z6|xAR%0fUI6~)Cl7bK;^P(xS7wVyI11z^irh0O}AVy=n*Czfh>1I1k;i@>6BnIi$O z<35>&^;HR#X;#P_b6yJ$>!{}9A}7b$0V-fpTDlk~NGcS{(@(4C4zC4gY|BY#5q1{F zuuwTOs;esKu$InG@z%77>5y<%QFUmEb$6e5Tu{Kvov-vA&{Hnjjoa<;JD9o;lY)ZG z;nR8nsAOo3`!P0~6xOYqJeod7KvY|H+2MgR4Sb@<+hy7wnse7 zkJYRp*ZQK5xkr-!LAb`4&E(5+>yzFR@V3YE7O$G1k0X#V)vY|I`Z#p{a2W29daoc! zN*2?(%-640v%ANthvyKy`V4*dI_C+&Bu%E75~nj7jb6cfo&&wWph!8)YUX2u8&%3V zY`j=`X6Y(B)SnP9>dT!K;Dn4OkuOvMt%NezQxj)J;YCY0(sWe6(|-DDCa`+97@tR^ zrtzdbJsPn}Fqmka7FYb`L`Z7ulr>%pkKpsd{ZgpiTQdUm!i)ny^ zCKt-KZWzpUvRRo%-2UKT-(jRQ>!4C60lFEwFsc|c4})q08@C-Ii~GqwYw$`SQ5qz2f;UBhRooLs;CpyXUuRE?E| z^`3$>tfMY)1z7}WkbaMEF8K+)tgJK{6Iozn@}a=7s{-uFC3f+Rr|^Y8hh~S)@=pvpdBrMfozatlhCDN#2M! z*>5SYs@w*D=9r=dB+=K22&!Uj!EqM6r6L*ktP{Lt!sC-uR*AXQ2K58cyu-|!{w%GQ z(7n|{oU^2lkdXGLAaA8r5_e~QR@7ASW!`eeNa?TU9IYu;-Eiuk2=s#B;9+i37ZZef z6_f(=R%`Cie*|p++daG|3Ye~Tk8YSK-vJz6Wc`QSrISfE)}9Y)z^_Imlon>>(JXrF zQ!anp6ECy{klcl(Q~js40AQHLLe8YoUpeR4q%=I_($&jm^vb0v`(}`VMVkcP2ucVM zrNk`XnWhQ^FDpA{+r3PdT*tTUWHZ|6>Wv3}7nDg2AqAlHspQ821I6TI7L-Nf!>PmE zQg|JRYY^Bv`n5^ZPR4y3Usht+S+)DXNE0ADOF}0v+8Bas8)bH~5^QBVdMD%iHuG#m zBfFt9kCiDg1plnZ8Xd@j&8A$J5(}edHu-6QZQJ718%lG~Z}yCwA>f%mb7R?GZTB6A zS7}S~h`;2hZrWKT+1*B+R-R&%ttxvP1)VRp6=W4*+=eZr)~s+SG@PCmpd;FjjUY-Y z6fIV!^ib1lB2GT2TQl=sB}%remG3U1Xa2@KgovS;(LgJ=MUZr(|MQar;C6kdL=O7J zL9m`s%4AesPFEm~Ljc?_&|b50qbb9?kK^XwZI$KUX3mQWNNmM(Ct!o4kb)&qI*Y?r z6EuxmrALMYznzgpG!QawnUV3O>)_a7Lr0=;lDN6`?A-|b|9OA_W6$|Al7lrDkV58% zOoJMLZ@cM%@mT|0$o%`S`^kg=44g+$QchOx(8uBpfiM0=NiwQtVD^*0gM?U8S4s&3 zR_sU}0qKGpNM*VdivyyRmowbr(OhEm1_L~AZz+8`qP3bSm5-@J`v-8nKt=JIHBt=k z6MX^L_~AQXATKC+<+EjS$4-qnzVk-3{}~$2zz(o-!Xi z^H%6IV>2X?jJ|uv6n@R3Ec2L&5;>SqdU!^}uQnGpcoD#MR$)T-?T(S?70u4}E)qm< zpPb`MH)T@eM^_5^a?w+R^i%$!?j0ajmX{yS7B&-!ncd-KWdGgRP1_6Uc-xUwnttN$KI+48Gf?pi9=2zl3FAU&2~*?t@Mw}l;kD9pXu2e~ zD3TeS7|+XmlwL7TVEIhMK%5{ZS(_dcV9s1(>(7qW_7Pr)V#>z-=*J?DC+j z>bLfynkrYj=5*+m{;AhyLY3V;7mkTy!5v7u4~eZ^(;R4D5E*T{mhO;5=ha*>OqNOh zaAV=1odSTLON*@yYdLAn6tBr&Zfk1BGTLPpt?4rw^p%$$BAeQwyccU(cD~B2owA&k z#;{!Hyr=WzOg8Zpc@d#kWcKNCA^>E$lnEMY-S$35ZPkj)qNvUfh)TyH=XtfAQNa@`IF`_Xbwr|;nvcz(tTr-H2=&uC{RGO_7Lp{B`yRDEQue|Da{hAxmwQZHO^-Ic zdpc_~Rv3$!DIj-HUF6n4brKWy;kf-eJjRGc^jT<#hqDeO9@KOPJIPST@BHH=uUg^ zBwZ>~jbawVdK10zs^hFq#d8*HxjcBH1zfje`wW*Ny!iGe-T_P<$8XU~?|@-r9|0}z ztmWtF8&^F2a#y_*-sv}@cR=HE?zRr;>{|k3D<2Uk(T^-_R4a`GlT7!b|X#xj=}aYuOtyUJW(?On!l}= zSz}|BZiJp}I`FK`#e^+G*(~!Nu*cEiGxe#MJo`oPgD|Jt%#&|2C4qFnFf4Sl8Sk7}*3nPQsD zYW7Eb_D-&YPd_C$EMdPCMn)x&No&2h>mTG&Np_TINFySaNa3`AP=vNLz8H4$Qb~Q8 z1?G6ol1)na+X;3hqI^x6mCuCzV-b~PBmTvxgiTEomhaPG+0Kt@p^+kRKS^Dip<^jH zL=RutPQttdhN65WspKubSu_T$nxr^pxFjEEs_>G|?oezjr+D&?0Fc4ZH1!<^%Y@M?%}vGYfv%&@%O>@T-! z$z)FbE+Sz&LN%2=@&mR9{jc(U&$Q9pyk>#}>3Vdl zNxs`%4TN3BU#z`g*%=IhntaDLr(dk%$vvk|<3r=2&io5Zh)~6C;1TINH>GWTf%yi~ z^Lk>AA@(#$MkI(XTE*W zqQUa&Oq2ZxpP zXwYVEOjF+&kjAqt5(MMP@6 z$S_TmaBxV6qJi&tp_tWk5j~pXK*}^%%nB!OnU-mo>>i~xXSeZ-OrUYZ@)ED5V#dw` zL0xr>r``X@iV4W!3ZC$}V-|SP(N8T?M7UPf$Srfde@l;i<9Unp8V`J^y&{C)LjE0n zj*|_12OM*Dbqb_)$guF5ZeJ{R%?n-${iH%R@cdXNF4xAM2j94=!DrtAt*KAhLT@AM zG0o@ecMRU|fNt3rWesbe=HPSE@{)IejpM!MmewoUOUPf2I;3~NYwBCE%DF996b2S9vCN7+=;B;koYimC*| z6OUAxGG;zP9M&spOyWJVG*79PF9g3?Q?yD8605NIt&IVDQPMIWC?%@YXHfvOcs<3T z&kJB854*_}_bvII7&H9UCzRmrSp~;n3}FO(pigWb4~!zG-OfEW1+|H(D>DV8GV87g zC$Gq=vBU4mhJ}xkzZFtRAzmW*b0Pc)wrrYrEWt=-$kB9HXjjIve6$qojj@o#23Ntd z;U?in(P8H25Rs5k2Rd}Y7r$F>k_UX3N@0Slnsr*@R+DA=Vx@WG`K1AnEM4jpW-;Z> zX9q_1d$K*L1yWzt7%86?!m6~wU3PUd8K#@hn8?P#TYbYi?YSi{qFZ3?fR5<)sre|F zl(D8`gPp$U79Y*M@8iq$F5HQn zbt1mK3UNUt<6%q8%BNJ;g9_4gOP1IxubX(f8!L+)QiORw4@c5r%Y3C&TGZ57I^j>t z_ZQ;WI-ZDC487VW4l=VD{o|3^zis)MxtW@RW{_V=WlPY*q(jT z9<6a2xy<8ZX5dOMBHdu?zO2}n4hPK#3u;t5$^5nqhTv4q%t9=K6la&{~k#`JsHihyN zH(6^uEJp!_O*OL8g^urV6Z@@W#~f@7o#bX$nj+H-Am}c)W4pBu47A;~3bJB3XcFZT}r^e+D4G!rw zlS`^xP<03Ipaz$*K&b{_8#{yV_LM97CY2YdBS zHow^(ir_RNHN`i;k7EW`q+MQ}bGSBpPD^9_H?<<(4aO!@15inw*s^Bwga#Y=8aY3HIn;x?9NYSs|yh z--9=pE0aJ3ip^E$)*m(5tjak!M5B3jeC*)sJo@s1G^iWXkKfhIHg-=_<4+$i>akPC zjnXhNXnI4ct$;gb=v1vm-Zn0v%CP~dNR*>JGowe&)xfUoTeFtN2(>lHWoV}Uh~vI| zD`3rNpZk3FF}$PIW75verA}w>oTtFo%=2B{0Ot$u`y~$!>!k~JYpTZ6TF8N(02DOp zTeZEtwWljr`jMRm$=SM9tvYynMZY}ZmS(r*AhHFnn$Q~N&x5S8-HG)?vj!a46m=rw zC>c7&WvTn;87Q6|r*y(K*6H*NPyGo^?n3bo<`YZXx63E5{OCvA!mIj8En^3I@%^{E zwadc2O+bFKuA{a`Jo*l%ZOC4P78dd7s7SJ_UlQAjE~ZA`0R$A~)MRB>U$I(PlfGtU zoe;*gAuZOD-s_?L2)d59ppc|j1G6F3b|f@^n2%h)O^(+WH{n>(jp-ogP`mCn7gBE^5~3Bhj#p z2lhK=3c=Ro61g4lc9%fW%U1AbiRHw$)o=hbuCH>9i2M*+yu-lAJ8>6|H-(fwOK|^$ zA-SOwxA1_Wqbn+y63a^saT0Bk_LF2Kro+Wtz*yzF9tIZ z8YcD!{h7+HP&)=fxa;Li8H5B-=jGIN^?PJUI&bm$M1s=MH&o!zieS`BiQ%BnSw2^PJK%>?Gnl?K38*VCSAH>lp>fGP@1XMZz&kumjW3e#q_0 z23jpJ50#ZbU6buP%}@L@C`FG+_s24ose)gyw9BaCmT-OiNl(I&;ethz5bEMIzrPAt zjQuF03a|MFi^HEG>y*@b$rZF$h@I+WQ2C&eP3v$N>b^wC$rE08{druV;%=89qj@GC zWF(-eIyQ6<%KDT1h&V-|xu(o`y-YqR-wi8>ev(?}UxpAm5Bs#=4 z<>R;@DGC`b!&vOV;&gm+ixqx|N)a!B%C$ZjT+AVODi~ev&&sV1a{PBt7^MlZ7uvEn z-F~H(w7+{$>DpMPTM^BXY;t+3m7DbdbhIYqH`-&bHOBzk%*QppVOr@9TX%O|vA5L3B&L%{y8MF@K9}{P(+V0P z6n%DnM}1Q>KDqw%zVtNwQiV+4PFo(8a{k3;p z1DeQjR|IBK2~ltvge~6Au5wrB7@%JBZVoVmha%E@C6wZeZ>70f86SH-$CbJr=Dppq zSW5w-v^bcuKSf6p{6tFum^|{XuI$4^@m8zML^WOFn#TMftI{WGAxozW!pvGA z57K^}D?2AP7pG%)bY=uA{UvjW)2@SCbY5NnXgiTyc4k-n`NxNcD8hCNhgosf+u@}~ zZ?6ZqwfJ$8n7Bl}7V+0fNlreWPmYFr<|8b1W4_pX6+hrT*FSg%fQ0)RyHW|C>90ro z(8)DE|6cI#!?`otw^49_-5W^b&2Tj@4)yOR!DX&Z_k+18Nl;dC{{q`wKa($B@uuQA z6x?yw)nmq7Y;Qn?E!jJ#n3E`;y@VI(=3PFMwdSnXJ6iYal_k&Fq^(KPo6Pa_l#buT z^^MZdy2yTp{yG89=Aqyc2<*L6zq+Vsb?MPgPk2pC=QvuG}7Et z%eG&ku()Q{8D^~lYDWGTb=ig~=~pDZI-W0f0XjHaN2;^%;au z)fOYRHDaQ7u?P@W+9BQP>p-_%aL7mu*#_s{_y=n^>QxMy&$My-yF6a*y>lOKOV~+# z_erb=Tch!u7*oYu?&Cs1Dsw7jyLoWEY#LN}V~{0m*Nl)O({ve~*4(oSMFVbp@zE6# zJo1Fl6ixyYANx!S_liQhShyIT#$tZ6D!*rmUR0K1k;*jBP~@;%v@?9s^>68r9vWWX zz_Y;d{BW?MQ=IcN8;?!DCn^lXgH-Pw-X%^!&jEeGHE56T$rZXPGT5Ez~%8z375Rxap=cW7v zR!(`9I|J*I@W|+Lpgd3amh(&n&d{8$np7ng=1`^aIqPfC{spm~V2SnQZy$#=!lG#P zC(MZDiAkZM-)Gjr;=OO8h%*-jFd+AUnc!20QYk#?CIY(Yws9Hv+DsxiO>{Mj&zf3t z*%2kLSykwj^@pLTS=^zy@C9dy?)MKtf zIIZiNyV22`L)R_#TzSrKTxGB7A5NY*{aS~L_jQJu)HrrS0frd~r7qgbI`5i4QbqYu zL2Y;G&t&_;X>?%24#_ZVAKa?_^5&t*Y!&Z8$R#FtitV!077GOhY=ZXNY!a{x+E1o(JOjFdL?qW!*(T&+<+RiknO%&q+(W){^=vL*k~!~@43lz zavjsn#_isW;=Jym4JV?xLzQHbV)(jDLF?!7|$0tr0Zq9LY88?WYRU)Y(-IXIA0e&lZ&@EF|{k;Vpx(H9xAqKvs~MMDzVljkaU@vu=Ql#c zos7&{o6hT97m}8V>OH5^2$->UH)LjT4yk#<*h@sCB8!sH%}d1LLR*aOh9LZe2!rws z4eIbJ|FvWBBfh|KiHJ>g8>rB-pPji*CxCG6n$?Z`*p^i}kHK_{^$rM9;390enJ+u& zkgIRth(75mxgeM zU5H%Zl%{a9ab>A-*75I0k={NBmw^oS2S+}j{zc$Eqeap z=;*wX^f%1mJ>#35ULbchsSxsLYF>4041 z+fW>Q=%kDiP1x_a!o5G6_H(J^mBCDRi>ICrWT9Gtst;wixZo$h=5jjRP*$X|HK#nX zUl_YSd-5f)skjNG3NSeIkp8aj)e7_RUIW} z!8Oi35D31hz*al{$bW={jjp{MC?YXW-67H_!Tfc|FSIfS8)rd`j1U)de{&_)L8@Y~ zyRW1vNynlWn>tGNtFE|r#?Zrw`x+f8CSOKbHlhv|Aj@@x)T%ums6t+p5)H|i-PTd* zBO8^>4^>cTm6BqjSBFde1z!S4uq^|dVOI@Kx2K>&4R2Ku{`*%E1i27n>S()l0OuAB zBb%K{DHb*kpHT})*LENUXNodqU8gLM3oY(VTq=y@MjlPjolMPqiP_R4oVl(r$NW&d^Qr$IiK;C@OTG&E0w+C zh#VZMOhSy|@sHU+E>ojje?ICk8Jg8eh7gJ;K>Zp-659+$8+%@J2!2>H7^&!G8*W~n zj@+!-nxDA&ldux(W~+Jn9z*$@%>)KnJ^Y+??aHla^f|BM5T|0A=uj;NImB0MIXqXK zCH~x8(khEPY+Vi-{ZSRi)Y8l9dQ-FBQ@a|qphxq>%?+fml@K{m_rugWqr!q|R@-It zh|_XiodDuZon9W0x5+El9%utY9=cfHBv#nJySTgp1g|Y#1M3UZT(=E5SXaf1Zm^x# z+HlqNi_3Szb?HXE3mSh0KBv{%*w@6Pu@GvpjHw{kS`^1-=!Hx-qw!RG-aXOnLx0jq zG%*g+DSD9h-t{bdqADBzkzNoxfgPo?R`;`FUAsWT3q2uG^QxFnu3&-0ncGu}=IEepn0j1&=T{KQ)oZrqu9W|cu-l`-)r|9Fe8(F!dKCL? zq}ht&P;(^gl!~Lv4wYwA^=##djiuZ0Gi~<559zv2{kv+zjVeOh4KgJS*g9jdD{|8G zA<@@f!6DV?QI1u4`?jp|xN0-V9kB`FpMkA5xO(@&6zlB(_?hjJ4Q4M^x0khm+EMI{-kT^@W7Ryr0Nmn{=(~HfcL3IUAFFZx z9J2f7O@hWBsZ?>NG@pVmK+HCBTPXEmFXEwa> z1}o#b6V`gop0>&>sc#nPE#2H@XE>il-OXGQbtN-Ab?Oc>RoA*t=5S0Tz60p5ejK|Z za+){du_I=4&XlKwBA;>yfax=o-aj8a9IhWEw) zseS|r;?YfDPxEtnqCr#>>JOF?U0{NIimbL;TAB?x&FN(wdi;zD`edAIAjmNca5e%Nk(#y8<# zDqC+lXv_=sZ;1cUNh7lDZTptvioTSH3?0UN9+(4pTKb408c>8F#W?$!)HHqO_I;wU ze8h*_E)qc)vP<5+?KT>n7Rb7#?iAg2&KuwSQy7g?oQ=5mL>_ z6lY&}(yME^nMaPUnv9r(ueb1O`bqec4{7x5GYy+9#+};9)1{;#Q-`ZHt_*UxU&(k* zDsCvog(wKwMvJLbI+@D8-~5thl+&@f%qtz}$Ac(ItOqY{&-%@$`?mjY@}pRw z1FZ#Zy{p;%kJRl6DzCcfY?@97&zeV{#fNm-BKwt{_UDY6?NVf(OR(1do^Yzt(oOCO z!F5{>dhe=?{AgsRHlIKDqxy?0KKTsS38hzE61H87`_JHSW}RAZa`6$H5Rk8B?}{IpPY9^t?SLwbDtll8S{p< zzVK#fFUPsb@auwJV*pz@g59j|7I)GN!!Y>)$ZMm+z;e7iTWC2ED$qEU{-Y;R^c{dq zvV$`8WLCEJtXf$%bi8z$_?$){FF<_jXgm_lW4e4z0uYalLiH1`vvu*gjC>Im zlj1W_p*KiXlx~45qyOUsKq5495&wD9;jO8!}cKMGRzP4m+Ut09_ zy+D+oJPW5($If~B7Y@vE7aYqBRuf2x<8R{p5a3%>=1GVlUySIjZHQ=DbV+Q(2G;WuzZS_MrNYL}$&^N{ zBZS^t_uL)CSlDoa;?Y3p%x*6S`j2fX8DmCa>j>e+lBE6?ISrEfnySi6jD|6|g7Kw3 zsvZ??$GKg|Ca_96MhiTYN+S^(x#nRy@O~?{XQk9QtS|XK0qs&lb3*`RH9qdjKT*!) z?K<|OvAz*&xquZWx)VT?e#iwI#6y_BEZ$kw6Sn4LCQK_uQUiRcC~LFg<1iBs_Gji7 zdS^qyPqP%v8q73ey#yvs&}BBl@GH*tq&>3~<9fCE8%CsGrjx*6zd~#BadZh2PMXWM zHORHhjGKU@G|y+Fa*D-6_ryC>Vlo-G#E>uQ87gYPtO#iTM02OHXc?)-;~WGDNs!L) ziVhjv%+@eyGEk%Bi$q4=cK9~jl=dwvTl;f=i}*@?AfPrRt4lFv#QyMkHyZnNo*KG$ zhRrye#&OFqQ2;*#WQX}=X+<(}6Dyq<#$Xf`jG;`kjv z@hnER$owOmIEnfSKLWL*5)bRCJ?*09W!9XAwA#FWpna6{>PPtA=ZW<1&Wk+Ku;VOe z6%4EeMAF$uvguvDACxgV%8Fs`jR? zKwA5}Kl@5v*L5AEOf3~;E1B}C)T;4!x3EM&9ooDBgb|sB&$XjAo`O|L5w{qe_OH9? zSq&;RMvCosF9u|h(X-7*{GW>a6}xt}pWGgNn`24!hZ_3TBvSqmMcNeO6oRK3LFl_^ z$M8Z@DgL=-bHA?tJBEjxwazR^$i(vt#pu<(8Q=3tC#>{YUh-{Z{v9w*_>!-ld-Km= zAKdHJN1hb63;P39R}W^H&Yk9Pr~L0kF1&X@rGU8?+mO2y^K}D0(#~&2Izub?xkg9+ zY{?hfl&8R}7xa^;H3S$^aDt6@KsfrGw=L)+Cg1Wramdb%_mLnD3H_s8@y`2U#)tBO zdJxxAA@HTB5X$YS!W5WIBe1-h{bJ_{jea zQeywvM?BI47(PQQU8mpLSvT+^I_EUk-xPf+P~Luh>XF|Y?-E%o;JH%MSlLL*4pa`= z620E@1Z-K+&Km)dl$<1%ZJRQW!#=BiBnABV;dJV?GU5_D|1xkmE%T@o(YdRE-iVdh;K%(+AL0|E%Z)7Vgkc)-n|3_Q0@Dg zhiNlykkRN9^|$W;q}xnHjpkSNlTO{>{4?5Nf)j>sm^qBHVBz6WRUUADf++wX-z-2R zsu5|I6k;aLQ|3( z#h2};i}WEMC>_lJg1Im>#yH0D+?(@ML7 z?TxV;qc;D8Nu><>P7=&TGDN;<){dgeiN{oa=;rK&ME9sLh)mI)E3 zB^}bi@x+ZoCShFpLou|HhuqS@f-b~wh43X5OBNq6x7eP5^YGT-;R?p*BLwz03iQ^5imR3=3fnv)@!`K+3|xa3oAU{&#Wp=groFrwsP%AX(wXo6fdMdP9) z5nIt#V~d`3(Pm!DBf1&oq0o3dmxEhh{$6!Ox+-R}g~EhOii-HCEXumXXxFa8i=Sj=C#>!j3=O=0y}Gb-3P+zpT$1^M`*i z#Y1fN-Pg*G+8O(9!0)n2K)@Wg$V~GM8C7FJjX8+&i*q8fh%H~+r|sTF&KAOe zxXVHtQkOsB`mqFFW?YPvTCxRO2Qm zXIE|R#&dNY&bI&Ku5TIh9RWJO==jpdB7tnezv#UBG9!C#(wpHSHHv{i~8^Croe+NALFirUpP((?;UO5qO!~UU#iZp$@gi0 z1n-)en4M@x5fIvbr_forqUWK5fr&3j*nL(jgc~gbF6Fl{@bE^hw$+lN*%YHO$H6dC z1biGTNQZXc%I>=uc<9<(KO*Bc=kN~MCcWtduqqSq-;&(MBRJiZrFOK|p6#JER^q(X zeyCe#LsJ6#v#qII9!Z2F4;L7UZ36(jkSZfe4$khix-cK3#-%<%a1abU-90_f!F!$Z zO@v1GMOLKjE&U0;t`uUHLDP@;{|;0w@Y}wOM%S}%*PSYXuV|KdNWBAAx4E&A&!Ru} zfyYi+Y3CCv9U)IYkOSvET9=lmko%JJo0_lk+lEfbBJ{+`GgDULoXO(#7HJ66*_X>% z@NDVA(WLami51aw;$(kb^{VU~dk16~RsOmn6SxbRKZ)~RiY`!( zKIUWT^(MnCyFPhFp7bU-=4bW9jh@#OaCgm&>I6Fyx`8s6^aBH3%^&1{0#ia<;2`l~ zQ>lr+wDJi!KF<+hSLXnxOKp^+SlKso5Q++x;SNQ>7=Q2Jh?4t7$8d3&v+to$ug7ml z79<)(fyzS_$IlKm)`!YUlEAJ~eg!jht0GImyNke3YQ@No2`3h;r^?Cz$4ya(Pd65q z4Qp_StcAW+v44z=>xd>%Nllmy#YmQDzZFOAjXH+A>QSZkR2foEW+AdOBNV*l@Evtv zjSXxbYt@XSjKef1WjRM;FV|T+$7>$(jWRUwp zSVG8@NSb>{Ju|Ib0jmsbVx@Nh0c8q0D@CQj9u#LDOyZOrH}3C^!;-D9$^~Dw(&aHz zmOZ{~zu|S()Yh_f8@hdcOG~JsE5}w(dAy+v&A$ak6)~zG>t}a{QCQR;3ueo)`s1Ld zoqu5x8l45%4E$bEO^`nFNG~!>21O>%I=NX3-%vx8z7Cg8tJYur33!MVpH|K-f=F4U zr|*PtSB+!4)Mkoe{a+V}V1CNsDHVZoBt&+UuskEomOd&{7+Oco1qV zioFrfkOR!*4Q=Ydc}c1y6x222TCa^+2&f^{&==Mgqi}3C*RJnhZH1O`B{zbIVbB)E zb8JzX*nVsq&SCH+91G3sg%?6a%5?;x2MS9EAH+-NKT7?HHpIqQkMv_Yb&hBs~pg z!hzNs=>&wZ){D!etqism#PVIzr*3!1fjI#!uR{%rds@v%!v0?hyC2ez9u)FZ$?b4w zei_*J5qwIZ+Vfy;AHMJTJJoEjQU7Zus0ld9?D86&jpPI;aYLIMBV=vD?g4_sE^dnxbRoC} z4GHePxF*4Mu>?&>2rRHzaCZ$5G`QySKlj}G^_;5t*415IQ!_PhzwyM^v<>W;Z{}{GQ}>gV_hKE3EgRn;Z&GHG^Qqi{U#9Ob?5zPVd2 zoNkX;yb(Dt=YPS<2n@v6V1Hby`=)cG8eU^?&X|2Nv+4oZVfXfss+F~iyb|#AHxhTT zulti~Gr*oe)mgCLmH%ebWGN$kcys?1y@&fw8FsmqTH}3New)A3>t%nLz4FC%94@;z zCj%@SA>nd-mObgcp9g$n=e}gHP?VnieP2-={OaAr@WF=o@=tmZ8^0f=fE7g@s^nYq zUKhWV$lTRS?aM>=^*FXg2}w)U&Ex|zLs@LPzT&8Ai=o^&ECbj*l!k6QZGi~tX?blJSlVh-6*Q5DmeCu_+@58_jbi4b0iiy`?&p0`ce8ywma6p*7rLMPvtL24@vA^vPZcqC8y{2 zF-FdQd;XHqe1kZ(zQ4Eiu=XF?($`Tj;|4idhTcI`_dtROnQ9CNE}06?e<2z~Mqc__ zGPyzi?9aGW#!F^CK7X{c^KTg+&)Fk>h{o7a3c>|qRRcJFueo$z$QE2)30^Y3>fh_6 zuL1kJ06BrPGaiGxSG(3Tn|5z+GGr$l90|FnZagckH9irRxj(a}TjJ9-amsKm-k170 znN{GZPHHzBD0)4r2b5COZT6(oKMbzeSJu(fjtO0J*5ZlXwztxg8ZX8y!=uaIDus?s zi!uujM4MDMy5}sR+XS7T|5@+O)39ej6_kdG5&;`Ik$p%?pcqbhq?+OD2s@{1zl5=b zI&r{0FD%36y_)$c-AN5s+9JZ_9&<|kLTU_$Sah>TUrtOr?X%CCjTf16Jg{iy`fAsg z$t>afwC4Qsw@*+9*#0eCXvrt<44R0q;ae0%Uk^9Uqm`VH%k%AO^58gDZW z#RJP}99UpKEa0AMv6EpqE25B6hZ!+u?V9wKa_5Ww?8L`{!Ieg0@EbQS)7WBkgn`M?!z*A~zVP*4AGQ&T7LK8j*IqPY`9KB}(G_#n|l!*8fOLU#5 zScc<;$pUUSlagCmAY;>&s!cMJiWSGC{lav}AcTRUjH1Xutd8L$fr)RxUG-e%DJ^Ab zZ%`0I;7n}8Rgm>)uax;!#$u8@~v=iB&7q@ql2>(!i z+V{-cJZ`nLC$xR`96Bxomm04=nkRV%+zf{^7^>B z>a~P{;oRM8=?;HD_a>ZJ#%^k8DE&s#4ybl^0vvJPR(_-MLW(~6@Y}&dYZzni;u@Hc ziDH7misjJuD8!}9lQK_RyLRtQQnRwN?#{No`ov~GOPGvO8h*vPuKE> z9)~MaG*NY&313V@{`54VyZ9N;I(fGnMXAM~$shw{(Ez*tDwDJnh(;s05LdU;rAmY< z@x!+`&8GbWUr>>nxYAGZ#!u_PVs_#~!$2@^`3xPI4t|E==i8F7h!mewRkSXuoqDS1)}0*1q>& z6!KFeq=%;?!;{v;G-Dr6CJDnwCgtqwRI7JvqQTCHiqA4=F9!!*T1LFCY@yDJUgRl3Kqtcc#s(gib z_rY6T=ZfEg$w%(dD!uA(d;h#w?OW(E7D~Ug>nlHAdKr=b_iw-@x7V%sb&NK#BIe#; z#3AOL)feJcgl!rWyhZJ<{>D3tFJqd+M&4oTRE)V-9v{>2xX*skD_+RpkC#4p{%?Kj z*o43RmMoK%n`si$A@HGaVcB)Wok98IsAVBrlT3PgU`I|Br4-eZ#Za>Gcy&H=g1V7T zLE2}V2an%6PpOK%ZV1*1xjuGy*!+hEoDJW9D(*EOG>Ss(Jy7OXS9jD;J?^#gNA9}! zDVsf4@ad_LmiD{aQ@Q_4cR4p>{VVmEk_{UTL-o_*ye^Ob@xhFm_6}Qm9f0YvGcY9f zAKLvtwC#Ur>5KQwCtv@eF+DJ><(+YUMz3nx4YVwZ#5thvp2Z!8Qqk;XBfj%tq={}; zWTGu-uyUi}fUfqWPQ=Hi#!{7@889~}N)cV(gTt=Inl&?33He*8_kSD$=@=;WGtD>dAPP^_jSiVb zr*m99`w$ii76BsN8{KQve;Xi#JEZG)!tyIpQv@#R4I@k=)j?Va^d)?fKqGNJP=|Pq z$arJJO>JzcUVMQ`%#6C@cAs}4bR%+kMZSyJ0SstkG_ovH>7(i^e?*B+L*rv=aHK}~ zVQwjtpbIjA`=W{7fNO{uhDJ$|P08(}BBOefi@4 zvaHP|o?eUCUi6`CmS1^q--RQZDPavfJE}hCXO>Xy%GXR(EzNx$0ft#B7APZ{&9bKm z5;Q@Hvzk)FVMyWYZ|+g%$)CKc%JBDD(m56Iz}+DZmrsJ?FexiWo%{|!rApD4a-J0= z*ri2dxOticZmLaV1o8Z}PJO=#dzh}j0@tPpQ|u{hP<%~(_eK%KpbcFbz9c%J+CHq( zE8=k|Yu_SMkJoLDRb23MSZR8oMp-jR*A7ZjwU7M8oYL zQqjvZPTd00yS$5QbA83I-Zja;tLfv4@*E5MJ|IM zKK*}mhCR9T$8kjkR{ zw;sLHNgaCa_o#npi=!)^Nq2WL_apN#o0L|L!$bk66LzD?%<)^s$1Z2Cw-2qo^z=2f zjv+@@X4PVz0kL1b#%L`F#A>Hj@L(x@%20R=b)J?F*5oJYs#sgWu{I>lHlZdSrE5=V++yLT9iRxcs$Iyx?UIqlIQ0JTg@~ z37Fu|X=sgCPdWW`e_g0T!_P;4^KWo5_D)@79O3uUd^ zZ|7)|G9GBEOiti6luT^q)7$Vh^U@kCMQ3?ldo^oh4PSh!K#!KnGqUl|J$u3{@)QA< zNXu0f_2IlEVft+~+E&Bcu?Hh8ugmymZ+0OaqFV@6BAS90FXZG*j5{r-=UaR(NaHr% z_F91%ZDzf{(3Cf>^0YU*_SPV+NN2fKt1iIBIf@;bq{NrXhRkv*a?k0VcMDp)gtUa$ zWzxzhgERV>Z9DS&Y0~R9dUXI&pG|P2XW$SH`{^!KxUcwjkXL1ydJ8y7Xw%edlQd^b zJni`|`po6&rC}v7dXlgYf%f{Hp9|TRug?+#-p0!>)4y3zuO;vl;Nma<6LbcjEkyfp z?VV4JqKRJWma)6YMXxNst7hy^6CXj=DjlOpBFQ&3m4BaCN1D=>FI}3#UI&Dmpcc-v z+%O#l?9{DMQ{Zl!OkxL`3}33O5jKCJR1Z%ufTt7RnO&nG%C?7=N4QY_wLApN`xI-c zGQ*pMz0hh5^_hf#Hy+OjCV`TGJeAfJEji7umV>S}`=u z^+(sW{P7?U#+PvsA1R5g_}Q25blx(m&oD@a$fb38IPm|>yn#*6-z2)+?K(W4+XMqJ zk0&^K7(ssNyG~s#XOG3gHp?>eCb%T1W$>zhQT95(PM(V&1lJ}=t#mcuA`K5z(*)a2 z*irS|*O@BGkwh*U&CjV~SYvc?${SR2#kpwU<(WRYsyfjC(w-c|25d;?ojlT$z*uu6 z-*P0eL5(7foGsA}%dS6{H5KS#B;wjQbax*O_f=`C`U`xx&=0@>yQ!#)r1M&%%~`C} zI?UUVo#C3*@EMFxZ|ms)uM%u$Vyabr;^L5XQrK}r=XEl8R@i!;A*cdQM(JJ@<{sYzX-we z_}l;;1S1sxWV=YavW={CJ|f9DMo6za1tdl&iuvY)Yk{#rf_DK%Q%Ca<4zPOAOkWeB z3Ue)p&X&LdTPY}(0xpDEb?U%jCsl)SaDC{6Qk&c9b{AQI+J3-l=pL>Xn8X=W#Uo7L zRhBPDEkMUUS?UYSnzLksA1DXT)7O zx>7_t^D+e88*QIUY__6MOOkR*eni@H;9~HWFX(p2tDe-;`Bbwc^#?M}l#Lw6G8GSt zWH8{4$Sle?HFY3)0)1i(rKbNR$4NEgzo0SBS=_%!q70*w9apEE!P@Q+R7t)M9w;eO zn}!-|v4qBWk%=8>upj758Yoi0sWjt$1Avf;K~Ym?%Z#f+v?@L2eS0cxVluVq4tq)o ztX|rE+jj&EJkv$o6?62-4)cq1-jBq1zf>VOxKd3;zu)vMLu(w`z;UI&kLZ(sr{frD zgH01ECb4!)k~4lno6_B+lI=b8?Hshy!)Vk>^`q@}cp;P~$O&Viu9**HzcifH+BuW{ zK<)L@+tvzV@!EQ+F_sFh?`mGOG(899;jJ<%XW~3%ut>%JN-oz|`NE0;DRTnZ=hhkZ zuVc*`+K$Ih<>WMWG5g&0!m>m~UBC49d`Db>Nr&%vkmQ?v&z=-3T1XQzvJju~BVC8E z2d3v@qjiLPmb~0A21Q?q8GI9`<7U$>hYzf_FGa21E0U$Q6=PveZolnNC4-BR8X<`S zr*ii>kWaNRtuO1i@3xM`|LT1~muHU0#MP4r^_5{#eG+uU(nVuUs+Z@6u;J1@nuR(n zds~Rr@g08>rj4aywUnuKgnSI!ZluZ-JFCIOFDY}ybFXUxr^uCu_JF71z-m{rwbfy- zPW1o`kZK=LU(J+Cm%ZU3f8nCc3kG!&ElObTF#8!Q{tBAa$b22dGd6-BJK~qP`8|F8@;I#GyA7`pa*w{knvmInlnmQ?PBER^d@I0YlE) z%}6^r$^|Mp(S9!YDY0Mh{5MKX;FMcVw>3`2Nt@k#P5FWvllSLD57U$RG!koL&umeI z3*X>5uNLtx0c{V5klKcJjiu?KI#S>Lft0*})H?|^mXU&)Iw% z+tk9tjJn?+WP4G^j$?T$s#`00MiC1LC7+OShPvePZwvEmXKZ1@Gws7y1AaybUE&u~ zup;XmTEDuFZI9X~6w^$x*JODaT^_HEM98@HG0avBiTP;v z*?QyEw$M)zta19?gnC|^n`Xsj1-jrO1G|4{ty1Ci%GZLXC?=EiqneTzRGzk99R8u1 zSoXi-+3n58@&a_DB}}|DnEri|@Sf5SmYbqpMmwl#h`&TkUBmhelA2)s?6;BQs2)iC zE4GxO8N24WeIV=Ne{<0PQx+5r=p2KKkn}COm^E`=sJJ5`hDXKsa~R8eWfCq(L-$sp z*%jvKVCbN^MtZO%Q&y;&RrUQVt<8(#Q8}fGxo-(8 z9<~)dMaK-H9~A z&;T@@{pyxqvE5v4-3zBe{T3mCoQJX%yVasTeNiiOGqeQEqaG<`kb59U$J-Dh<&9T<6Ua_*83bg%tWT-xQcy2CNL{&oAqJ$x0$09 z7@Nz+YB>YtrlX9a0ee83@CzmspP8-o0_cmeW$8nxm~VJ@ix>+;ME?C=?+7pI=Sit-Drx84y|9ji#sDZ^B{k7X0W zbSwF|QkIY4MWZ-E4=$M+DON6TwCKKtH%wZVG`i7-+Bu(h`ab(AWnqt*SX|!-B1)hQ zsF8_sA+`%@kTHhpg(eKSjP2@HS(S#Wwtd6?&e37$f*%D{p@?)s#I|j|&8c29(vGYc z*x$qa6Gd0lsISr&leABP38#`s+D_+q`s0KSp;UsqHy&uTt@!@=sI>BRzw+TYS`w3D*=RC zGIn})E{s4TNzn5I5JeiW-In-QTF)dsd4DNseW6*VimzVkhLag~GI-yfLEN2VDPa0b z=8VEXk8CauirNsfyDo7NR3{Z03<7|(wD8*rb&@-z8qR7!h@{c5fCyS&89pP;ky?mH zYw4Pz)+lxe=DMzxm_lPKKfh$u20y}c)2vgoxbjpbXiYv1>t2MXCB7#n)2MvUkEooL zWUb3^Y&ZH!);12B*BnJCyDYs}tUT#oGGi;mf`D+_6k4Yip&JD2tGG*ac_}W*K|&3eehJR2tr~ zfJzr<^pn9$SH??T%f}$Ps8R=T=o_?sR)OW5KAq0JyJ_25hSl@wpIi1snU*jFtc_~S z#>naaH*E8yF#J!Kpqo6XlxRQ^C#5M=N9eGM8Fl+x8;bFBHl`yoN8%55a6Bf*EcGVh zhZck;yW+T`-WK~lg{On37n6A`8nmSK?S%oFvBG{ZiI3xYok!Q7u9>>L`06nQVUe$z z_4@R!hBZ|-*Pf(V>RLPrJEH5XmQQcij}R=D*leo#Ro-t$m5_Gmo53GG z1=8m;O&TYwdI;s>u&y`#f+dScp6Aw?ygVOESg4>|Gsd@-mA4ou4U14$e4E+^>hj#R zDgR<>%NRhieu3i-&FM{+uDU38Sq2t$reNS^zM6hX8oPJ+W^wNdivW z?k`H4gfz%hy@!WmQ^p05c0uH2-x19dj}%FGg(}bOY1Di$dA;xz^jG{E%9(^Lg1x0> zX7A=61(rxdK24A3yE+7?0*uup)@C|QNl;09zCzn>V1Xec5g7Y<*L5)4Nfj6BMm8y_ z7Ho(qlE!>5(7p7f`PfK>7N4ZhPTz84DPSs1d+^?PJ*6D`;ty^`N^_QG)$t?CTG)s{U7ZxPF0u6#_&mVr#rB3*mE7~wq)e{C zrbe_XsX6oLOw-A*=0YfP7(c#Z*3W0#Q~o}@eaCOH^8CHrRghJs*~)EE9lPp5<89K^ zW=&T_&|URuQPM)H_&A;W;Futs@6odW`)1z_;R~Ke-pszQ6?rak(R=Um$S_se&P>d4 z|8m+Mu91`~_!c>vNCqYeVDjLR@)9wt2@6V20!8=3izTkpkSSi~(+pJhY3Y}q{DSMK z3+yKP`fSD4{TfvbxT&gzX{(!_L-SI(DbWE~3W2KCAgu$tYHAcSO9_KgV>8_(~NPKI`$Hy{rEa%SAPYI Date: Tue, 27 Aug 2013 10:22:25 -0700 Subject: [PATCH 230/244] Bulk email - final tweaks and cleanup --- .../fixtures/course_email_template.json | 2 +- lms/djangoapps/bulk_email/tasks.py | 24 +++--- .../bulk_email/tests/test_err_handling.py | 77 ++++++++++++++++++- .../tests/test_legacy_email.py} | 13 ++-- lms/djangoapps/instructor/views/legacy.py | 30 ++++---- lms/templates/emails/email_footer.html | 6 -- lms/templates/emails/email_footer.txt | 7 -- 7 files changed, 107 insertions(+), 52 deletions(-) rename lms/djangoapps/{bulk_email/tests/tests.py => instructor/tests/test_legacy_email.py} (92%) delete mode 100644 lms/templates/emails/email_footer.html delete mode 100644 lms/templates/emails/email_footer.txt diff --git a/lms/djangoapps/bulk_email/fixtures/course_email_template.json b/lms/djangoapps/bulk_email/fixtures/course_email_template.json index 076dedbd14..bea136df38 100644 --- a/lms/djangoapps/bulk_email/fixtures/course_email_template.json +++ b/lms/djangoapps/bulk_email/fixtures/course_email_template.json @@ -3,7 +3,7 @@ "pk": 1, "model": "bulk_email.courseemailtemplate", "fields": { - "plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX: Facebook (http://facebook.com/edxonline)\nTwitter (http://twitter.com/edxonline)\nGoogle+ (https://plus.google.com/108235383044095082735)\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\n This email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", + "plain_template": "{course_title}\n\n{{message_body}}\r\n----\r\nCopyright 2013 edX, All rights reserved.\r\n----\r\nConnect with edX:\r\nFacebook (http://facebook.com/edxonline)\r\nTwitter (http://twitter.com/edxonline)\r\nGoogle+ (https://plus.google.com/108235383044095082735)\r\nMeetup (http://www.meetup.com/edX-Communities/)\r\n----\r\nThis email was automatically sent from {platform_name}.\r\nYou are receiving this email at address {email} because you are enrolled in {course_title}\r\n(URL: {course_url} ).\r\nTo stop receiving email like this, update your account settings at {account_settings_url}.\r\n", "html_template": " Update from {course_title}

    edX
    Connect with edX:        

    {course_title}


    {{message_body}}
           
    Copyright © 2013 edX, All rights reserved.


    Our mailing address is:
    edX
    11 Cambridge Center, Suite 101
    Cambridge, MA, USA 02142


    This email was automatically sent from {platform_name}.
    You are receiving this email at address {email} because you are enrolled in {course_title}.
    To stop receiving email like this, update your course email settings here.
    " } } diff --git a/lms/djangoapps/bulk_email/tasks.py b/lms/djangoapps/bulk_email/tasks.py index 2153090844..c9be4f5347 100644 --- a/lms/djangoapps/bulk_email/tasks.py +++ b/lms/djangoapps/bulk_email/tasks.py @@ -27,24 +27,16 @@ log = get_task_logger(__name__) @task(default_retry_delay=10, max_retries=5) # pylint: disable=E1102 -def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): +def delegate_email_batches(email_id, user_id): """ Delegates emails by querying for the list of recipients who should get the mail, chopping up into batches of settings.EMAILS_PER_TASK size, and queueing up worker jobs. - `to_option` is {'myself', 'staff', or 'all'} - Returns the number of batches (workers) kicked off. """ try: - course = get_course_by_id(course_id) - except Http404 as exc: - log.error("get_course_by_id failed: %s", exc.args[0]) - raise Exception("get_course_by_id failed: " + exc.args[0]) - - try: - CourseEmail.objects.get(id=email_id) + email_obj = CourseEmail.objects.get(id=email_id) except CourseEmail.DoesNotExist as exc: # The retry behavior here is necessary because of a race condition between the commit of the transaction # that creates this CourseEmail row and the celery pipeline that starts this task. @@ -87,7 +79,6 @@ def delegate_email_batches(email_id, to_option, course_id, course_url, user_id): log.error("Unexpected bulk email TO_OPTION found: %s", to_option) raise Exception("Unexpected bulk email TO_OPTION found: {0}".format(to_option)) - image_url = course_image_url(course) recipient_qset = recipient_qset.order_by('pk') total_num_emails = recipient_qset.count() num_queries = int(math.ceil(float(total_num_emails) / float(settings.EMAILS_PER_QUERY))) @@ -135,9 +126,10 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl user__in=[i['pk'] for i in to_list]) .values_list('user__email', flat=True)) + optouts = set(optouts) num_optout = len(optouts) - to_list = filter(lambda x: x['email'] not in set(optouts), to_list) + to_list = filter(lambda x: x['email'] not in optouts, to_list) subject = "[" + course_title + "] " + msg.subject @@ -208,6 +200,9 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl except (SMTPDataError, SMTPConnectError, SMTPServerDisconnected) as exc: # Error caught here cause the email to be retried. The entire task is actually retried without popping the list + # Reasoning is that all of these errors may be temporary condition. + log.warning('Email with id %d not delivered due to temporary error %s, retrying send to %d recipients', + email_id, exc, len(to_list)) raise course_email.retry( arg=[ email_id, @@ -220,6 +215,11 @@ def course_email(email_id, to_list, course_title, course_url, image_url, throttl exc=exc, countdown=(2 ** current_task.request.retries) * 15 ) + except: + log.exception('Email with id %d caused course_email task to fail with uncaught exception. To list: %s', + email_id, + [i['email'] for i in to_list]) + raise # This string format code is wrapped in this function to allow mocking for a unit test diff --git a/lms/djangoapps/bulk_email/tests/test_err_handling.py b/lms/djangoapps/bulk_email/tests/test_err_handling.py index e8874ea18e..61bdd315e9 100644 --- a/lms/djangoapps/bulk_email/tests/test_err_handling.py +++ b/lms/djangoapps/bulk_email/tests/test_err_handling.py @@ -12,14 +12,20 @@ from xmodule.modulestore.tests.django_utils import ModuleStoreTestCase from xmodule.modulestore.tests.factories import CourseFactory from student.tests.factories import UserFactory, AdminFactory, CourseEnrollmentFactory +from bulk_email.models import CourseEmail +from bulk_email.tasks import delegate_email_batches from bulk_email.tests.smtp_server_thread import FakeSMTPServerThread -from mock import patch +from mock import patch, Mock from smtplib import SMTPDataError, SMTPServerDisconnected, SMTPConnectError TEST_SMTP_PORT = 1025 +class EmailTestException(Exception): + pass + + @override_settings( MODULESTORE=TEST_DATA_MONGO_MODULESTORE, EMAIL_BACKEND='django.core.mail.backends.smtp.EmailBackend', @@ -33,8 +39,8 @@ class TestEmailErrors(ModuleStoreTestCase): def setUp(self): self.course = CourseFactory.create() - instructor = AdminFactory.create() - self.client.login(username=instructor.username, password="test") + self.instructor = AdminFactory.create() + self.client.login(username=self.instructor.username, password="test") # load initial content (since we don't run migrations as part of tests): call_command("loaddata", "course_email_template.json") @@ -145,3 +151,68 @@ class TestEmailErrors(ModuleStoreTestCase): (_, kwargs) = retry.call_args exc = kwargs['exc'] self.assertTrue(type(exc) == SMTPConnectError) + + @patch('bulk_email.tasks.course_email_result') + @patch('bulk_email.tasks.course_email.retry') + @patch('bulk_email.tasks.log') + @patch('bulk_email.tasks.get_connection', Mock(return_value=EmailTestException)) + def test_general_exception(self, mock_log, retry, result): + """ + Tests the if the error is not SMTP-related, we log and reraise + """ + test_email = { + 'action': 'Send email', + 'to_option': 'myself', + 'subject': 'test subject for myself', + 'message': 'test message for myself' + } + # For some reason (probably the weirdness of testing with celery tasks) assertRaises doesn't work here + # so we assert on the arguments of log.exception + self.client.post(self.url, test_email) + ((log_str, email_id, to_list), _) = mock_log.exception.call_args + self.assertTrue(mock_log.exception.called) + self.assertIn('caused course_email task to fail with uncaught exception.', log_str) + self.assertEqual(email_id, 1) + self.assertEqual(to_list, [self.instructor.email]) + self.assertFalse(retry.called) + self.assertFalse(result.called) + + @patch('bulk_email.tasks.course_email_result') + @patch('bulk_email.tasks.delegate_email_batches.retry') + @patch('bulk_email.tasks.log') + def test_nonexist_email(self, mock_log, retry, result): + """ + Tests retries when the email doesn't exist + """ + delegate_email_batches.delay(-1, self.instructor.id) + ((log_str, email_id, num_retries), _) = mock_log.warning.call_args + self.assertTrue(mock_log.warning.called) + self.assertIn('Failed to get CourseEmail with id', log_str) + self.assertEqual(email_id, -1) + self.assertTrue(retry.called) + self.assertFalse(result.called) + + @patch('bulk_email.tasks.log') + def test_nonexist_course(self, mock_log): + """ + Tests exception when the course in the email doesn't exist + """ + email = CourseEmail(course_id="I/DONT/EXIST") + email.save() + delegate_email_batches.delay(email.id, self.instructor.id) + ((log_str, _), _) = mock_log.exception.call_args + self.assertTrue(mock_log.exception.called) + self.assertIn('get_course_by_id failed:', log_str) + + @patch('bulk_email.tasks.log') + def test_nonexist_to_option(self, mock_log): + """ + Tests exception when the to_option in the email doesn't exist + """ + email = CourseEmail(course_id=self.course.id, to_option="IDONTEXIST") + email.save() + delegate_email_batches.delay(email.id, self.instructor.id) + ((log_str, opt_str), _) = mock_log.error.call_args + self.assertTrue(mock_log.error.called) + self.assertIn('Unexpected bulk email TO_OPTION found', log_str) + self.assertEqual("IDONTEXIST", opt_str) diff --git a/lms/djangoapps/bulk_email/tests/tests.py b/lms/djangoapps/instructor/tests/test_legacy_email.py similarity index 92% rename from lms/djangoapps/bulk_email/tests/tests.py rename to lms/djangoapps/instructor/tests/test_legacy_email.py index 210ae63df0..d8761466b0 100644 --- a/lms/djangoapps/bulk_email/tests/tests.py +++ b/lms/djangoapps/instructor/tests/test_legacy_email.py @@ -99,12 +99,12 @@ class TestStudentDashboardEmailView(ModuleStoreTestCase): # URL for dashboard self.url = reverse('dashboard') # URL for email settings modal - self.email_modal_link = ''.format( - self.course.org, - self.course.number, - self.course.display_name.replace(' ', '_') - ) - + self.email_modal_link = (('') + .format(self.course.org, + self.course.number, + self.course.display_name.replace(' ', '_'))) def tearDown(self): """ @@ -118,7 +118,6 @@ class TestStudentDashboardEmailView(ModuleStoreTestCase): response = self.client.get(self.url) self.assertTrue(self.email_modal_link in response.content) - @patch.dict(settings.MITX_FEATURES, {'ENABLE_INSTRUCTOR_EMAIL': False}) def test_email_flag_false(self): # Assert that the URL for the email view is not in the response diff --git a/lms/djangoapps/instructor/views/legacy.py b/lms/djangoapps/instructor/views/legacy.py index 7326fe2e98..b9295557d7 100644 --- a/lms/djangoapps/instructor/views/legacy.py +++ b/lms/djangoapps/instructor/views/legacy.py @@ -84,8 +84,8 @@ def instructor_dashboard(request, course_id): msg = '' email_msg = '' - to_option = None - subject = None + email_to_option = None + email_subject = None html_message = '' show_email_tab = False problems = [] @@ -703,30 +703,26 @@ def instructor_dashboard(request, course_id): # email elif action == 'Send email': - to_option = request.POST.get("to_option") - subject = request.POST.get("subject") + email_to_option = request.POST.get("to_option") + email_subject = request.POST.get("subject") html_message = request.POST.get("message") text_message = html_to_text(html_message) email = CourseEmail(course_id=course_id, sender=request.user, - to_option=to_option, - subject=subject, + to_option=email_to_option, + subject=email_subject, html_message=html_message, text_message=text_message) email.save() - course_url = request.build_absolute_uri(reverse('course_root', kwargs={'course_id': course_id})) tasks.delegate_email_batches.delay( email.id, - email.to_option, - course_id, - course_url, request.user.id ) - if to_option == "all": + if email_to_option == "all": email_msg = '

    Your email was successfully queued for sending. Please note that for large public classes (~10k), it may take 1-2 hours to send all emails.

    ' else: email_msg = '

    Your email was successfully queued for sending.

    ' @@ -799,9 +795,9 @@ def instructor_dashboard(request, course_id): # HTML editor for email if idash_mode == 'Email': html_module = HtmlDescriptor(course.system, {'data': html_message}) - editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')() + email_editor = wrap_xmodule(html_module.get_html, html_module, 'xmodule_edit.html')() else: - editor = None + email_editor = None # Flag for whether or not we display the email tab (depending upon # what backing store this course using (Mongo vs. XML)) @@ -825,11 +821,13 @@ def instructor_dashboard(request, course_id): 'course_stats': course_stats, 'msg': msg, 'modeflag': {idash_mode: 'selectedmode'}, - 'to_option': to_option, # email - 'subject': subject, # email - 'editor': editor, # email + + 'to_option': email_to_option, # email + 'subject': email_subject, # email + 'editor': email_editor, # email 'email_msg': email_msg, # email 'show_email_tab': show_email_tab, # email + 'problems': problems, # psychometrics 'plots': plots, # psychometrics 'course_errors': modulestore().get_item_errors(course.location), diff --git a/lms/templates/emails/email_footer.html b/lms/templates/emails/email_footer.html deleted file mode 100644 index ad9813c601..0000000000 --- a/lms/templates/emails/email_footer.html +++ /dev/null @@ -1,6 +0,0 @@ -<%! from django.core.urlresolvers import reverse %> -
    -----
    -This email was automatically sent from ${settings.PLATFORM_NAME}.
    -You are receiving this email at address ${ email } because you are enrolled in ${ course_title }.
    -To stop receiving email like this, update your course email settings here.
    diff --git a/lms/templates/emails/email_footer.txt b/lms/templates/emails/email_footer.txt deleted file mode 100644 index 95dffc218e..0000000000 --- a/lms/templates/emails/email_footer.txt +++ /dev/null @@ -1,7 +0,0 @@ -<%! from django.core.urlresolvers import reverse %> - ----- -This email was automatically sent from ${settings.PLATFORM_NAME}. -You are receiving this email at address ${ email } because you are enrolled in ${ course_title } -(URL: ${course_url} ). -To stop receiving email like this, update your account settings at https://${settings.SITE_NAME}${reverse('dashboard')}. From a061c7ece78450715120af12f3ee5850fe942ab5 Mon Sep 17 00:00:00 2001 From: cahrens Date: Thu, 29 Aug 2013 14:35:16 -0400 Subject: [PATCH 231/244] Rewriting of links for Course Updates and Course Handouts. STUD-669. --- .../features/course-updates.feature | 22 +++ .../contentstore/features/course-updates.py | 17 +++ cms/djangoapps/contentstore/views/course.py | 4 +- cms/envs/common.py | 2 +- .../coffee/spec/views/course_info_spec.coffee | 144 ++++++++++++++++++ cms/static/js/views/course_info_edit.js | 122 ++++++++------- cms/static/js_test.yml | 2 + cms/templates/course_info.html | 1 + common/lib/xmodule/xmodule/js/js_test.yml | 1 + .../xmodule/xmodule/js/src/html/edit.coffee | 15 +- common/static/js/spec/utility_spec.js | 13 ++ common/static/js/{ => src}/utility.js | 10 ++ common/static/js_test.yml | 2 + lms/envs/common.py | 2 +- 14 files changed, 290 insertions(+), 67 deletions(-) create mode 100644 cms/static/coffee/spec/views/course_info_spec.coffee create mode 100644 common/static/js/spec/utility_spec.js rename common/static/js/{ => src}/utility.js (83%) diff --git a/cms/djangoapps/contentstore/features/course-updates.feature b/cms/djangoapps/contentstore/features/course-updates.feature index 41ee785db5..bc73479c5f 100644 --- a/cms/djangoapps/contentstore/features/course-updates.feature +++ b/cms/djangoapps/contentstore/features/course-updates.feature @@ -45,3 +45,25 @@ Feature: Course updates When I modify the handout to "
      Test
    " Then I see the handout "Test" And I see a "saving" notification + + Scenario: Static links are rewritten when previewing a course update + Given I have opened a new course in Studio + And I go to the course updates page + When I add a new update with the text "" + # Can only do partial text matches because of the quotes with in quotes (and regexp step matching). + Then I should see the update "/c4x/MITx/999/asset/my_img.jpg" + And I change the update from "/static/my_img.jpg" to "" + Then I should see the update "/c4x/MITx/999/asset/modified.jpg" + And when I reload the page + Then I should see the update "/c4x/MITx/999/asset/modified.jpg" + + Scenario: Static links are rewritten when previewing handouts + Given I have opened a new course in Studio + And I go to the course updates page + When I modify the handout to "
    " + # Can only do partial text matches because of the quotes with in quotes (and regexp step matching). + Then I see the handout "/c4x/MITx/999/asset/my_img.jpg" + And I change the handout from "/static/my_img.jpg" to "" + Then I see the handout "/c4x/MITx/999/asset/modified.jpg" + And when I reload the page + Then I see the handout "/c4x/MITx/999/asset/modified.jpg" diff --git a/cms/djangoapps/contentstore/features/course-updates.py b/cms/djangoapps/contentstore/features/course-updates.py index f431af9cf5..3278805a48 100644 --- a/cms/djangoapps/contentstore/features/course-updates.py +++ b/cms/djangoapps/contentstore/features/course-updates.py @@ -38,6 +38,16 @@ def modify_update(_step, text): change_text(text) +@step(u'I change the update from "([^"]*)" to "([^"]*)"$') +def change_existing_update(_step, before, after): + verify_text_in_editor_and_update('div.post-preview a.edit-button', before, after) + + +@step(u'I change the handout from "([^"]*)" to "([^"]*)"$') +def change_existing_handout(_step, before, after): + verify_text_in_editor_and_update('div.course-handouts a.edit-button', before, after) + + @step(u'I delete the update$') def click_button(_step): button_css = 'div.post-preview a.delete-button' @@ -80,3 +90,10 @@ def change_text(text): type_in_codemirror(0, text) save_css = 'a.save-button' world.css_click(save_css) + + +def verify_text_in_editor_and_update(button_css, before, after): + world.css_click(button_css) + text = world.css_find(".cm-string").html + assert before in text + change_text(after) diff --git a/cms/djangoapps/contentstore/views/course.py b/cms/djangoapps/contentstore/views/course.py index aad56e4a2e..939286a765 100644 --- a/cms/djangoapps/contentstore/views/course.py +++ b/cms/djangoapps/contentstore/views/course.py @@ -18,6 +18,7 @@ from mitxmako.shortcuts import render_to_response from xmodule.modulestore.django import modulestore from xmodule.modulestore.inheritance import own_metadata +from xmodule.contentstore.content import StaticContent from xmodule.modulestore.exceptions import ( ItemNotFoundError, InvalidLocationError) @@ -206,7 +207,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(), + 'base_asset_url': StaticContent.get_base_url_path_for_course_assets(location) + '/'}) @expect_json diff --git a/cms/envs/common.py b/cms/envs/common.py index 29e99b2551..3e3b642b36 100644 --- a/cms/envs/common.py +++ b/cms/envs/common.py @@ -246,7 +246,7 @@ PIPELINE_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', + 'js/views/assets.js', 'js/src/utility.js', 'js/models/settings/course_grading_policy.js'], 'output_filename': 'js/cms-application.js', 'test_order': 0 diff --git a/cms/static/coffee/spec/views/course_info_spec.coffee b/cms/static/coffee/spec/views/course_info_spec.coffee new file mode 100644 index 0000000000..297e78f34a --- /dev/null +++ b/cms/static/coffee/spec/views/course_info_spec.coffee @@ -0,0 +1,144 @@ +courseInfoPage = """ +
    +
    +
    +
      +
      +
      + +
      + """ + +commonSetup = () -> + window.analytics = jasmine.createSpyObj('analytics', ['track']) + window.course_location_analytics = jasmine.createSpy() + window.courseUpdatesXhr = sinon.useFakeXMLHttpRequest() + requests = [] + window.courseUpdatesXhr.onCreate = (xhr) -> requests.push(xhr) + return requests + +commonCleanup = () -> + window.courseUpdatesXhr.restore() + delete window.analytics + delete window.course_location_analytics + +describe "Course Updates", -> + courseInfoTemplate = readFixtures('course_info_update.underscore') + + beforeEach -> + setFixtures($("