diff --git a/app/coffeescripts/api/gradingPeriodSetsApi.coffee b/app/coffeescripts/api/gradingPeriodSetsApi.coffee index d2a41e563db..9eb98eb9424 100644 --- a/app/coffeescripts/api/gradingPeriodSetsApi.coffee +++ b/app/coffeescripts/api/gradingPeriodSetsApi.coffee @@ -3,10 +3,11 @@ define [ 'underscore' 'i18n!grading_periods' 'jsx/shared/helpers/dateHelper' + 'compiled/api/gradingPeriodsApi' 'axios' 'jsx/shared/CheatDepaginator' 'jquery.instructure_misc_helpers' -], ($, _, I18n, DateHelper, axios, Depaginate) -> +], ($, _, I18n, DateHelper, gradingPeriodsApi, axios, Depaginate) -> listUrl = () => ENV.GRADING_PERIOD_SETS_URL @@ -17,26 +18,15 @@ define [ $.replaceTags(ENV.GRADING_PERIOD_SET_UPDATE_URL, 'id', id) serializeSet = (set) => - grading_period_set: { title: set.title }, + grading_period_set: { title: set.title, weighted: set.weighted }, enrollment_term_ids: set.enrollmentTermIDs - deserializePeriods = (periods) => - _.map periods, (period) -> - { - id: period.id.toString() - title: period.title - startDate: new Date(period.start_date) - endDate: new Date(period.end_date) - # TODO: After the close_date data fixup has run, this can become: - # `closeDate: new Date(period.close_date)` - closeDate: new Date(period.close_date || period.end_date) - } - baseDeserializeSet = (set) -> { id: set.id.toString() title: gradingPeriodSetTitle(set) - gradingPeriods: deserializePeriods(set.grading_periods) + weighted: set.weighted || false + gradingPeriods: gradingPeriodsApi.deserializePeriods(set.grading_periods) permissions: set.permissions createdAt: new Date(set.created_at) } @@ -46,7 +36,7 @@ define [ set.title.trim() else createdAt = DateHelper.formatDateForDisplay(new Date(set.created_at)) - I18n.t("Set created %{createdAt}", { createdAt: createdAt }); + I18n.t('Set created %{createdAt}', { createdAt }) deserializeSet = (set) -> newSet = baseDeserializeSet(set) @@ -57,29 +47,23 @@ define [ _.flatten _.map setGroups, (group) -> _.map group.grading_period_sets, (set) -> baseDeserializeSet(set) + deserializeSet: deserializeSet + list: () -> promise = new Promise (resolve, reject) => Depaginate(listUrl()) - .then (response) -> - resolve(deserializeSets(response)) - .fail (error) -> - reject(error) + .then (response) -> + resolve(deserializeSets(response)) + .fail (error) -> + reject(error) promise create: (set) -> - promise = new Promise (resolve, reject) => - axios.post(createUrl(), serializeSet(set)) - .then (response) -> - resolve(deserializeSet(response.data.grading_period_set)) - .catch (error) -> - reject(error) - promise + axios.post(createUrl(), serializeSet(set)) + .then (response) -> + deserializeSet(response.data.grading_period_set) update: (set) -> - promise = new Promise (resolve, reject) => - axios.patch(updateUrl(set.id), serializeSet(set)) - .then (response) -> - resolve(set) - .catch (error) -> - reject(error) - promise + axios.patch(updateUrl(set.id), serializeSet(set)) + .then (response) -> + set diff --git a/app/coffeescripts/api/gradingPeriodsApi.coffee b/app/coffeescripts/api/gradingPeriodsApi.coffee index 0a43b52f8a3..664286ddf85 100644 --- a/app/coffeescripts/api/gradingPeriodsApi.coffee +++ b/app/coffeescripts/api/gradingPeriodsApi.coffee @@ -15,6 +15,7 @@ define [ start_date: period.startDate end_date: period.endDate close_date: period.closeDate + weight: period.weight } grading_periods: serialized @@ -25,11 +26,10 @@ define [ title: period.title startDate: new Date(period.start_date) endDate: new Date(period.end_date) - # TODO: After the close_date data fixup has run, this can become: - # `closeDate: new Date(period.close_date)` - closeDate: new Date(period.close_date || period.end_date) + closeDate: new Date(period.close_date) isLast: period.is_last isClosed: period.is_closed + weight: period.weight } batchUpdate: (setId, periods) -> diff --git a/app/coffeescripts/bundles/account_grading_standards.coffee b/app/coffeescripts/bundles/account_grading_standards.coffee index e2b51a66960..f6de6c87b4c 100644 --- a/app/coffeescripts/bundles/account_grading_standards.coffee +++ b/app/coffeescripts/bundles/account_grading_standards.coffee @@ -7,7 +7,6 @@ require [ ReactDOM.render( TabContainerFactory( - multipleGradingPeriodsEnabled: ENV.MULTIPLE_GRADING_PERIODS readOnly: ENV.GRADING_PERIODS_READ_ONLY urls: enrollmentTermsURL: ENV.ENROLLMENT_TERMS_URL diff --git a/app/coffeescripts/bundles/assignment_index.coffee b/app/coffeescripts/bundles/assignment_index.coffee index 4ff766a9923..74173e47743 100644 --- a/app/coffeescripts/bundles/assignment_index.coffee +++ b/app/coffeescripts/bundles/assignment_index.coffee @@ -95,7 +95,7 @@ require [ # kick it all off assignmentGroups.fetch(reset: true).then -> - app.filterResults() if ENV.MULTIPLE_GRADING_PERIODS_ENABLED + app.filterResults() if ENV.HAS_GRADING_PERIODS if ENV.PERMISSIONS.manage assignmentGroups.loadModuleNames() else diff --git a/app/coffeescripts/bundles/course_grading_standards.coffee b/app/coffeescripts/bundles/course_grading_standards.coffee index b99f4bf789e..195a8612c06 100644 --- a/app/coffeescripts/bundles/course_grading_standards.coffee +++ b/app/coffeescripts/bundles/course_grading_standards.coffee @@ -4,8 +4,7 @@ require [ 'jsx/grading/CourseTabContainer' ], (React, ReactDOM, CourseTabContainer) -> CourseTabContainerFactory = React.createFactory CourseTabContainer - mgpEnabled = ENV.MULTIPLE_GRADING_PERIODS ReactDOM.render( - CourseTabContainerFactory(multipleGradingPeriodsEnabled: mgpEnabled), + CourseTabContainerFactory(hasGradingPeriods: ENV.HAS_GRADING_PERIODS), document.getElementById("react_grading_tabs") ) diff --git a/app/coffeescripts/ember/screenreader_gradebook/components/final_grade_component.coffee b/app/coffeescripts/ember/screenreader_gradebook/components/final_grade_component.coffee index ef9f43e183e..d7be1d02cdf 100644 --- a/app/coffeescripts/ember/screenreader_gradebook/components/final_grade_component.coffee +++ b/app/coffeescripts/ember/screenreader_gradebook/components/final_grade_component.coffee @@ -17,7 +17,7 @@ define [ pointRatio: ( -> "#{I18n.n @get('student.total_grade.score')} / #{I18n.n @get('student.total_grade.possible')}" - ).property("weighted_groups", "student.total_grade.score", "student.total_grade.possible") + ).property("weighted_grades", "student.total_grade.score", "student.total_grade.possible") letterGrade:(-> GradingSchemeHelper.scoreToGrade(@get('percent'), @get('gradingStandard')) @@ -26,7 +26,7 @@ define [ showGrade: Ember.computed.bool('student.total_grade.possible') showPoints:(-> - !!(!@get("weighted_groups") && @get("student.total_grade")) - ).property("weighted_groups","student.total_grade") + !!(!@get("weighted_grades") && @get("student.total_grade")) + ).property("weighted_grades","student.total_grade") showLetterGrade: Ember.computed.bool("gradingStandard") diff --git a/app/coffeescripts/ember/screenreader_gradebook/controllers/screenreader_gradebook_controller.coffee b/app/coffeescripts/ember/screenreader_gradebook/controllers/screenreader_gradebook_controller.coffee index 7ee69ee3b4a..cc79e24b8d7 100644 --- a/app/coffeescripts/ember/screenreader_gradebook/controllers/screenreader_gradebook_controller.coffee +++ b/app/coffeescripts/ember/screenreader_gradebook/controllers/screenreader_gradebook_controller.coffee @@ -32,19 +32,23 @@ define [ 'compiled/AssignmentDetailsDialog' 'compiled/AssignmentMuter' 'jsx/gradebook/CourseGradeCalculator' + 'jsx/gradebook/EffectiveDueDates' 'compiled/gradebook/OutcomeGradebookGrid' '../../shared/components/ic_submission_download_dialog_component' 'str/htmlEscape' 'compiled/models/grade_summary/CalculationMethodContent' 'jsx/gradebook/SubmissionStateMap' 'compiled/api/gradingPeriodsApi' + 'compiled/api/gradingPeriodSetsApi' 'jsx/gradezilla/individual-gradebook/components/GradebookSelector' 'jquery.instructure_date_and_time' -], ($, React, ReactDOM, ajax, round, userSettings, fetchAllPages, parseLinkHeader, - I18n, Ember, _, tz, AssignmentDetailsDialog, AssignmentMuter, - CourseGradeCalculator, outcomeGrid, ic_submission_download_dialog, - htmlEscape, CalculationMethodContent, SubmissionStateMap, GradingPeriodsAPI, - GradebookSelector) -> +], ( + $, React, ReactDOM, + ajax, round, userSettings, fetchAllPages, parseLinkHeader, I18n, Ember, _, tz, AssignmentDetailsDialog, + AssignmentMuter, CourseGradeCalculator, EffectiveDueDates, outcomeGrid, ic_submission_download_dialog, + htmlEscape, CalculationMethodContent, SubmissionStateMap, GradingPeriodsApi, GradingPeriodSetsApi, + GradebookSelector +) -> { get, set, setProperties } = Ember @@ -117,17 +121,33 @@ define [ submissionsUrl: get(window, 'ENV.GRADEBOOK_OPTIONS.submissions_url') - mgpEnabled: get(window, 'ENV.GRADEBOOK_OPTIONS.multiple_grading_periods_enabled') + has_grading_periods: get(window, 'ENV.GRADEBOOK_OPTIONS.has_grading_periods') + + gradingPeriods: + (-> + periods = get(window, 'ENV.GRADEBOOK_OPTIONS.active_grading_periods') + deserializedPeriods = GradingPeriodsApi.deserializePeriods(periods) + optionForAllPeriods = + id: '0', title: I18n.t("all_grading_periods", "All Grading Periods") + _.compact([optionForAllPeriods].concat(deserializedPeriods)) + )() + + getGradingPeriodSet: -> + grading_period_set = get(window, 'ENV.GRADEBOOK_OPTIONS.grading_period_set') + if grading_period_set + GradingPeriodSetsApi.deserializeSet(grading_period_set) + else + null gradingPeriods: (-> periods = get(window, 'ENV.GRADEBOOK_OPTIONS.active_grading_periods') - deserializedPeriods = GradingPeriodsAPI.deserializePeriods(periods) + deserializedPeriods = GradingPeriodsApi.deserializePeriods(periods) optionForAllPeriods = id: '0', title: I18n.t("all_grading_periods", "All Grading Periods") _.compact([optionForAllPeriods].concat(deserializedPeriods)) )() - lastGeneratedCsvLabel: do () => + lastGeneratedCsvLabel: do () => if get(window, 'ENV.GRADEBOOK_OPTIONS.gradebook_csv_progress') gradebook_csv_export_date = get(window, 'ENV.GRADEBOOK_OPTIONS.gradebook_csv_progress.progress.updated_at') I18n.t('Download Scores Generated on %{date}', @@ -333,12 +353,26 @@ define [ id != userId set(assignment, 'assignment_visibility', filteredVisibilities) - calculate: (submissionsArray) -> - CourseGradeCalculator.calculate submissionsArray, @assignmentGroupsHash(), @get('weightingScheme') + calculate: (student) -> + submissions = @submissionsForStudent(student) + assignmentGroups = @assignmentGroupsHash() + weightingScheme = @get('weightingScheme') + gradingPeriodSet = @getGradingPeriodSet() + effectiveDueDates = @get('effectiveDueDates.content') + + hasGradingPeriods = gradingPeriodSet and effectiveDueDates + + CourseGradeCalculator.calculate( + submissions, + assignmentGroups, + weightingScheme, + gradingPeriodSet if hasGradingPeriods, + EffectiveDueDates.scopeToUser(effectiveDueDates, student.id) if hasGradingPeriods + ) submissionsForStudent: (student) -> allSubmissions = (value for key, value of student when key.match /^assignment_(?!group)/) - return allSubmissions unless @get('mgpEnabled') + return allSubmissions unless @get('has_grading_periods') selectedPeriodID = @get('selectedGradingPeriod.id') return allSubmissions if !selectedPeriodID or selectedPeriodID == '0' @@ -348,23 +382,32 @@ define [ calculateStudentGrade: (student) -> if student.isLoaded - finalOrCurrent = if @get('includeUngradedAssignments') then 'final' else 'current' - result = @calculate(@submissionsForStudent(student)) - for group in result.group_sums - set(student, "assignment_group_#{group.group.id}", group[finalOrCurrent]) - for submissionData in group[finalOrCurrent].submissions - set(submissionData.submission, 'drop', submissionData.drop) - result = result[finalOrCurrent] + grades = @calculate(student) - percent = round (result.score / result.possible * 100), 2 + selectedPeriodID = @get('selectedGradingPeriod.id') + if selectedPeriodID && selectedPeriodID != '0' + grades = grades.gradingPeriods[selectedPeriodID] + + finalOrCurrent = if @get('includeUngradedAssignments') then 'final' else 'current' + + for assignmentGroupId, grade of grades.assignmentGroups + set(student, "assignment_group_#{assignmentGroupId}", grade[finalOrCurrent]) + for submissionData in grade[finalOrCurrent].submissions + set(submissionData.submission, 'drop', submissionData.drop) + grades = grades[finalOrCurrent] + + percent = round (grades.score / grades.possible * 100), 2 percent = 0 if isNaN(percent) setProperties student, - total_grade: result + total_grade: grades total_percent: I18n.n(percent, percentage: true) calculateAllGrades: (-> @get('students').forEach (student) => @calculateStudentGrade student - ).observes('includeUngradedAssignments','groupsAreWeighted', 'assignment_groups.@each.group_weight') + ).observes( + 'includeUngradedAssignments','groupsAreWeighted', 'assignment_groups.@each.group_weight', + 'effectiveDueDates.isLoaded' + ) sectionSelectDefaultLabel: I18n.t "all_sections", "All Sections" studentSelectDefaultLabel: I18n.t "no_student", "No Student Selected" @@ -378,7 +421,7 @@ define [ fetchAssignmentGroups: (-> params = { exclude_response_fields: ['in_closed_grading_period'] } gpId = @get('selectedGradingPeriod.id') - if @get('mgpEnabled') && gpId != '0' + if @get('has_grading_periods') && gpId != '0' params.grading_period_id = gpId @set('assignment_groups', []) @set('assignmentsFromGroups', []) @@ -475,16 +518,17 @@ define [ ) displayPointTotals: (-> - if @get("groupsAreWeighted") - false - else - @get("showTotalAsPoints") - ).property('groupsAreWeighted', 'showTotalAsPoints') + @get('showTotalAsPoints') and not @get('gradesAreWeighted') + ).property('gradesAreWeighted', 'showTotalAsPoints') groupsAreWeighted: (-> @get("weightingScheme") == "percent" ).property("weightingScheme") + gradesAreWeighted: (-> + @get('groupsAreWeighted') or !!@getGradingPeriodSet()?.weighted + ).property('weightingScheme') + updateShowTotalAs: (-> @set "showTotalAsPoints", @get("displayPointTotals") ajax.request( @@ -493,7 +537,7 @@ define [ url: ENV.GRADEBOOK_OPTIONS.setting_update_url data: show_total_grade_as_points: @get("displayPointTotals")) - ).observes('showTotalAsPoints', 'groupsAreWeighted') + ).observes('showTotalAsPoints', 'gradesAreWeighted') studentColumnData: {} @@ -705,7 +749,7 @@ define [ populateSubmissionStateMap: (-> map = new SubmissionStateMap( - gradingPeriodsEnabled: !!@mgpEnabled + hasGradingPeriods: !!@has_grading_periods selectedGradingPeriodID: @get('selectedGradingPeriod.id') || '0' isAdmin: ENV.current_user_roles && _.contains(ENV.current_user_roles, "admin") ) diff --git a/app/coffeescripts/ember/screenreader_gradebook/templates/settings/grading_period_select.hbs b/app/coffeescripts/ember/screenreader_gradebook/templates/settings/grading_period_select.hbs index 4fe7f4e1fd2..4a7a551e9f8 100644 --- a/app/coffeescripts/ember/screenreader_gradebook/templates/settings/grading_period_select.hbs +++ b/app/coffeescripts/ember/screenreader_gradebook/templates/settings/grading_period_select.hbs @@ -1,4 +1,4 @@ -{{#if mgpEnabled}} +{{#if has_grading_periods}}
-{{/if}} \ No newline at end of file +{{/if}} diff --git a/app/coffeescripts/ember/screenreader_gradebook/templates/student_information/index.hbs b/app/coffeescripts/ember/screenreader_gradebook/templates/student_information/index.hbs index 9d478418522..89658f09086 100644 --- a/app/coffeescripts/ember/screenreader_gradebook/templates/student_information/index.hbs +++ b/app/coffeescripts/ember/screenreader_gradebook/templates/student_information/index.hbs @@ -35,7 +35,7 @@ {{ final-grade student=selectedStudent - weighted_groups=groupsAreWeighted + weighted_grades=gradesAreWeighted gradingStandard=ENV.GRADEBOOK_OPTIONS.grading_standard }} @@ -55,4 +55,4 @@ {{/if}} - \ No newline at end of file + diff --git a/app/coffeescripts/ember/screenreader_gradebook/tests/components/grading_cell.spec.coffee b/app/coffeescripts/ember/screenreader_gradebook/tests/components/grading_cell.spec.coffee index dce1283a92f..2fa2d52af76 100644 --- a/app/coffeescripts/ember/screenreader_gradebook/tests/components/grading_cell.spec.coffee +++ b/app/coffeescripts/ember/screenreader_gradebook/tests/components/grading_cell.spec.coffee @@ -17,8 +17,7 @@ define [ App = startApp() @component = App.GradingCellComponent.create() - ENV.GRADEBOOK_OPTIONS.multiple_grading_periods_enabled = true - ENV.GRADEBOOK_OPTIONS.latest_end_date_of_admin_created_grading_periods_in_the_past = "2013-10-01T10:00:00Z" + ENV.GRADEBOOK_OPTIONS.has_grading_periods = true ENV.current_user_roles = [] setType = (type) => diff --git a/app/coffeescripts/ember/screenreader_gradebook/tests/controllers/screenreader_gradebook.spec.coffee b/app/coffeescripts/ember/screenreader_gradebook/tests/controllers/screenreader_gradebook.spec.coffee index fe573cc4dfe..3d14a1cf1d2 100644 --- a/app/coffeescripts/ember/screenreader_gradebook/tests/controllers/screenreader_gradebook.spec.coffee +++ b/app/coffeescripts/ember/screenreader_gradebook/tests/controllers/screenreader_gradebook.spec.coffee @@ -5,10 +5,15 @@ define [ '../start_app' 'ember' '../shared_ajax_fixtures' + 'spec/jsx/gradebook/GradeCalculatorSpecHelper' '../../controllers/screenreader_gradebook_controller' 'compiled/userSettings' + 'jsx/gradebook/CourseGradeCalculator' 'vendor/jquery.ba-tinypubsub' -], ($, _, ajax, startApp, Ember, fixtures, SRGBController, userSettings) -> +], ( + $, _, ajax, startApp, Ember, fixtures, GradeCalculatorSpecHelper, SRGBController, userSettings, + CourseGradeCalculator +) -> workAroundRaceCondition = -> ajax.request() @@ -19,6 +24,13 @@ define [ clone = (obj) -> Ember.copy obj, true + createExampleGrades = GradeCalculatorSpecHelper.createCourseGradesWithGradingPeriods + + createExampleGradingPeriodSet = -> + id: '1501' + gradingPeriods: [{ id: '701', weight: 50 }, { id: '702', weight: 50 }] + weighted: true + setup = (isDraftState=false, sortOrder='assignment_group') -> fixtures.create() @contextGetStub = sinon.stub(userSettings, 'contextGet') @@ -51,7 +63,6 @@ define [ teardown: -> teardown.call this - test 'calculates students properly', -> equal @srgb.get('students.length'), 10 equal @srgb.get('students.firstObject').name, fixtures.students[0].user.name @@ -85,19 +96,19 @@ define [ test 'displayName is hiddenName when hideStudentNames is true', -> @srgb.set('hideStudentNames', true) - equal @srgb.get('displayName'), "hiddenName" + equal @srgb.get('displayName'), 'hiddenName' @srgb.set('hideStudentNames', false) - equal @srgb.get('displayName'), "name" + equal @srgb.get('displayName'), 'name' - test 'displayPointTotals is false when groups are weighted even if showTotalAsPoints is true', -> + test 'displayPointTotals is false when grades are weighted even if showTotalAsPoints is true', -> Ember.run => @srgb.set('showTotalAsPoints', true) - @srgb.set('groupsAreWeighted', true) + @srgb.set('gradesAreWeighted', true) equal @srgb.get('displayPointTotals'), false - test 'displayPointTotals is toggled by showTotalAsPoints when groups are unweighted', -> + test 'displayPointTotals is toggled by showTotalAsPoints when grades are unweighted', -> Ember.run => - @srgb.set('groupsAreWeighted', false) + @srgb.set('gradesAreWeighted', false) @srgb.set('showTotalAsPoints', true) equal @srgb.get('displayPointTotals'), true @srgb.set('showTotalAsPoints', false) @@ -161,18 +172,75 @@ define [ equal @srgb.get('assignments.lastObject.name'), 'Drink Water' start() + QUnit.module 'screenreader_gradebook_controller#gradesAreWeighted', + setup: -> + setup.call this + teardown: -> + teardown.call this + + test 'is true when the grading period set is weighted', -> + gradingPeriodSet = createExampleGradingPeriodSet() + gradingPeriodSet.weighted = true + @stub(@srgb, 'getGradingPeriodSet').returns(gradingPeriodSet) + Ember.run => + @srgb.set('groupsAreWeighted', false) + equal @srgb.get('gradesAreWeighted'), true + + test 'is true when groupsAreWeighted is true', -> + gradingPeriodSet = createExampleGradingPeriodSet() + gradingPeriodSet.weighted = false + @stub(@srgb, 'getGradingPeriodSet').returns(gradingPeriodSet) + Ember.run => + @srgb.set('groupsAreWeighted', true) + equal @srgb.get('gradesAreWeighted'), true + + test 'is false when assignment groups are not weighted and the grading period set is not weighted', -> + gradingPeriodSet = createExampleGradingPeriodSet() + gradingPeriodSet.weighted = false + @stub(@srgb, 'getGradingPeriodSet').returns(gradingPeriodSet) + Ember.run => + @srgb.set('groupsAreWeighted', false) + equal @srgb.get('gradesAreWeighted'), false + + test 'is false when assignment groups are not weighted and the grading period set is not defined', -> + @stub(@srgb, 'getGradingPeriodSet').returns(null) + Ember.run => + @srgb.set('groupsAreWeighted', false) + equal @srgb.get('gradesAreWeighted'), false + + QUnit.module '#getGradingPeriodSet', + setup: -> + setup.call this + + teardown: -> + teardown.call this + + test 'normalizes the grading period set from the env', -> + ENV.GRADEBOOK_OPTIONS.grading_period_set = + id: '1501' + grading_periods: [{ id: '701', weight: 50 }, { id: '702', weight: 50 }] + weighted: true + gradingPeriodSet = @srgb.getGradingPeriodSet() + deepEqual(gradingPeriodSet.id, '1501') + equal(gradingPeriodSet.gradingPeriods.length, 2) + deepEqual(_.map(gradingPeriodSet.gradingPeriods, 'id'), ['701', '702']) + + test 'sets grading period set to null when not defined in the env', -> + gradingPeriodSet = @srgb.getGradingPeriodSet() + deepEqual(gradingPeriodSet, null) + QUnit.module '#submissionsForStudent', setupThis: (options = {}) -> effectiveDueDates = Ember.ObjectProxy.create( content: { - 1: { 1: { grading_period_id: "1" } }, - 2: { 1: { grading_period_id: "2" } } + 1: { 1: { grading_period_id: '1' } }, + 2: { 1: { grading_period_id: '2' } } } ) defaults = { - mgpEnabled: false, - "selectedGradingPeriod.id": null, + has_grading_periods: false, + 'selectedGradingPeriod.id': null, effectiveDueDates } self = _.defaults options, defaults @@ -181,40 +249,41 @@ define [ setup: -> @student = - id: "1" - assignment_1: { assignment_id: "1", user_id: "1", name: "yolo" } - assignment_2: { assignment_id: "2", user_id: "1", name: "froyo" } + id: '1' + assignment_1: { assignment_id: '1', user_id: '1', name: 'yolo' } + assignment_2: { assignment_id: '2', user_id: '1', name: 'froyo' } setup.call this teardown: -> teardown.call this - test "returns all submissions for the student (multiple grading periods disabled)", -> + test 'returns all submissions for the student when there are no grading periods', -> self = @setupThis() submissions = @srgb.submissionsForStudent.call(self, @student) - propEqual _.pluck(submissions, "assignment_id"), ["1", "2"] + propEqual _.pluck(submissions, 'assignment_id'), ['1', '2'] - test "returns all submissions if 'All Grading Periods' is selected", -> + test 'returns all submissions if "All Grading Periods" is selected', -> self = @setupThis( - mgpEnabled: true, - "selectedGradingPeriod.id": "0", + has_grading_periods: true, + 'selectedGradingPeriod.id': '0' ) submissions = @srgb.submissionsForStudent.call(self, @student) - propEqual _.pluck(submissions, "assignment_id"), ["1", "2"] + propEqual _.pluck(submissions, 'assignment_id'), ['1', '2'] - test "only returns submissions due for the student in the selected grading period", -> + test 'only returns submissions due for the student in the selected grading period', -> self = @setupThis( - mgpEnabled: true, - "selectedGradingPeriod.id": "2" + has_grading_periods: true, + 'selectedGradingPeriod.id': '2' ) submissions = @srgb.submissionsForStudent.call(self, @student) - propEqual _.pluck(submissions, "assignment_id"), ["2"] + propEqual _.pluck(submissions, 'assignment_id'), ['2'] QUnit.module 'screenreader_gradebook_controller: with selected student', setup: -> setup.call this + @stub(@srgb, 'calculateStudentGrade') @completeSetup = => workAroundRaceCondition().then => Ember.run => @@ -232,7 +301,7 @@ define [ start() asyncTest 'assignments excludes any due for the selected student in a different grading period', -> - @srgb.mgpEnabled = true + @srgb.has_grading_periods = true @completeSetup().then => deepEqual(@srgb.get('assignments').mapBy('id'), ['3']) start() @@ -350,8 +419,8 @@ define [ id: '21' name: 'Unpublished Assignment' points_possible: 10 - grading_type: "percent" - submission_types: ["none"] + grading_type: 'percent' + submission_types: ['none'] due_at: null position: 6 assignment_group_id:'4' @@ -370,24 +439,7 @@ define [ calc_stub = { - group_sums: [ - { - final: - possible: 100 - score: 50 - submission_count: 10 - weight: 50 - submissions: [] - current: - possible: 100 - score: 20 - submission_count: 5 - weight: 50 - submissions:[] - group: - id: "1" - } - ] + assignmentGroups: {} final: possible: 100 score: 90 @@ -398,24 +450,7 @@ define [ calc_stub_with_0_possible = { - group_sums: [ - { - final: - possible: 0 - score: 50 - submission_count: 10 - weight: 50 - submissions: [] - current: - possible: 0 - score: 20 - submission_count: 5 - weight: 50 - submissions:[] - group: - id: "1" - } - ] + assignmentGroups: {} final: possible: 0 score: 0 @@ -457,6 +492,125 @@ define [ equal @srgb.get('students.firstObject.total_percent'), '0%' start() + QUnit.module 'screenreader_gradebook_controller: calculate', + setupThis:(options = {}) -> + assignments = [{ id: 201, points_possible: 10, omit_from_final_grade: false }] + submissions = [{ assignment_id: 201, score: 10 }] + assignmentGroupsHash = { 301: { id: 301, group_weight: 60, rules: {}, assignments } } + gradingPeriodSet = + id: '1501' + gradingPeriods: [{ id: '701', weight: 50 }, { id: '702', weight: 50 }] + weighted: true + props = _.defaults options, + weightingScheme: 'points' + getGradingPeriodSet: () -> gradingPeriodSet + 'effectiveDueDates.content': { 201: { 101: { grading_period_id: '701' } } } + _.extend {}, props, + get: (attr) -> props[attr] + submissionsForStudent: () -> submissions + assignmentGroupsHash: () -> assignmentGroupsHash + + setup: -> + @calculate = SRGBController.prototype.calculate + + test 'calculates grades using properties from the gradebook', -> + self = @setupThis() + @stub(CourseGradeCalculator, 'calculate').returns('expected') + grades = @calculate.call(self, id: '101', loaded: true) + equal(grades, 'expected') + args = CourseGradeCalculator.calculate.getCall(0).args + equal(args[0], self.submissionsForStudent()) + equal(args[1], self.assignmentGroupsHash()) + equal(args[2], self.get('weightingScheme')) + equal(args[3], self.getGradingPeriodSet()) + + test 'scopes effective due dates to the user', -> + self = @setupThis() + @stub(CourseGradeCalculator, 'calculate') + @calculate.call(self, id: '101', loaded: true) + dueDates = CourseGradeCalculator.calculate.getCall(0).args[4] + deepEqual(dueDates, 201: { grading_period_id: '701' }) + + test 'calculates grades without grading period data when grading period set is null', -> + self = @setupThis(getGradingPeriodSet: -> null) + @stub(CourseGradeCalculator, 'calculate') + @calculate.call(self, id: '101', loaded: true) + args = CourseGradeCalculator.calculate.getCall(0).args + equal(args[0], self.submissionsForStudent()) + equal(args[1], self.assignmentGroupsHash()) + equal(args[2], self.get('weightingScheme')) + equal(typeof args[3], 'undefined') + equal(typeof args[4], 'undefined') + + test 'calculates grades without grading period data when effective due dates are not defined', -> + self = @setupThis('effectiveDueDates.content': null) + @stub(CourseGradeCalculator, 'calculate') + @calculate.call(self, id: '101', loaded: true) + args = CourseGradeCalculator.calculate.getCall(0).args + equal(args[0], self.submissionsForStudent()) + equal(args[1], self.assignmentGroupsHash()) + equal(args[2], self.get('weightingScheme')) + equal(typeof args[3], 'undefined') + equal(typeof args[4], 'undefined') + + QUnit.module 'screenreader_gradebook_controller: calculateStudentGrade', + setupThis:(options = {}) -> + assignments = [{ id: 201, points_possible: 10, omit_from_final_grade: false }] + submissions = [{ assignment_id: 201, score: 10 }] + assignmentGroupsHash = { 301: { id: 301, group_weight: 60, rules: {}, assignments } } + gradingPeriodSet = + id: '1501' + gradingPeriods: [{ id: '701', weight: 50 }, { id: '702', weight: 50 }] + weighted: true + props = _.defaults options, + weightingScheme: 'points' + getGradingPeriodSet: () -> gradingPeriodSet + calculate: () -> CourseGradeCalculator.calculate() + 'effectiveDueDates.content': { 201: { 101: { grading_period_id: '701' } } } + 'selectedGradingPeriod.id': '0' + _.extend {}, props, + get: (attr) -> props[attr] + submissionsForStudent: () -> submissions + assignmentGroupsHash: () -> assignmentGroupsHash + + setup: -> + @calculateStudentGrade = SRGBController.prototype.calculateStudentGrade + + test 'stores the current grade on the student when not including ungraded assignments', -> + exampleGrades = createExampleGrades() + self = @setupThis(includeUngradedAssignments: false) + @stub(CourseGradeCalculator, 'calculate').returns(exampleGrades) + student = Ember.Object.create(id: '101', loaded: true) + student.set('isLoaded', true) + @calculateStudentGrade.call(self, student) + equal(student.total_grade, exampleGrades.current) + + test 'stores the final grade on the student when including ungraded assignments', -> + exampleGrades = createExampleGrades() + self = @setupThis(includeUngradedAssignments: true) + @stub(CourseGradeCalculator, 'calculate').returns(exampleGrades) + student = Ember.Object.create(id: '101', loaded: true) + student.set('isLoaded', true) + @calculateStudentGrade.call(self, student) + equal(student.total_grade, exampleGrades.final) + + test 'stores the current grade from the selected grading period when not including ungraded assignments', -> + exampleGrades = createExampleGrades() + self = @setupThis('selectedGradingPeriod.id': 701, includeUngradedAssignments: false) + @stub(CourseGradeCalculator, 'calculate').returns(exampleGrades) + student = Ember.Object.create(id: '101', loaded: true) + student.set('isLoaded', true) + @calculateStudentGrade.call(self, student) + equal(student.total_grade, exampleGrades.gradingPeriods[701].current) + + test 'stores the final grade from the selected grading period when including ungraded assignments', -> + exampleGrades = createExampleGrades() + self = @setupThis('selectedGradingPeriod.id': 701, includeUngradedAssignments: true) + @stub(CourseGradeCalculator, 'calculate').returns(exampleGrades) + student = Ember.Object.create(id: '101', loaded: true) + student.set('isLoaded', true) + @calculateStudentGrade.call(self, student) + equal(student.total_grade, exampleGrades.gradingPeriods[701].final) QUnit.module 'screenreader_gradebook_controller: notes computed props', setup: -> @@ -519,20 +673,20 @@ define [ Ember.run => @srgb.set('showNotesColumn', true) @srgb.set('shouldCreateNotes', false) - deepEqual @srgb.get('notesParams'), "column[hidden]": false + deepEqual @srgb.get('notesParams'), 'column[hidden]': false Ember.run => @srgb.set('showNotesColumn', false) @srgb.set('shouldCreateNotes', false) - deepEqual @srgb.get('notesParams'), "column[hidden]": true + deepEqual @srgb.get('notesParams'), 'column[hidden]': true Ember.run => @srgb.set('showNotesColumn', true) @srgb.set('shouldCreateNotes', true) deepEqual @srgb.get('notesParams'), - "column[title]": "Notes" - "column[position]": 1 - "column[teacher_notes]": true + 'column[title]': 'Notes' + 'column[position]': 1 + 'column[teacher_notes]': true test 'notesVerb', -> Ember.run => @@ -552,13 +706,14 @@ define [ teardown.call this test 'calculates invalidGroupsWarningPhrases properly', -> - equal @srgb.get('invalidGroupsWarningPhrases'), "Note: Score does not include assignments from the group Invalid AG because it has no points possible." + equal @srgb.get('invalidGroupsWarningPhrases'), + 'Note: Score does not include assignments from the group Invalid AG because it has no points possible.' test 'sets showInvalidGroupWarning to false if groups are not weighted', -> Ember.run => - @srgb.set('weightingScheme', "equal") + @srgb.set('weightingScheme', 'equal') equal @srgb.get('showInvalidGroupWarning'), false - @srgb.set('weightingScheme', "percent") + @srgb.set('weightingScheme', 'percent') equal @srgb.get('showInvalidGroupWarning'), true diff --git a/app/coffeescripts/ember/screenreader_gradebook/tests/start_app.coffee b/app/coffeescripts/ember/screenreader_gradebook/tests/start_app.coffee index c9e837003fb..1e47ad8dc37 100644 --- a/app/coffeescripts/ember/screenreader_gradebook/tests/start_app.coffee +++ b/app/coffeescripts/ember/screenreader_gradebook/tests/start_app.coffee @@ -3,11 +3,6 @@ define ['../main', 'ember'], (Application, Ember) -> App = null Ember.run.join -> App = Application.create - LOG_ACTIVE_GENERATION: yes - LOG_MODULE_RESOLVER: yes - LOG_TRANSITIONS: yes - LOG_TRANSITIONS_INTERNAL: yes - LOG_VIEW_LOOKUPS: yes rootElement: '#fixtures' App.Router.reopen history: 'none' App.setupForTesting() diff --git a/app/coffeescripts/gradebook/Gradebook.coffee b/app/coffeescripts/gradebook/Gradebook.coffee index 68b5e11cf72..a0142ff6db2 100644 --- a/app/coffeescripts/gradebook/Gradebook.coffee +++ b/app/coffeescripts/gradebook/Gradebook.coffee @@ -1,4 +1,21 @@ -# This class both creates the slickgrid instance, and acts as the data source for that instance. +# +# Copyright (C) 2011 - 2017 Instructure, Inc. +# +# This file is part of Canvas. +# +# Canvas is free software: you can redistribute it and/or modify it under +# the terms of the GNU Affero General Public License as published by the Free +# Software Foundation, version 3 of the License. +# +# Canvas is distributed in the hope that it will be useful, but WITHOUT ANY +# WARRANTY; without even the implied warranty of MERCHANTABILITY or FITNESS FOR +# A PARTICULAR PURPOSE. See the GNU Affero General Public License for more +# details. +# +# You should have received a copy of the GNU Affero General Public License along +# with this program. If not, see . +# + define [ 'jquery' 'underscore' @@ -18,6 +35,7 @@ define [ 'i18n!gradebook' 'compiled/gradebook/GradebookTranslations' 'jsx/gradebook/CourseGradeCalculator' + 'jsx/gradebook/EffectiveDueDates' 'jsx/gradebook/GradingSchemeHelper' 'compiled/userSettings' 'spin.js' @@ -41,6 +59,7 @@ define [ 'compiled/gradebook/GradebookKeyboardNav' 'jsx/gradebook/shared/helpers/assignmentHelper' 'compiled/api/gradingPeriodsApi' + 'compiled/api/gradingPeriodSetsApi' 'jst/_avatar' #needed by row_student_name 'jquery.ajaxJSON' 'jquery.instructure_date_and_time' @@ -60,13 +79,13 @@ define [ ], ( $, _, Backbone, tz, DataLoader, React, ReactDOM, LongTextEditor, KeyboardNavDialog, KeyboardNavTemplate, Slick, TotalColumnHeaderView, round, InputFilterView, i18nObj, I18n, GRADEBOOK_TRANSLATIONS, CourseGradeCalculator, - GradingSchemeHelper, UserSettings, Spinner, SubmissionDetailsDialog, AssignmentGroupWeightsDialog, - GradeDisplayWarningDialog, PostGradesFrameDialog, SubmissionCell, GradebookHeaderMenu, NumberCompare, natcompare, - htmlEscape, PostGradesStore, PostGradesApp, SubmissionStateMap, ColumnHeaderTemplate, GroupTotalCellTemplate, - RowStudentNameTemplate, SectionMenuView, GradingPeriodMenuView, GradebookKeyboardNav, assignmentHelper, - GradingPeriodsAPI + EffectiveDueDates, GradingSchemeHelper, UserSettings, Spinner, SubmissionDetailsDialog, + AssignmentGroupWeightsDialog, GradeDisplayWarningDialog, PostGradesFrameDialog, SubmissionCell, + GradebookHeaderMenu, NumberCompare, natcompare, htmlEscape, PostGradesStore, PostGradesApp, SubmissionStateMap, + ColumnHeaderTemplate, GroupTotalCellTemplate, RowStudentNameTemplate, SectionMenuView, GradingPeriodMenuView, + GradebookKeyboardNav, assignmentHelper, GradingPeriodsApi, GradingPeriodSetsApi ) -> - + # This class both creates the slickgrid instance, and acts as the data source for that instance. class Gradebook columnWidths = assignment: @@ -102,11 +121,15 @@ define [ @options.settings['show_inactive_enrollments'] == "true" @totalColumnInFront = UserSettings.contextGet 'total_column_in_front' @numberOfFrozenCols = if @totalColumnInFront then 3 else 2 - @gradingPeriodsEnabled = @options.multiple_grading_periods_enabled - @gradingPeriods = GradingPeriodsAPI.deserializePeriods(@options.active_grading_periods) + @hasGradingPeriods = @options.has_grading_periods + @gradingPeriods = GradingPeriodsApi.deserializePeriods(@options.active_grading_periods) + if @options.grading_period_set + @gradingPeriodSet = GradingPeriodSetsApi.deserializeSet(@options.grading_period_set) + else + @gradingPeriodSet = null @gradingPeriodToShow = @getGradingPeriodToShow() @submissionStateMap = new SubmissionStateMap - gradingPeriodsEnabled: @gradingPeriodsEnabled + hasGradingPeriods: @hasGradingPeriods selectedGradingPeriodID: @gradingPeriodToShow isAdmin: _.contains(ENV.current_user_roles, "admin") @gradebookColumnSizeSettings = @options.gradebook_column_size_settings @@ -120,7 +143,7 @@ define [ $.subscribe 'currentGradingPeriod/change', @updateCurrentGradingPeriod assignmentGroupsParams = { exclude_response_fields: @fieldsToExcludeFromAssignments } - if @gradingPeriodsEnabled && @gradingPeriodToShow && @gradingPeriodToShow != '0' && @gradingPeriodToShow != '' + if @hasGradingPeriods && @gradingPeriodToShow && @gradingPeriodToShow != '0' && @gradingPeriodToShow != '' $.extend(assignmentGroupsParams, {grading_period_id: @gradingPeriodToShow}) $('li.external-tools-dialog > a[data-url], button.external-tools-dialog').on 'click keyclick', (event) -> @@ -135,7 +158,7 @@ define [ submissionParams = response_fields: ['id', 'user_id', 'url', 'score', 'grade', 'submission_type', 'submitted_at', 'assignment_id', 'grade_matches_current_submission', 'attachments', 'late', 'workflow_state', 'excused'] exclude_response_fields: ['preview_url'] - submissionParams['grading_period_id'] = @gradingPeriodToShow if @gradingPeriodsEnabled && @gradingPeriodToShow && @gradingPeriodToShow != '0' && @gradingPeriodToShow != '' + submissionParams['grading_period_id'] = @gradingPeriodToShow if @hasGradingPeriods && @gradingPeriodToShow && @gradingPeriodToShow != '0' && @gradingPeriodToShow != '' dataLoader = DataLoader.loadGradebookData( assignmentGroupsURL: @options.assignment_groups_url assignmentGroupsParams: assignmentGroupsParams @@ -235,7 +258,7 @@ define [ _.contains(activePeriodIds, gradingPeriodId) getGradingPeriodToShow: () => - return null unless @gradingPeriodsEnabled + return null unless @hasGradingPeriods currentPeriodId = UserSettings.contextGet('gradebook_current_grading_period') if currentPeriodId && (@isAllGradingPeriods(currentPeriodId) || @gradingPeriodIsActive(currentPeriodId)) currentPeriodId @@ -719,7 +742,7 @@ define [ templateOpts.warning = @totalGradeWarning templateOpts.lastColumn = true templateOpts.showPointsNotPercent = @displayPointTotals() - templateOpts.hideTooltip = @weightedGroups() and not @totalGradeWarning + templateOpts.hideTooltip = @weightedGrades() and not @totalGradeWarning GroupTotalCellTemplate templateOpts htmlContentFormatter: (row, col, val, columnDef, student) -> @@ -732,7 +755,7 @@ define [ submissionsForStudent: (student) => allSubmissions = (value for key, value of student when key.match /^assignment_(?!group)/) - return allSubmissions unless @gradingPeriodsEnabled + return allSubmissions unless @hasGradingPeriods return allSubmissions if !@gradingPeriodToShow or @isAllGradingPeriods(@gradingPeriodToShow) _.filter allSubmissions, (submission) => @@ -741,17 +764,26 @@ define [ calculateStudentGrade: (student) => if student.loaded and student.initialized - finalOrCurrent = if @include_ungraded_assignments then 'final' else 'current' - result = CourseGradeCalculator.calculate( + hasGradingPeriods = @gradingPeriodSet and @effectiveDueDates + + grades = CourseGradeCalculator.calculate( @submissionsForStudent(student), @assignmentGroups, - @options.group_weighting_scheme + @options.group_weighting_scheme, + @gradingPeriodSet if hasGradingPeriods, + EffectiveDueDates.scopeToUser(@effectiveDueDates, student.id) if hasGradingPeriods ) - for group in result.group_sums - student["assignment_group_#{group.group.id}"] = group[finalOrCurrent] - for submissionData in group[finalOrCurrent].submissions + + if @gradingPeriodToShow && !@isAllGradingPeriods(@gradingPeriodToShow) + grades = grades.gradingPeriods[@gradingPeriodToShow] + + finalOrCurrent = if @include_ungraded_assignments then 'final' else 'current' + + for assignmentGroupId, grade of grades.assignmentGroups + student["assignment_group_#{assignmentGroupId}"] = grade[finalOrCurrent] + for submissionData in grade[finalOrCurrent].submissions submissionData.submission.drop = submissionData.drop - student["total_grade"] = result[finalOrCurrent] + student["total_grade"] = grades[finalOrCurrent] @addDroppedClass(student) @@ -1044,7 +1076,7 @@ define [ initHeader: => @drawSectionSelectButton() if @sections_enabled - @drawGradingPeriodSelectButton() if @gradingPeriodsEnabled + @drawGradingPeriodSelectButton() if @hasGradingPeriods $settingsMenu = $('.gradebook_dropdown') showConcludedEnrollmentsEl = $settingsMenu.find("#show_concluded_enrollments") @@ -1217,11 +1249,11 @@ define [ weightedGroups: => @options.group_weighting_scheme == "percent" + weightedGrades: => + @options.group_weighting_scheme == "percent" || @gradingPeriodSet?.weighted || false + displayPointTotals: => - if @weightedGroups() - false - else - @options.show_total_grade_as_points + @options.show_total_grade_as_points and not @weightedGrades() switchTotalDisplay: => @options.show_total_grade_as_points = not @options.show_total_grade_as_points @@ -1667,7 +1699,7 @@ define [ currentPeriodId == "0" hideAggregateColumns: -> - return false unless @gradingPeriodsEnabled + return false unless @hasGradingPeriods return false if @options.all_grading_periods_totals selectedPeriodId = @getGradingPeriodToShow() @isAllGradingPeriods(selectedPeriodId) diff --git a/app/coffeescripts/gradezilla/Gradebook.coffee b/app/coffeescripts/gradezilla/Gradebook.coffee index 14b7cd277d2..1de06039033 100644 --- a/app/coffeescripts/gradezilla/Gradebook.coffee +++ b/app/coffeescripts/gradezilla/Gradebook.coffee @@ -29,12 +29,14 @@ define [ 'jst/KeyboardNavDialog' 'vendor/slickgrid' 'compiled/api/gradingPeriodsApi' + 'compiled/api/gradingPeriodSetsApi' 'compiled/util/round' 'compiled/views/InputFilterView' 'i18nObj' 'i18n!gradezilla' 'compiled/gradezilla/GradebookTranslations' 'jsx/gradebook/CourseGradeCalculator' + 'jsx/gradebook/EffectiveDueDates' 'jsx/gradebook/GradingSchemeHelper' 'compiled/userSettings' 'spin.js' @@ -79,15 +81,16 @@ define [ 'compiled/jquery.kylemenu' 'compiled/jquery/fixDialogButtons' 'jsx/context_cards/StudentContextCardTrigger' -], ($, _, Backbone, tz, DataLoader, React, ReactDOM, LongTextEditor, KeyboardNavDialog, KeyboardNavTemplate, Slick, - GradingPeriodsAPI, round, InputFilterView, i18nObj, I18n, GRADEBOOK_TRANSLATIONS, CourseGradeCalculator, - GradingSchemeHelper, UserSettings, Spinner, SubmissionDetailsDialog, AssignmentGroupWeightsDialog, - GradeDisplayWarningDialog, PostGradesFrameDialog, SubmissionCell, GradebookHeaderMenu, NumberCompare, natcompare, - htmlEscape, AssignmentColumnHeader, AssignmentGroupColumnHeader, StudentColumnHeader, TotalGradeColumnHeader, - GradebookMenu, ViewOptionsMenu, ActionMenu, PostGradesStore, PostGradesApp, SubmissionStateMap, - GroupTotalCellTemplate, RowStudentNameTemplate, SectionMenuView, GradingPeriodMenuView, GradebookKeyboardNav, - assignmentHelper ) -> - +], ( + $, _, Backbone, tz, DataLoader, React, ReactDOM, LongTextEditor, KeyboardNavDialog, KeyboardNavTemplate, Slick, + GradingPeriodsApi, GradingPeriodSetsApi, round, InputFilterView, i18nObj, I18n, GRADEBOOK_TRANSLATIONS, + CourseGradeCalculator, EffectiveDueDates, GradingSchemeHelper, UserSettings, Spinner, SubmissionDetailsDialog, + AssignmentGroupWeightsDialog, GradeDisplayWarningDialog, PostGradesFrameDialog, SubmissionCell, + GradebookHeaderMenu, NumberCompare, natcompare, htmlEscape, AssignmentColumnHeader, AssignmentGroupColumnHeader, + StudentColumnHeader, TotalGradeColumnHeader, GradebookMenu, ViewOptionsMenu, ActionMenu, PostGradesStore, + PostGradesApp, SubmissionStateMap, GroupTotalCellTemplate, RowStudentNameTemplate, SectionMenuView, + GradingPeriodMenuView, GradebookKeyboardNav, assignmentHelper +) -> renderComponent = (reactClass, mountPoint, props = {}, children = null) -> component = React.createElement(reactClass, props, children) ReactDOM.render(component, mountPoint) @@ -127,11 +130,15 @@ define [ @options.settings['show_inactive_enrollments'] == "true" @totalColumnInFront = UserSettings.contextGet 'total_column_in_front' @numberOfFrozenCols = if @totalColumnInFront then 3 else 2 - @gradingPeriodsEnabled = @options.multiple_grading_periods_enabled - @gradingPeriods = GradingPeriodsAPI.deserializePeriods(@options.active_grading_periods) + @hasGradingPeriods = @options.has_grading_periods + @gradingPeriods = GradingPeriodsApi.deserializePeriods(@options.active_grading_periods) + if @options.grading_period_set + @gradingPeriodSet = GradingPeriodSetsApi.deserializeSet(@options.grading_period_set) + else + @gradingPeriodSet = null @gradingPeriodToShow = @getGradingPeriodToShow() @submissionStateMap = new SubmissionStateMap - gradingPeriodsEnabled: @gradingPeriodsEnabled + hasGradingPeriods: @hasGradingPeriods selectedGradingPeriodID: @gradingPeriodToShow isAdmin: _.contains(ENV.current_user_roles, "admin") @gradebookColumnSizeSettings = @options.gradebook_column_size_settings @@ -145,7 +152,7 @@ define [ $.subscribe 'currentGradingPeriod/change', @updateCurrentGradingPeriod assignmentGroupsParams = { exclude_response_fields: @fieldsToExcludeFromAssignments } - if @gradingPeriodsEnabled && @gradingPeriodToShow && @gradingPeriodToShow != '0' && @gradingPeriodToShow != '' + if @hasGradingPeriods && @gradingPeriodToShow && @gradingPeriodToShow != '0' && @gradingPeriodToShow != '' $.extend(assignmentGroupsParams, {grading_period_id: @gradingPeriodToShow}) $('li.external-tools-dialog > a[data-url], button.external-tools-dialog').on 'click keyclick', (event) -> @@ -158,7 +165,7 @@ define [ submissionParams = response_fields: ['id', 'user_id', 'url', 'score', 'grade', 'submission_type', 'submitted_at', 'assignment_id', 'grade_matches_current_submission', 'attachments', 'late', 'workflow_state', 'excused'] exclude_response_fields: ['preview_url'] - submissionParams['grading_period_id'] = @gradingPeriodToShow if @gradingPeriodsEnabled && @gradingPeriodToShow && @gradingPeriodToShow != '0' && @gradingPeriodToShow != '' + submissionParams['grading_period_id'] = @gradingPeriodToShow if @hasGradingPeriods && @gradingPeriodToShow && @gradingPeriodToShow != '0' && @gradingPeriodToShow != '' dataLoader = DataLoader.loadGradebookData( assignmentGroupsURL: @options.assignment_groups_url assignmentGroupsParams: assignmentGroupsParams @@ -270,7 +277,7 @@ define [ _.contains(activePeriodIds, gradingPeriodId) getGradingPeriodToShow: () => - return null unless @gradingPeriodsEnabled + return null unless @hasGradingPeriods currentPeriodId = UserSettings.contextGet('gradebook_current_grading_period') if currentPeriodId && (@isAllGradingPeriods(currentPeriodId) || @gradingPeriodIsActive(currentPeriodId)) currentPeriodId @@ -737,7 +744,7 @@ define [ templateOpts.warning = @totalGradeWarning templateOpts.lastColumn = true templateOpts.showPointsNotPercent = @displayPointTotals() - templateOpts.hideTooltip = @weightedGroups() and not @totalGradeWarning + templateOpts.hideTooltip = @weightedGrades() and not @totalGradeWarning GroupTotalCellTemplate templateOpts htmlContentFormatter: (row, col, val, columnDef, student) -> @@ -750,7 +757,7 @@ define [ submissionsForStudent: (student) => allSubmissions = (value for key, value of student when key.match /^assignment_(?!group)/) - return allSubmissions unless @gradingPeriodsEnabled + return allSubmissions unless @hasGradingPeriods return allSubmissions if !@gradingPeriodToShow or @isAllGradingPeriods(@gradingPeriodToShow) _.filter allSubmissions, (submission) => @@ -759,17 +766,26 @@ define [ calculateStudentGrade: (student) => if student.loaded and student.initialized - finalOrCurrent = if @include_ungraded_assignments then 'final' else 'current' - result = CourseGradeCalculator.calculate( + hasGradingPeriods = @gradingPeriodSet and @effectiveDueDates + + grades = CourseGradeCalculator.calculate( @submissionsForStudent(student), @assignmentGroups, - @options.group_weighting_scheme + @options.group_weighting_scheme, + @gradingPeriodSet if hasGradingPeriods, + EffectiveDueDates.scopeToUser(@effectiveDueDates, student.id) if hasGradingPeriods ) - for group in result.group_sums - student["assignment_group_#{group.group.id}"] = group[finalOrCurrent] - for submissionData in group[finalOrCurrent].submissions + + if @gradingPeriodToShow && !@isAllGradingPeriods(@gradingPeriodToShow) + grades = grades.gradingPeriods[@gradingPeriodToShow] + + finalOrCurrent = if @include_ungraded_assignments then 'final' else 'current' + + for assignmentGroupId, grade of grades.assignmentGroups + student["assignment_group_#{assignmentGroupId}"] = grade[finalOrCurrent] + for submissionData in grade[finalOrCurrent].submissions submissionData.submission.drop = submissionData.drop - student["total_grade"] = result[finalOrCurrent] + student["total_grade"] = grades[finalOrCurrent] @addDroppedClass(student) @@ -1062,7 +1078,7 @@ define [ initHeader: => @renderGradebookMenus() @drawSectionSelectButton() if @sections_enabled - @drawGradingPeriodSelectButton() if @gradingPeriodsEnabled + @drawGradingPeriodSelectButton() if @hasGradingPeriods $settingsMenu = $('.gradebook_dropdown') showConcludedEnrollmentsEl = $settingsMenu.find("#show_concluded_enrollments") @@ -1185,11 +1201,11 @@ define [ weightedGroups: => @options.group_weighting_scheme == "percent" + weightedGrades: => + @options.group_weighting_scheme == "percent" || @gradingPeriodSet?.weighted || false + displayPointTotals: => - if @weightedGroups() - false - else - @options.show_total_grade_as_points + @options.show_total_grade_as_points and not @weightedGrades() switchTotalDisplay: => @options.show_total_grade_as_points = not @options.show_total_grade_as_points @@ -1648,7 +1664,7 @@ define [ currentPeriodId == "0" hideAggregateColumns: -> - return false unless @gradingPeriodsEnabled + return false unless @hasGradingPeriods return false if @options.all_grading_periods_totals selectedPeriodId = @getGradingPeriodToShow() @isAllGradingPeriods(selectedPeriodId) diff --git a/app/coffeescripts/util/DateValidator.coffee b/app/coffeescripts/util/DateValidator.coffee index e6f508a773f..39f8fcd3374 100644 --- a/app/coffeescripts/util/DateValidator.coffee +++ b/app/coffeescripts/util/DateValidator.coffee @@ -30,7 +30,7 @@ define [ constructor: (params) -> @dateRange = params['date_range'] @data = params['data'] - @multipleGradingPeriodsEnabled = params.multipleGradingPeriodsEnabled + @hasGradingPeriods = params.hasGradingPeriods @gradingPeriods = params.gradingPeriods @userIsAdmin = params.userIsAdmin @dueDateRequired = params.postToSIS && ENV.DUE_DATE_REQUIRED_FOR_ACCOUNT @@ -79,7 +79,7 @@ define [ dueDateRequired: @dueDateRequired, } - if @multipleGradingPeriodsEnabled && !@userIsAdmin && @data.persisted == false + if @hasGradingPeriods && !@userIsAdmin && @data.persisted == false datetimesToValidate.push { date: dueAt, range: "grading_period_range", diff --git a/app/coffeescripts/views/assignments/CreateAssignmentView.coffee b/app/coffeescripts/views/assignments/CreateAssignmentView.coffee index 748845d6aba..557174b6667 100644 --- a/app/coffeescripts/views/assignments/CreateAssignmentView.coffee +++ b/app/coffeescripts/views/assignments/CreateAssignmentView.coffee @@ -159,7 +159,7 @@ define [ dateValidator = new DateValidator( date_range: _.extend({}, validRange) data: data - multipleGradingPeriodsEnabled: !!ENV.MULTIPLE_GRADING_PERIODS_ENABLED + hasGradingPeriods: !!ENV.HAS_GRADING_PERIODS gradingPeriods: GradingPeriodsAPI.deserializePeriods(ENV.active_grading_periods) userIsAdmin: @currentUserIsAdmin(), data diff --git a/app/coffeescripts/views/assignments/DueDateOverride.coffee b/app/coffeescripts/views/assignments/DueDateOverride.coffee index a17666fadf5..84e6101e93d 100644 --- a/app/coffeescripts/views/assignments/DueDateOverride.coffee +++ b/app/coffeescripts/views/assignments/DueDateOverride.coffee @@ -44,7 +44,7 @@ define [ defaultSectionId: @model.defaultDueDateSectionId, selectedGroupSetId: @model.assignment.get("group_category_id"), gradingPeriods: @gradingPeriods, - multipleGradingPeriodsEnabled: @multipleGradingPeriodsEnabled, + hasGradingPeriods: @hasGradingPeriods, isOnlyVisibleToOverrides: @model.assignment.isOnlyVisibleToOverrides(), dueAt: tz.parse(@model.assignment.get("due_at")) }) @@ -53,7 +53,7 @@ define [ gradingPeriods: GradingPeriodsAPI.deserializePeriods(ENV.active_grading_periods) - multipleGradingPeriodsEnabled: !!ENV.MULTIPLE_GRADING_PERIODS_ENABLED + hasGradingPeriods: !!ENV.HAS_GRADING_PERIODS validateBeforeSave: (data, errors) => return errors unless data @@ -69,7 +69,7 @@ define [ dateValidator = new DateValidator({ date_range: _.extend({}, ENV.VALID_DATE_RANGE) data: override - multipleGradingPeriodsEnabled: @multipleGradingPeriodsEnabled + hasGradingPeriods: @hasGradingPeriods gradingPeriods: @gradingPeriods userIsAdmin: _.contains(ENV.current_user_roles, "admin"), postToSIS: @options.postToSIS || data.postToSIS == '1' diff --git a/app/coffeescripts/views/assignments/IndexView.coffee b/app/coffeescripts/views/assignments/IndexView.coffee index ab952eddbdf..fb150687a44 100644 --- a/app/coffeescripts/views/assignments/IndexView.coffee +++ b/app/coffeescripts/views/assignments/IndexView.coffee @@ -104,7 +104,7 @@ define [ filterResults: => term = $('#search_term').val() gradingPeriod = null - if ENV.MULTIPLE_GRADING_PERIODS_ENABLED + if ENV.HAS_GRADING_PERIODS gradingPeriodIndex = $("#grading_period_selector").val() gradingPeriod = @gradingPeriods[parseInt(gradingPeriodIndex)] if gradingPeriodIndex != "all" @saveSelectedGradingPeriod(gradingPeriod) diff --git a/app/controllers/application_controller.rb b/app/controllers/application_controller.rb index f1ce7a60070..f6a42f916f8 100644 --- a/app/controllers/application_controller.rb +++ b/app/controllers/application_controller.rb @@ -235,11 +235,10 @@ class ApplicationController < ActionController::Base end helper_method :k12? - def multiple_grading_periods? - account_and_grading_periods_allowed? || - context_grading_periods_enabled? + def grading_periods? + !!@context.try(:grading_periods?) end - helper_method :multiple_grading_periods? + helper_method :grading_periods? def master_courses? @domain_root_account && @domain_root_account.feature_enabled?(:master_courses) @@ -277,18 +276,6 @@ class ApplicationController < ActionController::Base end private :tool_dimensions - def account_and_grading_periods_allowed? - @context.is_a?(Account) && - @context.feature_allowed?(:multiple_grading_periods) - end - private :account_and_grading_periods_allowed? - - def context_grading_periods_enabled? - @context.present? && - @context.feature_enabled?(:multiple_grading_periods) - end - private :context_grading_periods_enabled? - # Reject the request by halting the execution of the current handler # and returning a helpful error message (and HTTP status code). # @@ -2050,7 +2037,7 @@ class ApplicationController < ActionController::Base }, :POST_TO_SIS => Assignment.sis_grade_export_enabled?(@context), :PERMISSIONS => permissions, - :MULTIPLE_GRADING_PERIODS_ENABLED => @context.feature_enabled?(:multiple_grading_periods), + :HAS_GRADING_PERIODS => @context.grading_periods?, :VALID_DATE_RANGE => CourseDateRange.new(@context), :assignment_menu_tools => external_tools_display_hashes(:assignment_menu), :discussion_topic_menu_tools => external_tools_display_hashes(:discussion_topic_menu), @@ -2061,7 +2048,7 @@ class ApplicationController < ActionController::Base conditional_release_js_env(includes: :active_rules) - if @context.feature_enabled?(:multiple_grading_periods) + if @context.grading_periods? js_env(:active_grading_periods => GradingPeriod.json_for(@context, @current_user)) end end diff --git a/app/controllers/assignment_groups_api_controller.rb b/app/controllers/assignment_groups_api_controller.rb index a2602479631..581cea7b196 100644 --- a/app/controllers/assignment_groups_api_controller.rb +++ b/app/controllers/assignment_groups_api_controller.rb @@ -37,7 +37,7 @@ class AssignmentGroupsApiController < ApplicationController # # @argument grading_period_id [Integer] # The id of the grading period in which assignment groups are being requested - # (Requires the Multiple Grading Periods account feature turned on) + # (Requires grading periods to exist on the account) # # @returns AssignmentGroup def show @@ -45,7 +45,7 @@ class AssignmentGroupsApiController < ApplicationController includes = Array(params[:include]) override_dates = value_to_boolean(params[:override_assignment_dates] || true) assignments = @assignment_group.visible_assignments(@current_user) - if params[:grading_period_id].present? && multiple_grading_periods? + if params[:grading_period_id].present? assignments = GradingPeriod.for(@context).find_by(id: params[:grading_period_id]).assignments(assignments) end if assignments.any? && includes.include?('submission') diff --git a/app/controllers/assignment_groups_controller.rb b/app/controllers/assignment_groups_controller.rb index 1c98e90f9b7..dfb109b0fca 100644 --- a/app/controllers/assignment_groups_controller.rb +++ b/app/controllers/assignment_groups_controller.rb @@ -115,14 +115,14 @@ class AssignmentGroupsController < ApplicationController # # @argument grading_period_id [Integer] # The id of the grading period in which assignment groups are being requested - # (Requires the Multiple Grading Periods feature turned on.) + # (Requires grading periods to exist.) # # @argument scope_assignments_to_student [Boolean] # If true, all assignments returned will apply to the current user in the # specified grading period. If assignments apply to other students in the # specified grading period, but not the current user, they will not be - # returned. (Requires the grading_period_id argument and the Multiple Grading - # Periods feature turned on. In addition, the current user must be a student.) + # returned. (Requires the grading_period_id argument and grading periods to + # exist. In addition, the current user must be a student.) # # @returns [AssignmentGroup] def index @@ -295,8 +295,7 @@ class AssignmentGroupsController < ApplicationController end def filter_by_grading_period? - return false if all_grading_periods_selected? - params[:grading_period_id].present? && multiple_grading_periods? + params[:grading_period_id].present? && !all_grading_periods_selected? end def all_grading_periods_selected? @@ -390,7 +389,7 @@ class AssignmentGroupsController < ApplicationController assignments = assignments.with_student_submission_count.all - if params[:grading_period_id].present? && multiple_grading_periods? + if filter_by_grading_period? assignments = filter_assignments_by_grading_period(assignments, context) end @@ -427,7 +426,7 @@ class AssignmentGroupsController < ApplicationController end def can_reorder_assignments?(assignments, group) - return true unless @context.feature_enabled?(:multiple_grading_periods) + return true unless @context.grading_periods? return true if @context.account_membership_allows(@current_user) effective_due_dates = EffectiveDueDates.for_course(@context, assignments) diff --git a/app/controllers/assignments_controller.rb b/app/controllers/assignments_controller.rb index 45706999d92..8d77d009634 100644 --- a/app/controllers/assignments_controller.rb +++ b/app/controllers/assignments_controller.rb @@ -443,7 +443,7 @@ class AssignmentsController < ApplicationController GROUP_CATEGORIES: group_categories, HAS_GRADED_SUBMISSIONS: @assignment.graded_submissions_exist?, KALTURA_ENABLED: !!feature_enabled?(:kaltura), - MULTIPLE_GRADING_PERIODS_ENABLED: @context.feature_enabled?(:multiple_grading_periods), + HAS_GRADING_PERIODS: @context.grading_periods?, PLAGIARISM_DETECTION_PLATFORM: @context.root_account.feature_enabled?(:plagiarism_detection_platform), POST_TO_SIS: post_to_sis, SIS_NAME: AssignmentUtil.post_to_sis_friendly_name(@assignment), @@ -475,7 +475,7 @@ class AssignmentsController < ApplicationController hash[:SELECTED_CONFIG_TOOL_ID] = selected_tool ? selected_tool.id : nil hash[:SELECTED_CONFIG_TOOL_TYPE] = selected_tool ? selected_tool.class.to_s : nil - if @context.feature_enabled?(:multiple_grading_periods) + if @context.grading_periods? hash[:active_grading_periods] = GradingPeriod.json_for(@context, @current_user) end append_sis_data(hash) diff --git a/app/controllers/courses_controller.rb b/app/controllers/courses_controller.rb index 28b5f1193a1..2bdc4bcea83 100644 --- a/app/controllers/courses_controller.rb +++ b/app/controllers/courses_controller.rb @@ -359,14 +359,14 @@ class CoursesController < ApplicationController # - "current_grading_period_scores": Optional information to include with # each Course. When current_grading_period_scores is given and total_scores # is given, any student enrollments will also include the fields - # 'multiple_grading_periods_enabled', + # 'has_grading_periods', # 'totals_for_all_grading_periods_option', 'current_grading_period_title', # 'current_grading_period_id', current_period_computed_current_score', # 'current_period_computed_final_score', # 'current_period_computed_current_grade', and # 'current_period_computed_final_grade' (see Enrollment documentation for # more information on these fields). In addition, when this argument is - # passed, the course will have a 'multiple_grading_periods_enabled' attribute + # passed, the course will have a 'has_grading_periods' attribute # on it. This argument is ignored if the course is configured to hide final # grades or if the total_scores argument is not included. # - "term": Optional information to include with each Course. When @@ -474,14 +474,14 @@ class CoursesController < ApplicationController # - "current_grading_period_scores": Optional information to include with # each Course. When current_grading_period_scores is given and total_scores # is given, any student enrollments will also include the fields - # 'multiple_grading_periods_enabled', + # 'has_grading_periods', # 'totals_for_all_grading_periods_option', 'current_grading_period_title', # 'current_grading_period_id', current_period_computed_current_score', # 'current_period_computed_final_score', # 'current_period_computed_current_grade', and # 'current_period_computed_final_grade' (see Enrollment documentation for # more information on these fields). In addition, when this argument is - # passed, the course will have a 'multiple_grading_periods_enabled' attribute + # passed, the course will have a 'has_grading_periods' attribute # on it. This argument is ignored if the course is configured to hide final # grades or if the total_scores argument is not included. # - "term": Optional information to include with each Course. When @@ -2474,9 +2474,9 @@ class CoursesController < ApplicationController # @API Get effective due dates # For each assignment in the course, returns each assigned student's ID - # and their corresponding due date along with some Multiple Grading Periods - # data. Returns a collection with keys representing assignment IDs and values - # as a collection containing keys representing student IDs and values representing + # and their corresponding due date along with some grading period data. + # Returns a collection with keys representing assignment IDs and values as a + # collection containing keys representing student IDs and values representing # the student's effective due_at, the grading_period_id of which the due_at falls # in, and whether or not the grading period is closed (in_closed_grading_period) # @@ -2805,7 +2805,7 @@ class CoursesController < ApplicationController end def can_change_group_weighting_scheme? - return true unless @course.feature_enabled?(:multiple_grading_periods) + return true unless @course.grading_periods? return true if @course.account_membership_allows(@current_user) !@course.any_assignment_in_closed_grading_period? end diff --git a/app/controllers/discussion_topics_controller.rb b/app/controllers/discussion_topics_controller.rb index 6e2ffcfe658..15dfd827d29 100644 --- a/app/controllers/discussion_topics_controller.rb +++ b/app/controllers/discussion_topics_controller.rb @@ -448,7 +448,7 @@ class DiscussionTopicsController < ApplicationController GROUP_CATEGORIES: categories. reject(&:student_organized?). map { |category| { id: category.id, name: category.name } }, - MULTIPLE_GRADING_PERIODS_ENABLED: @context.feature_enabled?(:multiple_grading_periods), + HAS_GRADING_PERIODS: @context.grading_periods?, SECTION_LIST: sections.map { |section| { id: section.id, name: section.name } } } @@ -479,7 +479,7 @@ class DiscussionTopicsController < ApplicationController js_hash[:CANCEL_TO] = cancel_redirect_url append_sis_data(js_hash) - if @context.feature_enabled?(:multiple_grading_periods) + if @context.grading_periods? gp_context = @context.is_a?(Group) ? @context.context : @context js_hash[:active_grading_periods] = GradingPeriod.json_for(gp_context, @current_user) end diff --git a/app/controllers/enrollments_api_controller.rb b/app/controllers/enrollments_api_controller.rb index 41960d98cd2..77f9ef58296 100644 --- a/app/controllers/enrollments_api_controller.rb +++ b/app/controllers/enrollments_api_controller.rb @@ -207,8 +207,8 @@ # "example": "B-", # "type": "string" # }, -# "multiple_grading_periods_enabled": { -# "description": "optional: Indicates whether the course the enrollment belongs to has the Multiple Grading Periods feature enabled. (applies only to student enrollments, and only available in course endpoints)", +# "has_grading_periods": { +# "description": "optional: Indicates whether the course the enrollment belongs to has grading periods set up. (applies only to student enrollments, and only available in course endpoints)", # "example": true, # "type": "boolean" # }, @@ -218,32 +218,32 @@ # "type": "boolean" # }, # "current_grading_period_title": { -# "description": "optional: The name of the currently active grading period, if one exists. If the course the enrollment belongs to does not have Multiple Grading Periods enabled, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", +# "description": "optional: The name of the currently active grading period, if one exists. If the course the enrollment belongs to does not have grading periods, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", # "example": "Fall Grading Period", # "type": "string" # }, # "current_grading_period_id": { -# "description": "optional: The id of the currently active grading period, if one exists. If the course the enrollment belongs to does not have Multiple Grading Periods enabled, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", +# "description": "optional: The id of the currently active grading period, if one exists. If the course the enrollment belongs to does not have grading periods, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", # "example": 5, # "type": "integer" # }, # "current_period_computed_current_score": { -# "description": "optional: The student's score in the course for the current grading period, ignoring ungraded assignments. If the course the enrollment belongs to does not have Multiple Grading Periods enabled, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", +# "description": "optional: The student's score in the course for the current grading period, ignoring ungraded assignments. If the course the enrollment belongs to does not have grading periods, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", # "example": 95.80, # "type": "number" # }, # "current_period_computed_final_score": { -# "description": "optional: The student's score in the course for the current grading period, including ungraded assignments with a score of 0. If the course the enrollment belongs to does not have Multiple Grading Periods enabled, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", +# "description": "optional: The student's score in the course for the current grading period, including ungraded assignments with a score of 0. If the course the enrollment belongs to does not have grading periods, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", # "example": 85.25, # "type": "number" # }, # "current_period_computed_current_grade": { -# "description": "optional: The letter grade equivalent of current_period_computed_current_score, if available. If the course the enrollment belongs to does not have Multiple Grading Periods enabled, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", +# "description": "optional: The letter grade equivalent of current_period_computed_current_score, if available. If the course the enrollment belongs to does not have grading periods, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", # "example": "A", # "type": "string" # }, # "current_period_computed_final_grade": { -# "description": "optional: The letter grade equivalent of current_period_computed_final_score, if available. If the course the enrollment belongs to does not have Multiple Grading Periods enabled, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", +# "description": "optional: The letter grade equivalent of current_period_computed_final_score, if available. If the course the enrollment belongs to does not have grading periods, or if no currently active grading period exists, the value will be null. (applies only to student enrollments, and only available in course endpoints)", # "example": "B", # "type": "string" # } @@ -262,7 +262,6 @@ class EnrollmentsApiController < ApplicationController :inactive_role => 'Cannot create an enrollment with this role because it is inactive.', :base_type_mismatch => 'The specified type must match the base type for the role', :concluded_course => 'Can\'t add an enrollment to a concluded course.', - :multiple_grading_periods_disabled => 'Multiple Grading Periods feature is disabled. Cannot filter by grading_period_id with this feature disabled', :insufficient_sis_permissions => 'Insufficient permissions to filter by SIS fields' } @@ -371,20 +370,10 @@ class EnrollmentsApiController < ApplicationController if params[:grading_period_id].present? if @context.is_a? User - unless @context.account.feature_enabled?(:multiple_grading_periods) - render_create_errors([@@errors[:multiple_grading_periods_disabled]]) - return false - end - grading_period = @context.courses.lazy.map do |course| GradingPeriod.for(course).find_by(id: params[:grading_period_id]) end.detect(&:present?) else - unless multiple_grading_periods? - render_create_errors([@@errors[:multiple_grading_periods_disabled]]) - return false - end - grading_period = GradingPeriod.for(@context).find_by(id: params[:grading_period_id]) end diff --git a/app/controllers/filters/grading_periods.rb b/app/controllers/filters/grading_periods.rb deleted file mode 100644 index 669590aceaa..00000000000 --- a/app/controllers/filters/grading_periods.rb +++ /dev/null @@ -1,11 +0,0 @@ -module Filters::GradingPeriods - def check_feature_flag - unless multiple_grading_periods? - if api_request? - render json: {message: t('Page not found')}, status: :not_found - else - render status: 404, template: "shared/errors/404_message" - end - end - end -end diff --git a/app/controllers/gradebooks_controller.rb b/app/controllers/gradebooks_controller.rb index bc6d6e683d8..9c80ebc7296 100644 --- a/app/controllers/gradebooks_controller.rb +++ b/app/controllers/gradebooks_controller.rb @@ -36,7 +36,7 @@ class GradebooksController < ApplicationController MAX_POST_GRADES_TOOLS = 10 def grade_summary - set_current_grading_period if multiple_grading_periods? + set_current_grading_period if grading_periods? @presenter = grade_summary_presenter # do this as the very first thing, if the current user is a # teacher in the course and they are not trying to view another @@ -59,9 +59,10 @@ class GradebooksController < ApplicationController add_crumb(@presenter.student_name, named_context_url(@context, :context_student_grades_url, @presenter.student_id)) gp_id = nil - if multiple_grading_periods? + if grading_periods? @grading_periods = active_grading_periods_json gp_id = @current_grading_period_id unless view_all_grading_periods? + effective_due_dates = EffectiveDueDates.new(@context).to_hash end @exclude_total = exclude_total?(@context) @@ -89,7 +90,6 @@ class GradebooksController < ApplicationController grading_period = @grading_periods && @grading_periods.find { |period| period[:id] == gp_id } - ags_json = light_weight_ags_json(@presenter.groups, {student: @presenter.student}) grading_scheme = @context.grading_standard.try(:data) || @@ -100,7 +100,10 @@ class GradebooksController < ApplicationController group_weighting_scheme: @context.group_weighting_scheme, show_total_grade_as_points: @context.settings[:show_total_grade_as_points], grading_scheme: grading_scheme, + grading_period_set: grading_period_group_json, grading_period: grading_period, + grading_periods: @grading_periods, + effective_due_dates: effective_due_dates, exclude_total: @exclude_total, student_outcome_gradebook_enabled: @context.feature_enabled?(:student_outcome_gradebook), student_id: @presenter.student_id) @@ -124,7 +127,7 @@ class GradebooksController < ApplicationController assignment_groups.map do |ag| visible_assignments = ag.visible_assignments(opts[:student] || @current_user).to_a - if multiple_grading_periods? && @current_grading_period_id && !view_all_grading_periods? + if grading_periods? && @current_grading_period_id && !view_all_grading_periods? current_period = GradingPeriod.for(@context).find_by(id: @current_grading_period_id) visible_assignments = current_period.assignments_for_student(visible_assignments, opts[:student]) end @@ -193,7 +196,7 @@ class GradebooksController < ApplicationController def show if authorized_action(@context, @current_user, [:manage_grades, :view_all_grades]) @last_exported_gradebook_csv = GradebookCsv.last_successful_export(course: @context, user: @current_user) - set_current_grading_period if multiple_grading_periods? + set_current_grading_period if grading_periods? set_js_env @course_is_concluded = @context.completed? @post_grades_tools = post_grades_tools @@ -289,27 +292,34 @@ class GradebooksController < ApplicationController @current_grading_period_id == 0 end + def grading_period_group + return @grading_period_group if defined? @grading_period_group + + @grading_period_group = active_grading_periods.first&.grading_period_group + end + def active_grading_periods @active_grading_periods ||= GradingPeriod.for(@context).sort_by(&:start_date) end + def grading_period_group_json + return @grading_period_group_json if defined? @grading_period_group_json + return @grading_period_group_json = nil unless grading_period_group.present? + + @grading_period_group_json = grading_period_group + .as_json + .fetch(:grading_period_group) + .merge(grading_periods: active_grading_periods_json) + end + def active_grading_periods_json @agp_json ||= GradingPeriod.periods_json(active_grading_periods, @current_user) end - def latest_end_date_of_admin_created_grading_periods_in_the_past - periods = active_grading_periods.select do |period| - admin_created = period.account_group? - admin_created && period.end_date.past? - end - periods.map(&:end_date).compact.sort.last - end - private :latest_end_date_of_admin_created_grading_periods_in_the_past - def set_js_env @gradebook_is_editable = @context.grants_right?(@current_user, session, :manage_grades) per_page = Setting.get('api_max_per_page', '50').to_i - teacher_notes = @context.custom_gradebook_columns.not_deleted.where(:teacher_notes=> true).first + teacher_notes = @context.custom_gradebook_columns.not_deleted.where(teacher_notes: true).first ag_includes = [:assignments, :assignment_visibility] chunk_size = if @context.assignments.published.count < Setting.get('gradebook2.assignments_threshold', '20').to_i Setting.get('gradebook2.submissions_chunk_size', '35').to_i @@ -317,82 +327,88 @@ class GradebooksController < ApplicationController Setting.get('gradebook2.many_submissions_chunk_size', '10').to_i end js_env STUDENT_CONTEXT_CARDS_ENABLED: @domain_root_account.feature_enabled?(:student_context_cards) - js_env :GRADEBOOK_OPTIONS => { - :gradezilla => @context.root_account.feature_enabled?(:gradezilla), - :chunk_size => chunk_size, - :assignment_groups_url => api_v1_course_assignment_groups_url( + js_env GRADEBOOK_OPTIONS: { + gradezilla: @context.root_account.feature_enabled?(:gradezilla), + chunk_size: chunk_size, + assignment_groups_url: api_v1_course_assignment_groups_url( @context, include: ag_includes, override_assignment_dates: "false", exclude_assignment_submission_types: ['wiki_page'] ), - :sections_url => api_v1_course_sections_url(@context), - :course_url => api_v1_course_url(@context), - :effective_due_dates_url => api_v1_course_effective_due_dates_url(@context), - :enrollments_url => custom_course_enrollments_api_url(per_page: per_page), - :enrollments_with_concluded_url => + sections_url: api_v1_course_sections_url(@context), + course_url: api_v1_course_url(@context), + effective_due_dates_url: api_v1_course_effective_due_dates_url(@context), + enrollments_url: custom_course_enrollments_api_url(per_page: per_page), + enrollments_with_concluded_url: custom_course_enrollments_api_url(include_concluded: true, per_page: per_page), - :enrollments_with_inactive_url => + enrollments_with_inactive_url: custom_course_enrollments_api_url(include_inactive: true, per_page: per_page), - :enrollments_with_concluded_and_inactive_url => + enrollments_with_concluded_and_inactive_url: custom_course_enrollments_api_url(include_concluded: true, include_inactive: true, per_page: per_page), - :students_url => custom_course_users_api_url(per_page: per_page), - :students_with_concluded_enrollments_url => + students_url: custom_course_users_api_url(per_page: per_page), + students_with_concluded_enrollments_url: custom_course_users_api_url(include_concluded: true, per_page: per_page), - :students_with_inactive_enrollments_url => + students_with_inactive_enrollments_url: custom_course_users_api_url(include_inactive: true, per_page: per_page), - :students_with_concluded_and_inactive_enrollments_url => + students_with_concluded_and_inactive_enrollments_url: custom_course_users_api_url(include_concluded: true, include_inactive: true, per_page: per_page), - :submissions_url => api_v1_course_student_submissions_url(@context, :grouped => '1'), - :outcome_links_url => api_v1_course_outcome_group_links_url(@context, :outcome_style => :full), - :outcome_rollups_url => api_v1_course_outcome_rollups_url(@context, :per_page => 100), - :change_grade_url => api_v1_course_assignment_submission_url(@context, ":assignment", ":submission", :include =>[:visibility]), - :context_url => named_context_url(@context, :context_url), - :download_assignment_submissions_url => named_context_url(@context, :context_assignment_submissions_url, "{{ assignment_id }}", :zip => 1), - :re_upload_submissions_url => named_context_url(@context, :submissions_upload_context_gradebook_url, "{{ assignment_id }}"), - :context_id => @context.id.to_s, - :context_code => @context.asset_string, - :context_sis_id => @context.sis_source_id, - :group_weighting_scheme => @context.group_weighting_scheme, - :grading_standard => @context.grading_standard_enabled? && (@context.grading_standard.try(:data) || GradingStandard.default_grading_standard), - :course_is_concluded => @context.completed?, - :course_name => @context.name, - :gradebook_is_editable => @gradebook_is_editable, - :context_allows_gradebook_uploads => @context.allows_gradebook_uploads?, - :gradebook_import_url => new_course_gradebook_upload_path(@context), - :setting_update_url => api_v1_course_settings_url(@context), - :show_total_grade_as_points => @context.settings[:show_total_grade_as_points], - :publish_to_sis_enabled => @context.allows_grade_publishing_by(@current_user) && @gradebook_is_editable, - :publish_to_sis_url => context_url(@context, :context_details_url, :anchor => 'tab-grade-publishing'), - :speed_grader_enabled => @context.allows_speed_grader?, - :multiple_grading_periods_enabled => multiple_grading_periods?, - :active_grading_periods => active_grading_periods_json, - :latest_end_date_of_admin_created_grading_periods_in_the_past => latest_end_date_of_admin_created_grading_periods_in_the_past, - :current_grading_period_id => @current_grading_period_id, - :outcome_gradebook_enabled => @context.feature_enabled?(:outcome_gradebook), - :custom_columns_url => api_v1_course_custom_gradebook_columns_url(@context), - :custom_column_url => api_v1_course_custom_gradebook_column_url(@context, ":id"), - :custom_column_data_url => api_v1_course_custom_gradebook_column_data_url(@context, ":id", per_page: per_page), - :custom_column_datum_url => api_v1_course_custom_gradebook_column_datum_url(@context, ":id", ":user_id"), - :reorder_custom_columns_url => api_v1_custom_gradebook_columns_reorder_url(@context), - :teacher_notes => teacher_notes && custom_gradebook_column_json(teacher_notes, @current_user, session), - :change_gradebook_version_url => context_url(@context, :change_gradebook_version_context_gradebook_url, :version => 2), - :export_gradebook_csv_url => course_gradebook_csv_url, - :gradebook_csv_progress => @last_exported_gradebook_csv.try(:progress), - :attachment_url => @last_exported_gradebook_csv.try(:attachment).try(:download_url), - :attachment => @last_exported_gradebook_csv.try(:attachment), - :sis_app_url => Setting.get('sis_app_url', nil), - :sis_app_token => Setting.get('sis_app_token', nil), - :list_students_by_sortable_name_enabled => @context.list_students_by_sortable_name?, - :gradebook_column_size_settings => @current_user.preferences[:gradebook_column_size], - :gradebook_column_size_settings_url => change_gradebook_column_size_course_gradebook_url, - :gradebook_column_order_settings => @current_user.preferences[:gradebook_column_order].try(:[], @context.id), - :gradebook_column_order_settings_url => save_gradebook_column_order_course_gradebook_url, - :all_grading_periods_totals => @context.feature_enabled?(:all_grading_periods_totals), - :sections => sections_json(@context.active_course_sections, @current_user, session), - :settings_update_url => api_v1_course_gradebook_settings_update_url(@context), - :settings => @current_user.preferences.fetch(:gradebook_settings, {}).fetch(@context.id, {}), - :version => params.fetch(:version, nil) + submissions_url: api_v1_course_student_submissions_url(@context, grouped: '1'), + outcome_links_url: api_v1_course_outcome_group_links_url(@context, outcome_style: :full), + outcome_rollups_url: api_v1_course_outcome_rollups_url(@context, per_page: 100), + change_grade_url: + api_v1_course_assignment_submission_url(@context, ":assignment", ":submission", include: [:visibility]), + context_url: named_context_url(@context, :context_url), + download_assignment_submissions_url: + named_context_url(@context, :context_assignment_submissions_url, "{{ assignment_id }}", zip: 1), + re_upload_submissions_url: + named_context_url(@context, :submissions_upload_context_gradebook_url, "{{ assignment_id }}"), + context_id: @context.id.to_s, + context_code: @context.asset_string, + context_sis_id: @context.sis_source_id, + group_weighting_scheme: @context.group_weighting_scheme, + grading_standard: ( + @context.grading_standard_enabled? && + (@context.grading_standard.try(:data) || GradingStandard.default_grading_standard) + ), + course_is_concluded: @context.completed?, + course_name: @context.name, + gradebook_is_editable: @gradebook_is_editable, + context_allows_gradebook_uploads: @context.allows_gradebook_uploads?, + gradebook_import_url: new_course_gradebook_upload_path(@context), + setting_update_url: api_v1_course_settings_url(@context), + show_total_grade_as_points: @context.settings[:show_total_grade_as_points], + publish_to_sis_enabled: @context.allows_grade_publishing_by(@current_user) && @gradebook_is_editable, + publish_to_sis_url: context_url(@context, :context_details_url, anchor: 'tab-grade-publishing'), + speed_grader_enabled: @context.allows_speed_grader?, + has_grading_periods: grading_periods?, + active_grading_periods: active_grading_periods_json, + grading_period_set: grading_period_group_json, + current_grading_period_id: @current_grading_period_id, + outcome_gradebook_enabled: @context.feature_enabled?(:outcome_gradebook), + custom_columns_url: api_v1_course_custom_gradebook_columns_url(@context), + custom_column_url: api_v1_course_custom_gradebook_column_url(@context, ":id"), + custom_column_data_url: api_v1_course_custom_gradebook_column_data_url(@context, ":id", per_page: per_page), + custom_column_datum_url: api_v1_course_custom_gradebook_column_datum_url(@context, ":id", ":user_id"), + reorder_custom_columns_url: api_v1_custom_gradebook_columns_reorder_url(@context), + teacher_notes: teacher_notes && custom_gradebook_column_json(teacher_notes, @current_user, session), + change_gradebook_version_url: context_url(@context, :change_gradebook_version_context_gradebook_url, version: 2), + export_gradebook_csv_url: course_gradebook_csv_url, + gradebook_csv_progress: @last_exported_gradebook_csv.try(:progress), + attachment_url: @last_exported_gradebook_csv.try(:attachment).try(:download_url), + attachment: @last_exported_gradebook_csv.try(:attachment), + sis_app_url: Setting.get('sis_app_url', nil), + sis_app_token: Setting.get('sis_app_token', nil), + list_students_by_sortable_name_enabled: @context.list_students_by_sortable_name?, + gradebook_column_size_settings: @current_user.preferences[:gradebook_column_size], + gradebook_column_size_settings_url: change_gradebook_column_size_course_gradebook_url, + gradebook_column_order_settings: @current_user.preferences[:gradebook_column_order].try(:[], @context.id), + gradebook_column_order_settings_url: save_gradebook_column_order_course_gradebook_url, + all_grading_periods_totals: @context.feature_enabled?(:all_grading_periods_totals), + sections: sections_json(@context.active_course_sections, @current_user, session), + settings_update_url: api_v1_course_gradebook_settings_update_url(@context), + settings: @current_user.preferences.fetch(:gradebook_settings, {}).fetch(@context.id, {}), + version: params.fetch(:version, nil) } end @@ -752,7 +768,7 @@ class GradebooksController < ApplicationController return true if context.hide_final_grades all_grading_periods_selected = - multiple_grading_periods? && view_all_grading_periods? + grading_periods? && view_all_grading_periods? hide_all_grading_periods_totals = !context.feature_enabled?(:all_grading_periods_totals) all_grading_periods_selected && hide_all_grading_periods_totals end @@ -787,7 +803,7 @@ class GradebooksController < ApplicationController options = {} return options unless @context.present? - if @current_grading_period_id.present? && !view_all_grading_periods? && multiple_grading_periods? + if @current_grading_period_id.present? && !view_all_grading_periods? && grading_periods? options[:grading_period_id] = @current_grading_period_id end diff --git a/app/controllers/grading_period_sets_controller.rb b/app/controllers/grading_period_sets_controller.rb index a5d00c4867d..73296ea9dd3 100644 --- a/app/controllers/grading_period_sets_controller.rb +++ b/app/controllers/grading_period_sets_controller.rb @@ -1,9 +1,6 @@ class GradingPeriodSetsController < ApplicationController - include ::Filters::GradingPeriods - before_action :require_user before_action :get_context - before_action :check_feature_flag before_action :check_manage_rights, except: [:index] before_action :check_read_rights, except: [:update, :create, :destroy] @@ -42,7 +39,11 @@ class GradingPeriodSetsController < ApplicationController end def update + old_term_ids = grading_period_set.enrollment_terms.pluck(:id) grading_period_set.enrollment_terms = enrollment_terms + # we need to recompute scores for enrollment terms that were removed since the line above + # will not run callbacks for the removed enrollment terms + EnrollmentTerm.where(id: old_term_ids - enrollment_terms.map(&:id)).each(&:recompute_course_scores) respond_to do |format| if grading_period_set.update(set_params) @@ -74,7 +75,7 @@ class GradingPeriodSetsController < ApplicationController end def set_params - params.require(:grading_period_set).permit(:title) + params.require(:grading_period_set).permit(:title, :weighted) end def check_read_rights diff --git a/app/controllers/grading_periods_controller.rb b/app/controllers/grading_periods_controller.rb index b57f3bcaacb..9834cb8ea69 100644 --- a/app/controllers/grading_periods_controller.rb +++ b/app/controllers/grading_periods_controller.rb @@ -61,11 +61,8 @@ # } # class GradingPeriodsController < ApplicationController - include ::Filters::GradingPeriods - before_action :require_user before_action :get_context - before_action :check_feature_flag # @API List grading periods # @beta @@ -240,6 +237,7 @@ class GradingPeriodsController < ApplicationController set_subquery = GradingPeriodGroup.active.select(:account_id).where(id: params[:set_id]) @context = Account.active.where(id: set_subquery).take + render json: {message: t('Page not found')}, status: :not_found unless @context end # model level validations @@ -325,11 +323,6 @@ class GradingPeriodsController < ApplicationController def index_permissions can_create_grading_periods = @context.is_a?(Account) && @context.root_account? && @context.grants_right?(@current_user, :manage) - can_toggle_grading_periods = @domain_root_account.grants_right?(@current_user, :manage) || - @context.feature_allowed?(:multiple_grading_periods, exclude_enabled: true) - { - can_create_grading_periods: can_create_grading_periods, - can_toggle_grading_periods: can_toggle_grading_periods - }.as_json + {can_create_grading_periods: can_create_grading_periods}.as_json end end diff --git a/app/controllers/grading_standards_controller.rb b/app/controllers/grading_standards_controller.rb index 5b377eb628c..b611077f258 100644 --- a/app/controllers/grading_standards_controller.rb +++ b/app/controllers/grading_standards_controller.rb @@ -30,7 +30,7 @@ class GradingStandardsController < ApplicationController GRADING_STANDARDS_URL: context_url(@context, :context_grading_standards_url), GRADING_PERIOD_SETS_URL: api_v1_account_grading_period_sets_url(@context), ENROLLMENT_TERMS_URL: api_v1_enrollment_terms_url(@context), - MULTIPLE_GRADING_PERIODS: multiple_grading_periods?, + HAS_GRADING_PERIODS: grading_periods?, DEFAULT_GRADING_STANDARD_DATA: GradingStandard.default_grading_standard, CONTEXT_SETTINGS_URL: context_url(@context, :context_settings_url) } diff --git a/app/controllers/quizzes/quizzes_controller.rb b/app/controllers/quizzes/quizzes_controller.rb index a89eb12f7cc..7385d9efe9e 100644 --- a/app/controllers/quizzes/quizzes_controller.rb +++ b/app/controllers/quizzes/quizzes_controller.rb @@ -320,12 +320,12 @@ class Quizzes::QuizzesController < ApplicationController :quiz_max_combination_count => QUIZ_MAX_COMBINATION_COUNT, :SHOW_QUIZ_ALT_TEXT_WARNING => true, :VALID_DATE_RANGE => CourseDateRange.new(@context), - :MULTIPLE_GRADING_PERIODS_ENABLED => @context.feature_enabled?(:multiple_grading_periods), + :HAS_GRADING_PERIODS => @context.grading_periods?, :MAX_NAME_LENGTH_REQUIRED_FOR_ACCOUNT => max_name_length_required_for_account, :MAX_NAME_LENGTH => max_name_length } - if @context.feature_enabled?(:multiple_grading_periods) + if @context.grading_periods? hash[:active_grading_periods] = GradingPeriod.json_for(@context, @current_user) end diff --git a/app/controllers/submissions_api_controller.rb b/app/controllers/submissions_api_controller.rb index 419d1f2e972..89c82ee417d 100644 --- a/app/controllers/submissions_api_controller.rb +++ b/app/controllers/submissions_api_controller.rb @@ -245,7 +245,7 @@ class SubmissionsApiController < ApplicationController # # @argument grading_period_id [Integer] # The id of the grading period in which submissions are being requested - # (Requires the Multiple Grading Periods account feature turned on) + # (Requires grading periods to exist on the account) # # @argument order [String, "id"|"graded_at"] # The order submissions will be returned in. Defaults to "id". Doesn't @@ -330,7 +330,7 @@ class SubmissionsApiController < ApplicationController assignment_scope = assignment_scope.where(:id => requested_assignment_ids) end - if params[:grading_period_id].present? && multiple_grading_periods? + if params[:grading_period_id].present? assignments = GradingPeriod.active.find(params[:grading_period_id]).assignments(assignment_scope) else assignments = assignment_scope.to_a diff --git a/app/controllers/users_controller.rb b/app/controllers/users_controller.rb index e70f3754ca0..53871b699bc 100644 --- a/app/controllers/users_controller.rb +++ b/app/controllers/users_controller.rb @@ -175,7 +175,12 @@ class UsersController < ApplicationController add_crumb(@current_user.short_name, crumb_url) add_crumb(t('crumbs.grades', 'Grades'), grades_path) - current_active_enrollments = @user.enrollments.current.preload(:course, :enrollment_state).shard(@user).to_a + current_active_enrollments = @user. + enrollments. + current. + preload(:course, :enrollment_state, :scores). + shard(@user). + to_a @presenter = GradesPresenter.new(current_active_enrollments) @@ -197,19 +202,11 @@ class UsersController < ApplicationController enrollment = Enrollment.active.find(params[:enrollment_id]) return render_unauthorized_action unless enrollment.grants_right?(@current_user, session, :read_grades) - course = enrollment.course - grading_period_id = params[:grading_period_id].to_i - grading_period = GradingPeriod.for(course).find_by(id: grading_period_id) - grading_periods = { - course.id => { - periods: [grading_period], - selected_period_id: grading_period_id - } + grading_period_id = generate_grading_period_id(params[:grading_period_id]) + render json: { + grade: enrollment.computed_current_score(grading_period_id: grading_period_id), + hide_final_grades: enrollment.course.hide_final_grades? } - calculator = grade_calculator([enrollment.user_id], course, grading_periods) - totals = calculator.compute_scores.first[:current] - totals[:hide_final_grades] = course.hide_final_grades? - render json: totals end def oauth @@ -2119,6 +2116,12 @@ class UsersController < ApplicationController private + def generate_grading_period_id(period_id) + # nil and '' will get converted to 0 in the .to_i call + id = period_id.to_i + id == 0 ? nil : id + end + def render_new_user_tutorial_statuses(user) render(json: { new_user_tutorial_statuses: { collapsed: user.new_user_tutorial_statuses }}) end @@ -2137,47 +2140,30 @@ class UsersController < ApplicationController presenter.observed_enrollments.group_by { |enrollment| enrollment[:course_id] } grouped_observed_enrollments.each do |course_id, enrollments| + grading_period_id = generate_grading_period_id( + grading_periods[course_id].try(:selected_period_id) + ) grades[:observed_enrollments][course_id] = {} - - if grading_periods[course_id].present? - user_ids = enrollments.map(&:user_id) - course = enrollments.first.course - grades[:observed_enrollments][course_id] = grades_from_grade_calculator(user_ids, course, grading_periods) - else - grades[:observed_enrollments][course_id] = grades_from_enrollments(enrollments) - end + grades[:observed_enrollments][course_id] = grades_from_enrollments( + enrollments, + grading_period_id: grading_period_id + ) end - presenter.student_enrollments.each do |enrollment_course_pair| - course = enrollment_course_pair.first - enrollment = enrollment_course_pair.second - - if grading_periods[course.id].present? - computed_score = grades_from_grade_calculator([enrollment.user_id], course, grading_periods)[enrollment.user_id] - grades[:student_enrollments][course.id] = computed_score - else - computed_score = enrollment.computed_current_score - grades[:student_enrollments][course.id] = computed_score - end + presenter.student_enrollments.each do |course, enrollment| + grading_period_id = generate_grading_period_id( + grading_periods[course.id].try(:[], :selected_period_id) + ) + computed_score = enrollment.computed_current_score(grading_period_id: grading_period_id) + grades[:student_enrollments][course.id] = computed_score end grades end - def grades_from_grade_calculator(user_ids, course, grading_periods) - calculator = grade_calculator(user_ids, course, grading_periods) - grades = {} - calculator.compute_scores.each_with_index do |score, index| - computed_score = score[:current][:grade] - user_id = user_ids[index] - grades[user_id] = computed_score - end - grades - end - - def grades_from_enrollments(enrollments) + def grades_from_enrollments(enrollments, grading_period_id: nil) grades = {} enrollments.each do |enrollment| - computed_score = enrollment.computed_current_score + computed_score = enrollment.computed_current_score(grading_period_id: grading_period_id) grades[enrollment.user_id] = computed_score end grades @@ -2191,7 +2177,7 @@ class UsersController < ApplicationController grading_periods = {} courses.each do |course| - next unless course.feature_enabled?(:multiple_grading_periods) + next unless course.grading_periods? course_periods = GradingPeriod.for(course) grading_period_specified = grading_period_id && @@ -2212,19 +2198,6 @@ class UsersController < ApplicationController grading_periods end - def grade_calculator(user_ids, course, grading_periods) - if course.feature_enabled?(:multiple_grading_periods) && - grading_periods[course.id][:selected_period_id] != 0 - - grading_period = grading_periods[course.id][:periods].find do |period| - period.id == grading_periods[course.id][:selected_period_id] - end - GradeCalculator.new(user_ids, course, grading_period: grading_period) - else - GradeCalculator.new(user_ids, course) - end - end - def create_user run_login_hooks # Look for an incomplete registration with this pseudonym diff --git a/app/jsx/due_dates/DueDates.jsx b/app/jsx/due_dates/DueDates.jsx index c2735d43672..c47c5be88f3 100644 --- a/app/jsx/due_dates/DueDates.jsx +++ b/app/jsx/due_dates/DueDates.jsx @@ -26,7 +26,7 @@ define([ syncWithBackbone: React.PropTypes.func.isRequired, sections: React.PropTypes.array.isRequired, defaultSectionId: React.PropTypes.string.isRequired, - multipleGradingPeriodsEnabled: React.PropTypes.bool.isRequired, + hasGradingPeriods: React.PropTypes.bool.isRequired, gradingPeriods: React.PropTypes.array.isRequired, isOnlyVisibleToOverrides: React.PropTypes.bool.isRequired, dueAt: function(props) { @@ -350,7 +350,7 @@ define([ let validGroups = this.valuesWithOmission({object: this.groupsForSelectedSet(), keysToOmit: this.chosenGroupIds()}) let validSections = this.valuesWithOmission({object: this.state.sections, keysToOmit: this.chosenSectionIds()}) let validNoops = this.valuesWithOmission({object: this.state.noops, keysToOmit: this.chosenNoops()}) - if (this.props.multipleGradingPeriodsEnabled && !_.contains(ENV.current_user_roles, "admin")) { + if (this.props.hasGradingPeriods && !_.contains(ENV.current_user_roles, "admin")) { ({validStudents, validGroups, validSections} = this.filterDropdownOptionsForMultipleGradingPeriods(validStudents, validGroups, validSections)) } @@ -431,7 +431,7 @@ define([ disableInputs(row) { const rowIsNewOrUserIsAdmin = !row.persisted || _.contains(ENV.current_user_roles, "admin") - if (!this.props.multipleGradingPeriodsEnabled || rowIsNewOrUserIsAdmin) { + if (!this.props.hasGradingPeriods || rowIsNewOrUserIsAdmin) { return false } diff --git a/app/jsx/gradebook/AssignmentGroupGradeCalculator.jsx b/app/jsx/gradebook/AssignmentGroupGradeCalculator.jsx index 77f5cf16fee..19efd3b5ee7 100644 --- a/app/jsx/gradebook/AssignmentGroupGradeCalculator.jsx +++ b/app/jsx/gradebook/AssignmentGroupGradeCalculator.jsx @@ -18,109 +18,105 @@ define([ 'underscore' -], function (_) { - const sum = function (collection) { - return _.reduce(collection, function (sum, value) { - return sum + value; - }, 0); - }; +], (_) => { + function sum (collection) { + return _.reduce(collection, (total, value) => total + value, 0); + } - const sumBy = function (collection, attr) { + function sumBy (collection, attr) { const values = _.map(collection, attr); return sum(values); - }; + } - const partition = function (collection, partitionFn) { + function partition (collection, partitionFn) { const grouped = _.groupBy(collection, partitionFn); - return [grouped[true] || [], grouped[false] || []]; - }; + return [grouped.true || [], grouped.false || []]; + } - const parseScore = function (score) { + function parseScore (score) { const result = parseFloat(score); return (result && isFinite(result)) ? result : 0; - }; + } + function sortPairsDescending ([scoreA, submissionA], [scoreB, submissionB]) { + const scoreDiff = scoreB - scoreA; + if (scoreDiff !== 0) { + return scoreDiff; + } + // To ensure stable sorting, use the assignment id as a secondary sort. + return submissionA.assignment_id - submissionB.assignment_id; + } - // Some browser sorting functions (such as in V8) are not stable. - // This function ensures that the same submission will be dropped regardless - // of browser. - const stableSubmissionSort = function (sortFn, getAssignmentIdFn) { - return function (a, b) { - const ret = sortFn(a, b); - if (ret === 0) { - return getAssignmentIdFn(a) - getAssignmentIdFn(b); - } else { - return ret; - } - }; - }; + function sortPairsAscending ([scoreA, submissionA], [scoreB, submissionB]) { + const scoreDiff = scoreA - scoreB; + if (scoreDiff !== 0) { + return scoreDiff; + } + // To ensure stable sorting, use the assignment id as a secondary sort. + return submissionA.assignment_id - submissionB.assignment_id; + } - const sortDescending = function ([a, xx], [b, yy]) { - return b - a; - }; - const sortAscending = function ([a, xx], [b, yy]) { - return a - b; - }; - const getAssignmentIdFn = function ([score, submission]) { - return submission.submission.assignment_id; - }; + function sortSubmissionsAscending (submissionA, submissionB) { + const scoreDiff = submissionA.score - submissionB.score; + if (scoreDiff !== 0) { + return scoreDiff; + } + // To ensure stable sorting, use the assignment id as a secondary sort. + return submissionA.assignment_id - submissionB.assignment_id; + } - const getSubmissionGrade = function ({ score, total }) { + function getSubmissionGrade ({ score, total }) { return score / total; - }; + } - const estimateQHigh = function (pointed, unpointed, grades) { + function estimateQHigh (pointed, unpointed, grades) { if (unpointed.length > 0) { const pointsPossible = sumBy(pointed, 'total'); const bestPointedScore = Math.max(pointsPossible, sumBy(pointed, 'score')); const unpointedScore = sumBy(unpointed, 'score'); return (bestPointedScore + unpointedScore) / pointsPossible; - } else { - return grades[grades.length - 1]; } - }; - const dropPointed = function (submissions, cannotDrop, keepHighest, keepLowest) { - const totals = _.map(submissions, 'total'); - const maxTotal = Math.max.apply(Math, totals); + return grades[grades.length - 1]; + } - const keepHelper = function (submissions, keep, bigFSort) { - keep = Math.max(1, keep); + function buildBigF (keepCount, cannotDrop, sortFn) { + return function bigF (q, submissions) { + const ratedScores = _.map(submissions, submission => ( + [submission.score - (q * submission.total), submission] + )); + const rankedScores = ratedScores.sort(sortFn); + const keptScores = rankedScores.slice(0, keepCount); + const qKept = sumBy(keptScores, ([score]) => score); + const keptSubmissions = _.map(keptScores, ([_score, submission]) => submission); + const qCannotDrop = sumBy(cannotDrop, submission => submission.score - (q * submission.total)); + return [qKept + qCannotDrop, keptSubmissions]; + } + } - if (submissions.length <= keep) { + function dropPointed (droppableSubmissionData, cannotDrop, keepHighest, keepLowest) { + const totals = _.map(droppableSubmissionData, 'total'); + const maxTotal = Math.max(...totals); + + function keepHelper (submissions, initialKeepCount, bigFSort) { + const keepCount = Math.max(1, initialKeepCount); + + if (submissions.length <= keepCount) { return submissions; } - const allSubmissions = [...submissions, ...cannotDrop]; - const [unpointed, pointed] = partition(allSubmissions, function (submission) { - return submission.total == 0; - }); + const allSubmissionData = [...submissions, ...cannotDrop]; + const [unpointed, pointed] = partition(allSubmissionData, submissionDatum => submissionDatum.total === 0); const grades = _.map(pointed, getSubmissionGrade).sort(); let qHigh = estimateQHigh(pointed, unpointed, grades); let qLow = grades[0]; let qMid = (qLow + qHigh) / 2; - const bigF = function (q, submissions) { - const ratedScores = _.map(submissions, function (submission) { - return [submission.score - (q * submission.total), submission]; - }); - const rankedScores = ratedScores.sort(bigFSort); - const keptScores = rankedScores.slice(0, keep); - const qKept = sumBy(keptScores, function ([score]) { - return score; - }); - const keptSubmissions = _.map(keptScores, function ([score, submission]) { - return submission; - }); - const qCantDrop = sumBy(cannotDrop, function (submission) { - return submission.score - q * submission.total; - }); - return [qKept + qCantDrop, keptSubmissions]; - }; + const bigF = buildBigF(keepCount, cannotDrop, bigFSort); - let [x, kept] = bigF(qMid, submissions); - const threshold = 1 / (2 * keep * Math.pow(maxTotal, 2)); + let [x, submissionsToKeep] = bigF(qMid, submissions); + const threshold = 1 / (2 * keepCount * (maxTotal ** 2)); while (qHigh - qLow >= threshold) { if (x < 0) { qHigh = qMid; @@ -132,22 +128,24 @@ define([ break; } - [x, kept] = bigF(qMid, submissions); + [x, submissionsToKeep] = bigF(qMid, submissions); } - return kept; - }; + return submissionsToKeep; + } - const kept = keepHelper(submissions, keepHighest, stableSubmissionSort(sortDescending, getAssignmentIdFn)); - return keepHelper(kept, keepLowest, stableSubmissionSort(sortAscending, getAssignmentIdFn)); - }; + const submissionsWithLowestDropped = keepHelper( + droppableSubmissionData, keepHighest, sortPairsDescending + ); + return keepHelper( + submissionsWithLowestDropped, keepLowest, sortPairsAscending + ); + } - const dropUnpointed = function (submissions, keepHighest, keepLowest) { - const sortAscending = function (a, b) { return a.score - b.score }; - const getAssignmentIdFn = function ({ submission }) { return submission.assignment_id }; - const sortedSubmissions = submissions.sort(stableSubmissionSort(sortAscending, getAssignmentIdFn)); + function dropUnpointed (submissions, keepHighest, keepLowest) { + const sortedSubmissions = submissions.sort(sortSubmissionsAscending); return _.chain(sortedSubmissions).last(keepHighest).first(keepLowest).value(); - }; + } // I am not going to pretend that this code is understandable. // @@ -159,130 +157,121 @@ define([ // Grades" by Daniel Kane and Jonathan Kane. Please see that paper for // a full explanation of the math. // (http://cseweb.ucsd.edu/~dakane/droplowest.pdf) - const dropAssignments = function (submissions, rules) { - rules = rules || {}; + function dropAssignments (allSubmissionData, rules = {}) { let dropLowest = rules.drop_lowest || 0; let dropHighest = rules.drop_highest || 0; const neverDropIds = rules.never_drop || []; if (!(dropLowest || dropHighest)) { - return submissions; + return allSubmissionData; } - let cannot_drop = []; + let cannotDrop = []; + let droppableSubmissionData = allSubmissionData; if (neverDropIds.length > 0) { - [cannot_drop, submissions] = partition(submissions, function (submission) { - return _.contains(neverDropIds, submission.submission.assignment_id); - }); + [cannotDrop, droppableSubmissionData] = partition(allSubmissionData, submission => ( + _.contains(neverDropIds, submission.submission.assignment_id) + )); } - if (submissions.length === 0) { - return cannot_drop; + if (droppableSubmissionData.length === 0) { + return cannotDrop; } - dropLowest = Math.min(dropLowest, submissions.length - 1); - dropHighest = (dropLowest + dropHighest) >= submissions.length ? 0 : dropHighest; + dropLowest = Math.min(dropLowest, droppableSubmissionData.length - 1); + dropHighest = (dropLowest + dropHighest) >= droppableSubmissionData.length ? 0 : dropHighest; - const keepHighest = submissions.length - dropLowest; + const keepHighest = droppableSubmissionData.length - dropLowest; const keepLowest = keepHighest - dropHighest; - const hasPointed = _.some(submissions, function (submission) { return submission.total > 0 }); + const hasPointed = _.some(droppableSubmissionData, submission => submission.total > 0); - let kept; + let submissionsToKeep; if (hasPointed) { - kept = dropPointed(submissions, cannot_drop, keepHighest, keepLowest); + submissionsToKeep = dropPointed(droppableSubmissionData, cannotDrop, keepHighest, keepLowest); } else { - kept = dropUnpointed(submissions, keepHighest, keepLowest); + submissionsToKeep = dropUnpointed(droppableSubmissionData, keepHighest, keepLowest); } - kept = [ ...kept, ...cannot_drop]; + submissionsToKeep = [...submissionsToKeep, ...cannotDrop]; - _.difference(submissions, kept).forEach(function (submission) { - submission.drop = true; - }); + _.difference(droppableSubmissionData, submissionsToKeep).forEach((submission) => { submission.drop = true }); - return kept; - }; + return submissionsToKeep; + } - const calculateGroupSum = function (group, submissions, includeUngraded) { - // remove assignments without visibility from gradeableAssignments - const hiddenAssignments = _.chain(submissions).filter('hidden').indexBy('assignment_id').value(); - const gradeableAssignments = _.reject(group.assignments, function (assignment) { - return assignment.omit_from_final_grade || - hiddenAssignments[assignment.id] || - _.isEqual(assignment.submission_types, ['not_graded']); - }); + function calculateGroupGrade (group, allSubmissions, includeUngraded) { + // Remove assignments without visibility from gradeableAssignments. + const hiddenAssignmentsById = _.chain(allSubmissions).filter('hidden').indexBy('assignment_id').value(); + const gradeableAssignments = _.reject(group.assignments, assignment => ( + assignment.omit_from_final_grade || + hiddenAssignmentsById[assignment.id] || + _.isEqual(assignment.submission_types, ['not_graded']) + )); const assignments = _.indexBy(gradeableAssignments, 'id'); - // filter out submissions from other assignment groups - submissions = _.filter(submissions, function (submission) { - return assignments[submission.assignment_id]; - }); + // Remove submissions from other assignment groups. + let submissions = _.filter(allSubmissions, submission => assignments[submission.assignment_id]); - // fill in any missing submissions + // To calculate grades for assignments to which the student has not yet + // submitted, create a submission stub with a score of `null`. if (includeUngraded) { - const submissionAssignmentIds = _.map(submissions, function ({ assignment_id }) { - return assignment_id.toString(); - }); - const missingSubmissions = _.difference(_.keys(assignments), submissionAssignmentIds); - const submissionStubs = _.map(missingSubmissions, (assignment_id) => { - return { assignment_id, score: null }; - }); - submissions = [ ...submissions, ...submissionStubs ]; + const submissionAssignmentIds = _.map(submissions, ({ assignment_id }) => assignment_id.toString()); + const missingAssignmentIds = _.difference(_.keys(assignments), submissionAssignmentIds); + const submissionStubs = _.map(missingAssignmentIds, assignmentId => ( + { assignment_id: assignmentId, score: null } + )); + submissions = [...submissions, ...submissionStubs]; } - // filter out excused assignments + // Remove excused submissions. submissions = _.reject(submissions, 'excused'); - const submissionsByAssignment = _.indexBy(submissions, 'assignment_id'); - - const submissionData = _.map(submissions, function (submission) { - return { + const submissionData = _.map(submissions, submission => ( + { total: parseScore(assignments[submission.assignment_id].points_possible), score: parseScore(submission.score), submitted: submission.score != null && submission.score !== '', pending_review: submission.workflow_state === 'pending_review', submission - }; - }); + } + )); let relevantSubmissionData = submissionData; if (!includeUngraded) { - relevantSubmissionData = _.filter(submissionData, function (submission) { - return submission.submitted && !submission.pending_review; - }); + relevantSubmissionData = _.filter(submissionData, submission => ( + submission.submitted && !submission.pending_review + )); } - const kept = dropAssignments(relevantSubmissionData, group.rules); - const score = sum(_.chain(kept).map('score').map(parseScore).value()); - const possible = sumBy(kept, 'total'); + const submissionsToKeep = dropAssignments(relevantSubmissionData, group.rules); + const score = sum(_.chain(submissionsToKeep).map('score').map(parseScore).value()); + const possible = sumBy(submissionsToKeep, 'total'); return { - possible, score, - weight: group.group_weight, + possible, submission_count: _.filter(submissionData, 'submitted').length, - submissions: _.map(submissionData, function (submission) { - return { - drop: submission.drop, - percent: parseScore(submission.score / submission.total), - possible: submission.total, - score: parseScore(submission.score), - submission: submission.submission, - submitted: submission.submitted - }; - }) + submissions: _.map(submissionData, submissionDatum => ( + { + drop: submissionDatum.drop, + percent: parseScore(submissionDatum.score / submissionDatum.total), + score: parseScore(submissionDatum.score), + possible: submissionDatum.total, + submission: submissionDatum.submission, + submitted: submissionDatum.submitted + } + )) }; - }; + } // Each submission requires the following properties: // * score: number // * points_possible: non-negative integer - // * assignment_id: Canvas id - // * assignment_group_id: Canvas id + // * assignment_id: + // * assignment_group_id: // * excused: boolean // - // To represent assignments which the student has not yet submitted, set the - // score of the related submission to `null`. + // Ungraded submissions will have a score of `null`. // // An assignment group requires the following properties: // * id: Canvas id @@ -296,21 +285,37 @@ define([ // * never_drop: [array of assignment ids] // // `assignments` is an array of objects with the following properties: - // * id: Canvas id + // * id: // * points_possible: non-negative number // * submission_types: [array of strings] // - // The weighting scheme is one of [`percent`, `points`] + // An AssignmentGroup Grade has the following shape: + // { + // score: number|null + // possible: number|null + // submission_count: non-negative number + // submissions: [array of Submissions] + // } // - // When weightingScheme is `percent`, assignment group weights are used. - // Otherwise, no weighting is applied. - const calculate = function (submissions, assignmentGroup, weightingScheme) { + // Return value is an AssignmentGroup Grade Set. + // An AssignmentGroup Grade Set has the following shape: + // { + // assignmentGroupId: + // assignmentGroupWeight: number + // current: + // final: + // scoreUnit: 'points' + // } + function calculate (allSubmissions, assignmentGroup) { + const submissions = _.uniq(allSubmissions, 'assignment_id'); return { - group: assignmentGroup, - current: calculateGroupSum(assignmentGroup, submissions, false), - final: calculateGroupSum(assignmentGroup, submissions, true) + assignmentGroupId: assignmentGroup.id, + assignmentGroupWeight: assignmentGroup.group_weight, + current: calculateGroupGrade(assignmentGroup, submissions, false), + final: calculateGroupGrade(assignmentGroup, submissions, true), + scoreUnit: 'points' }; - }; + } return { calculate diff --git a/app/jsx/gradebook/CourseGradeCalculator.jsx b/app/jsx/gradebook/CourseGradeCalculator.jsx index 4e80084f160..c0c36a25b36 100644 --- a/app/jsx/gradebook/CourseGradeCalculator.jsx +++ b/app/jsx/gradebook/CourseGradeCalculator.jsx @@ -1,5 +1,5 @@ /* - * Copyright (C) 2016 Instructure, Inc. + * Copyright (C) 2016 - 2017 Instructure, Inc. * * This file is part of Canvas. * @@ -20,51 +20,188 @@ define([ 'underscore', 'compiled/util/round', 'jsx/gradebook/AssignmentGroupGradeCalculator' -], function (_, round, AssignmentGroupGradeCalculator) { - const sum = function (collection) { - return _.reduce(collection, function (sum, value) { - return sum + value; - }, 0); - }; +], (_, round, AssignmentGroupGradeCalculator) => { + function sum (collection) { + return _.reduce(collection, (total, value) => (total + value), 0); + } - const sumBy = function (collection, attr) { + function sumBy (collection, attr) { const values = _.map(collection, attr); return sum(values); - }; + } - const getGroupSumWeightedPercent = ({ score, possible, weight }) => { - return (score / possible) * weight; - }; + function getWeightedPercent ({ score, possible, weight }) { + return score ? (score / possible) * weight : 0; + } - const calculateTotal = function (groupSums, includeUngraded, weightingScheme) { - groupSums = _.map(groupSums, function (groupSum) { - const sumVersion = includeUngraded ? groupSum.final : groupSum.current; - return { ...sumVersion, weight: groupSum.group.group_weight }; + function combineAssignmentGroupGrades (assignmentGroupGrades, includeUngraded, options) { + const scopedAssignmentGroupGrades = _.map(assignmentGroupGrades, (assignmentGroupGrade) => { + const gradeVersion = includeUngraded ? assignmentGroupGrade.final : assignmentGroupGrade.current; + return { ...gradeVersion, weight: assignmentGroupGrade.assignmentGroupWeight }; }); - if (weightingScheme === 'percent') { - const relevantGroupSums = _.filter(groupSums, 'possible'); - let finalGrade = sum(_.map(relevantGroupSums, getGroupSumWeightedPercent)); - const fullWeight = sumBy(relevantGroupSums, 'weight'); + if (options.weightAssignmentGroups) { + const relevantGroupGrades = _.filter(scopedAssignmentGroupGrades, 'possible'); + const fullWeight = sumBy(relevantGroupGrades, 'weight'); + + let finalGrade = sum(_.map(relevantGroupGrades, getWeightedPercent)); if (fullWeight === 0) { finalGrade = null; } else if (fullWeight < 100) { - finalGrade = finalGrade * 100 / fullWeight; + finalGrade = (finalGrade * 100) / fullWeight; } - const submissionCount = sumBy(relevantGroupSums, 'submission_count'); + const submissionCount = sumBy(relevantGroupGrades, 'submission_count'); const possible = ((submissionCount > 0) || includeUngraded) ? 100 : 0; let score = finalGrade && round(finalGrade, 2); score = isNaN(score) ? null : score; return { score, possible }; - } else { - return { - score: sumBy(groupSums, 'score'), - possible: sumBy(groupSums, 'possible') - } } - }; + + return { + score: sumBy(scopedAssignmentGroupGrades, 'score'), + possible: sumBy(scopedAssignmentGroupGrades, 'possible') + } + } + + function combineGradingPeriodGrades (gradingPeriodGradesByPeriodId, includeUngraded) { + const scopedGradingPeriodGrades = _.map(gradingPeriodGradesByPeriodId, (gradingPeriodGrade) => { + const gradeVersion = includeUngraded ? gradingPeriodGrade.final : gradingPeriodGrade.current; + return { ...gradeVersion, weight: gradingPeriodGrade.gradingPeriodWeight }; + }); + + const weightedScores = _.map(scopedGradingPeriodGrades, getWeightedPercent); + const totalWeight = sumBy(scopedGradingPeriodGrades, 'weight'); + const totalScore = totalWeight === 0 ? 0 : (sum(weightedScores) * 100) / Math.min(totalWeight, 100); + + return { + score: round(totalScore, 2), + possible: 100 + }; + } + + function divideGroupByGradingPeriods (assignmentGroup, effectiveDueDates) { + // When using weighted grading periods, assignment groups must not contain assignments due in different grading + // periods. This allows for calculated assignment group grades in closed grading periods to be accidentally + // changed if a related assignment is considered to be in an open grading period. + // + // To avoid this, assignment groups meeting this criteria are "divided" (duplicated) in a way where each + // instance of the assignment group includes assignments only from one grading period. + const assignmentsByGradingPeriodId = _.groupBy(assignmentGroup.assignments, assignment => ( + effectiveDueDates[assignment.id].grading_period_id + )); + return _.map(assignmentsByGradingPeriodId, assignments => ( + { ...assignmentGroup, assignments } + )); + } + + function extractPeriodBasedAssignmentGroups (assignmentGroups, effectiveDueDates) { + return _.reduce(assignmentGroups, (periodBasedGroups, assignmentGroup) => { + const assignedAssignments = _.filter(assignmentGroup.assignments, assignment => ( + effectiveDueDates[assignment.id] + )); + if (assignedAssignments.length > 0) { + const groupWithAssignedAssignments = { ...assignmentGroup, assignments: assignedAssignments }; + return [ + ...periodBasedGroups, + ...divideGroupByGradingPeriods(groupWithAssignedAssignments, effectiveDueDates) + ]; + } + return periodBasedGroups; + }, []); + } + + function recombinePeriodBasedAssignmentGroupGrades (grades) { + const map = {}; + _.forEach(grades, (grade) => { + const previousGrade = map[grade.assignmentGroupId]; + if (previousGrade) { + map[grade.assignmentGroupId] = { + ...previousGrade, + current: { + score: previousGrade.current.score + grade.current.score, + possible: previousGrade.current.possible + grade.current.possible + }, + final: { + score: previousGrade.final.score + grade.final.score, + possible: previousGrade.final.possible + grade.final.possible + } + }; + } else { + map[grade.assignmentGroupId] = grade; + } + }); + return map; + } + + function calculateWithGradingPeriods (submissions, assignmentGroups, gradingPeriods, effectiveDueDates, options) { + const periodBasedGroups = extractPeriodBasedAssignmentGroups(assignmentGroups, effectiveDueDates); + + const assignmentGroupsByGradingPeriodId = _.groupBy(periodBasedGroups, (assignmentGroup) => { + const assignmentId = assignmentGroup.assignments[0].id; + return effectiveDueDates[assignmentId].grading_period_id; + }); + + const gradingPeriodsById = _.indexBy(gradingPeriods, 'id'); + const gradingPeriodGradesByPeriodId = {}; + const periodBasedAssignmentGroupGrades = []; + + _.forEach(gradingPeriods, (gradingPeriod) => { + const groupGrades = {}; + + (assignmentGroupsByGradingPeriodId[gradingPeriod.id] || []).forEach((assignmentGroup) => { + groupGrades[assignmentGroup.id] = AssignmentGroupGradeCalculator.calculate(submissions, assignmentGroup); + periodBasedAssignmentGroupGrades.push(groupGrades[assignmentGroup.id]); + }); + + const groupGradesList = _.values(groupGrades); + + gradingPeriodGradesByPeriodId[gradingPeriod.id] = { + gradingPeriodId: gradingPeriod.id, + gradingPeriodWeight: gradingPeriodsById[gradingPeriod.id].weight || 0, + assignmentGroups: groupGrades, + current: combineAssignmentGroupGrades(groupGradesList, false, options), + final: combineAssignmentGroupGrades(groupGradesList, true, options), + scoreUnit: options.weightAssignmentGroups ? 'percentage' : 'points' + }; + }); + + if (options.weightGradingPeriods) { + return { + assignmentGroups: recombinePeriodBasedAssignmentGroupGrades(periodBasedAssignmentGroupGrades), + gradingPeriods: gradingPeriodGradesByPeriodId, + current: combineGradingPeriodGrades(gradingPeriodGradesByPeriodId, false, options), + final: combineGradingPeriodGrades(gradingPeriodGradesByPeriodId, true, options), + scoreUnit: 'percentage' + }; + } + + const allAssignmentGroupGrades = _.map(assignmentGroups, assignmentGroup => ( + AssignmentGroupGradeCalculator.calculate(submissions, assignmentGroup) + )); + + return { + assignmentGroups: _.indexBy(allAssignmentGroupGrades, grade => grade.assignmentGroupId), + gradingPeriods: gradingPeriodGradesByPeriodId, + current: combineAssignmentGroupGrades(allAssignmentGroupGrades, false, options), + final: combineAssignmentGroupGrades(allAssignmentGroupGrades, true, options), + scoreUnit: options.weightAssignmentGroups ? 'percentage' : 'points' + }; + } + + function calculateWithoutGradingPeriods (submissions, assignmentGroups, options) { + const assignmentGroupGrades = _.map(assignmentGroups, assignmentGroup => ( + AssignmentGroupGradeCalculator.calculate(submissions, assignmentGroup) + )); + + return { + assignmentGroups: _.indexBy(assignmentGroupGrades, grade => grade.assignmentGroupId), + current: combineAssignmentGroupGrades(assignmentGroupGrades, false, options), + final: combineAssignmentGroupGrades(assignmentGroupGrades, true, options), + scoreUnit: options.weightAssignmentGroups ? 'percentage' : 'points' + }; + } // Each submission requires the following properties: // * score: number @@ -73,8 +210,7 @@ define([ // * assignment_group_id: Canvas id // * excused: boolean // - // To represent assignments which the student has not yet submitted, set the - // score of the related submission to `null`. + // Ungraded submissions will have a score of `null`. // // Each assignment group requires the following properties: // * id: Canvas id @@ -96,17 +232,89 @@ define([ // // When weightingScheme is `percent`, assignment group weights are used. // Otherwise, no weighting is applied. - const calculate = function (submissions, assignmentGroups, weightingScheme) { - const groupSums = _.map(assignmentGroups, function (group) { - return AssignmentGroupGradeCalculator.calculate(submissions, group); - }); - - return { - group_sums: groupSums, - current: calculateTotal(groupSums, false, weightingScheme), - final: calculateTotal(groupSums, true, weightingScheme) + // + // Grading period set and effective due dates are optional, but must be used + // together. + // + // `gradingPeriodSet` is an object with at least the following shape: + // * gradingPeriods: [array of grading periods *see below] + // * weight: non-negative number + // + // Each grading period requires the following properties: + // * id: Canvas id + // * weight: non-negative number + // + // `effectiveDueDates` is an object with at least the following shape: + // { + // : { + // grading_period_id: + // } + // } + // + // `effectiveDueDates` should generally include an assignment id for most/all + // assignments in use for the course and student. The structure above is the + // "user-scoped" form of effective due dates, which includes only the + // necessary data to perform a grade calculation. Effective due date entries + // would otherwise include more information about a student's relationship + // with an assignment and related grading periods. + // + // Grades minimally have the following shape: + // { + // score: number|null + // possible: number|null + // } + // + // AssignmentGroup Grade maps have the following shape: + // { + // : + // } + // + // GradingPeriod Grade Sets have the following shape: + // { + // gradingPeriodId: + // gradingPeriodWeight: number + // assignmentGroups: + // current: + // final: + // scoreUnit: 'points'|'percent' + // } + // + // GradingPeriod Grade maps have the following shape: + // { + // : + // } + // + // Each grading period will have a map for assignment group grades, keyed to + // the id of assignment groups graded within the grading period. Not every + // call to `calculate` will include grading period grades, as some courses do + // not use grading periods. + // + // An AssignmentGroup Grade Set is the returned result from the + // AssignmentGroupGradeCalculator.calculate function. + // + // Return value is a Course Grade Set. + // A Course Grade Set has the following shape: + // { + // assignmentGroups: + // gradingPeriods: + // current: + // final: + // scoreUnit: 'points'|'percent' + // } + function calculate (submissions, assignmentGroups, weightingScheme, gradingPeriodSet, effectiveDueDates) { + const options = { + weightGradingPeriods: gradingPeriodSet && !!gradingPeriodSet.weighted, + weightAssignmentGroups: weightingScheme === 'percent' }; - }; + + if (gradingPeriodSet && effectiveDueDates) { + return calculateWithGradingPeriods( + submissions, assignmentGroups, gradingPeriodSet.gradingPeriods, effectiveDueDates, options + ); + } + + return calculateWithoutGradingPeriods(submissions, assignmentGroups, options); + } return { calculate diff --git a/app/jsx/gradebook/EffectiveDueDates.jsx b/app/jsx/gradebook/EffectiveDueDates.jsx new file mode 100644 index 00000000000..3e696a01bfb --- /dev/null +++ b/app/jsx/gradebook/EffectiveDueDates.jsx @@ -0,0 +1,17 @@ +define([ + 'underscore' +], (_) => { + function scopeToUser (dueDateData, userId) { + const scopedData = {}; + _.forEach(dueDateData, (dueDateDataByUserId, assignmentId) => { + if (dueDateDataByUserId[userId]) { + scopedData[assignmentId] = dueDateDataByUserId[userId]; + } + }); + return scopedData; + } + + return { + scopeToUser + }; +}); diff --git a/app/jsx/gradebook/GradingSchemeHelper.jsx b/app/jsx/gradebook/GradingSchemeHelper.jsx index 4dbc3a03463..2f0f59f70a0 100644 --- a/app/jsx/gradebook/GradingSchemeHelper.jsx +++ b/app/jsx/gradebook/GradingSchemeHelper.jsx @@ -18,17 +18,18 @@ define([ 'underscore' -], function (_) { - const scoreToGrade = function (score, gradingScheme) { - score = Math.max(score, 0); - const letter = _.find(gradingScheme, function (row, i) { +], (_) => { + function scoreToGrade (score, gradingScheme) { + const scoreWithLowerBound = Math.max(score, 0); + const letter = _.find(gradingScheme, (row, i) => { + const schemeScore = (row[1] * 100).toPrecision(4); // The precision of the lower bound (* 100) must be limited to eliminate // floating-point errors. // e.g. 0.545 * 100 returns 54.50000000000001 in JavaScript. - return score >= (row[1] * 100).toPrecision(4) || i === (gradingScheme.length - 1); + return scoreWithLowerBound >= schemeScore || i === (gradingScheme.length - 1); }); return letter[0]; - }; + } return { scoreToGrade diff --git a/app/jsx/gradebook/SubmissionStateMap.jsx b/app/jsx/gradebook/SubmissionStateMap.jsx index 2035b77df12..e1c9cea3a00 100644 --- a/app/jsx/gradebook/SubmissionStateMap.jsx +++ b/app/jsx/gradebook/SubmissionStateMap.jsx @@ -17,10 +17,10 @@ define([ return _.contains(assignment.assignment_visibility, student.id); } - function cellMapForSubmission(assignment, student, gradingPeriodsEnabled, selectedGradingPeriodID, isAdmin) { + function cellMapForSubmission(assignment, student, hasGradingPeriods, selectedGradingPeriodID, isAdmin) { if (!visibleToStudent(assignment, student)) { return { locked: true, hideGrade: true, tooltip: TOOLTIP_KEYS.NONE }; - } else if (gradingPeriodsEnabled) { + } else if (hasGradingPeriods) { return cellMappingsForMultipleGradingPeriods(assignment, student, selectedGradingPeriodID, isAdmin); } else { return { locked: false, hideGrade: false, tooltip: TOOLTIP_KEYS.NONE }; @@ -51,8 +51,8 @@ define([ } class SubmissionState { - constructor({ gradingPeriodsEnabled, selectedGradingPeriodID, isAdmin }) { - this.gradingPeriodsEnabled = gradingPeriodsEnabled; + constructor({ hasGradingPeriods, selectedGradingPeriodID, isAdmin }) { + this.hasGradingPeriods = hasGradingPeriods; this.selectedGradingPeriodID = selectedGradingPeriodID; this.isAdmin = isAdmin; this.submissionCellMap = {}; @@ -74,7 +74,7 @@ define([ const params = [ assignment, student, - this.gradingPeriodsEnabled, + this.hasGradingPeriods, this.selectedGradingPeriodID, this.isAdmin ]; diff --git a/app/jsx/gradezilla/SubmissionStateMap.jsx b/app/jsx/gradezilla/SubmissionStateMap.jsx index 2035b77df12..e1c9cea3a00 100644 --- a/app/jsx/gradezilla/SubmissionStateMap.jsx +++ b/app/jsx/gradezilla/SubmissionStateMap.jsx @@ -17,10 +17,10 @@ define([ return _.contains(assignment.assignment_visibility, student.id); } - function cellMapForSubmission(assignment, student, gradingPeriodsEnabled, selectedGradingPeriodID, isAdmin) { + function cellMapForSubmission(assignment, student, hasGradingPeriods, selectedGradingPeriodID, isAdmin) { if (!visibleToStudent(assignment, student)) { return { locked: true, hideGrade: true, tooltip: TOOLTIP_KEYS.NONE }; - } else if (gradingPeriodsEnabled) { + } else if (hasGradingPeriods) { return cellMappingsForMultipleGradingPeriods(assignment, student, selectedGradingPeriodID, isAdmin); } else { return { locked: false, hideGrade: false, tooltip: TOOLTIP_KEYS.NONE }; @@ -51,8 +51,8 @@ define([ } class SubmissionState { - constructor({ gradingPeriodsEnabled, selectedGradingPeriodID, isAdmin }) { - this.gradingPeriodsEnabled = gradingPeriodsEnabled; + constructor({ hasGradingPeriods, selectedGradingPeriodID, isAdmin }) { + this.hasGradingPeriods = hasGradingPeriods; this.selectedGradingPeriodID = selectedGradingPeriodID; this.isAdmin = isAdmin; this.submissionCellMap = {}; @@ -74,7 +74,7 @@ define([ const params = [ assignment, student, - this.gradingPeriodsEnabled, + this.hasGradingPeriods, this.selectedGradingPeriodID, this.isAdmin ]; diff --git a/app/jsx/grading/AccountGradingPeriod.jsx b/app/jsx/grading/AccountGradingPeriod.jsx index a6795401092..14b38f84ce0 100644 --- a/app/jsx/grading/AccountGradingPeriod.jsx +++ b/app/jsx/grading/AccountGradingPeriod.jsx @@ -15,10 +15,12 @@ define([ period: Types.shape({ id: Types.string.isRequired, title: Types.string.isRequired, + weight: Types.number, startDate: Types.instanceOf(Date).isRequired, endDate: Types.instanceOf(Date).isRequired, closeDate: Types.instanceOf(Date).isRequired }).isRequired, + weighted: Types.bool, onEdit: Types.func.isRequired, actionsDisabled: Types.bool, readOnly: Types.bool.isRequired, @@ -91,13 +93,13 @@ define([ } }, - dateWithTimezone(date) { - const displayDatetime = DateHelper.formatDatetimeForDisplay(date); - - if(ENV.CONTEXT_TIMEZONE === ENV.TIMEZONE) { - return displayDatetime; - } else { - return `${displayDatetime} ${tz.format(date, '%Z')}`; + renderWeight() { + if (this.props.weighted) { + return ( +
+ { I18n.t("Weight:") } { I18n.n(this.props.period.weight, {percentage: true}) } +
+ ); } }, @@ -105,18 +107,19 @@ define([ return (
-
+
{this.props.period.title}
-
- {I18n.t("Start Date:")} {this.dateWithTimezone(this.props.period.startDate)} +
+ {I18n.t("Starts:")} {DateHelper.formatDateForDisplay(this.props.period.startDate)}
-
- {I18n.t("End Date:")} {this.dateWithTimezone(this.props.period.endDate)} +
+ {I18n.t("Ends:")} {DateHelper.formatDateForDisplay(this.props.period.endDate)}
-
- {I18n.t("Close Date:")} {this.dateWithTimezone(this.props.period.closeDate)} +
+ {I18n.t("Closes:")} {DateHelper.formatDateForDisplay(this.props.period.closeDate)}
+ {this.renderWeight()}
{this.renderEditButton()} diff --git a/app/jsx/grading/AccountTabContainer.jsx b/app/jsx/grading/AccountTabContainer.jsx index d376fa3b38e..c71b2026761 100644 --- a/app/jsx/grading/AccountTabContainer.jsx +++ b/app/jsx/grading/AccountTabContainer.jsx @@ -9,7 +9,6 @@ define([ class AccountTabContainer extends React.Component { static propTypes = { - multipleGradingPeriodsEnabled: bool.isRequired, readOnly: bool.isRequired, urls: shape({ gradingPeriodSetsURL: string.isRequired, @@ -20,11 +19,10 @@ define([ } componentDidMount () { - if (!this.props.multipleGradingPeriodsEnabled) return; $(this.tabContainer).children('.ui-tabs-minimal').tabs(); } - renderSetsAndStandards () { + render () { return (
{ this.tabContainer = el; }}>

{I18n.t('Grading')}

@@ -52,22 +50,6 @@ define([
); } - - renderStandards () { - return ( -
{ this.gradingStandards = el; }}> -

{I18n.t('Grading Schemes')}

- -
- ); - } - - render () { - if (this.props.multipleGradingPeriodsEnabled) { - return this.renderSetsAndStandards(); - } - return this.renderStandards(); - } } return AccountTabContainer; diff --git a/app/jsx/grading/CourseTabContainer.jsx b/app/jsx/grading/CourseTabContainer.jsx index 04d96af6106..25c2d8b41c0 100644 --- a/app/jsx/grading/CourseTabContainer.jsx +++ b/app/jsx/grading/CourseTabContainer.jsx @@ -8,11 +8,11 @@ define([ ], (React, GradingStandardCollection, GradingPeriodCollection, $, I18n) => { class CourseTabContainer extends React.Component { static propTypes = { - multipleGradingPeriodsEnabled: React.PropTypes.bool.isRequired + hasGradingPeriods: React.PropTypes.bool.isRequired } componentDidMount () { - if (!this.props.multipleGradingPeriodsEnabled) return; + if (!this.props.hasGradingPeriods) return; $(this.tabContainer).children('.ui-tabs-minimal').tabs(); } @@ -52,7 +52,7 @@ define([ } render () { - if (this.props.multipleGradingPeriodsEnabled) { + if (this.props.hasGradingPeriods) { return this.renderSetsAndStandards(); } return this.renderStandards(); diff --git a/app/jsx/grading/EditGradingPeriodSetForm.jsx b/app/jsx/grading/EditGradingPeriodSetForm.jsx index fa38e2a7d09..572b2e77104 100644 --- a/app/jsx/grading/EditGradingPeriodSetForm.jsx +++ b/app/jsx/grading/EditGradingPeriodSetForm.jsx @@ -4,16 +4,18 @@ define([ 'underscore', 'jquery', 'instructure-ui/Button', + 'instructure-ui/Checkbox', 'i18n!grading_periods', 'jsx/grading/EnrollmentTermInput', 'compiled/jquery.rails_flash_notifications' -], function(React, ReactDOM, _, $, { default: Button }, I18n, EnrollmentTermInput) { +], function(React, ReactDOM, _, $, { default: Button }, { default: Checkbox }, I18n, EnrollmentTermInput) { const { array, bool, func, shape, string } = React.PropTypes; const buildSet = function(attr = {}) { return { id: attr.id, title: attr.title || "", + weighted: attr.weighted || false, enrollmentTermIDs: attr.enrollmentTermIDs || [] }; }; @@ -30,6 +32,7 @@ define([ set: shape({ id: string, title: string, + weighted: bool, enrollmentTermIDs: array }).isRequired, enrollmentTerms: array.isRequired, @@ -53,15 +56,18 @@ define([ }, changeTitle(e) { - let set = _.clone(this.state.set); - set.title = e.target.value; - this.setState({ set: set }); + const set = { ...this.state.set, title: e.target.value }; + this.setState({ set }); + }, + + changeWeighted(e) { + const set = { ...this.state.set, weighted: e.target.checked }; + this.setState({ set }); }, changeEnrollmentTermIDs(termIDs) { - let set = _.clone(this.state.set); - set.enrollmentTermIDs = termIDs; - this.setState({ set: set }); + const set = { ...this.state.set, enrollmentTermIDs: termIDs }; + this.setState({ set }); }, triggerSave: function(e) { @@ -129,6 +135,16 @@ define([ enrollmentTerms = {this.props.enrollmentTerms} selectedIDs = {this.state.set.enrollmentTermIDs} setSelectedEnrollmentTermIDs = {this.changeEnrollmentTermIDs} /> + +
+ { this.weightedCheckbox = ref }} + label={I18n.t('Weighted grading periods')} + value="weighted" + checked={this.state.set.weighted} + onChange={this.changeWeighted} + /> +
diff --git a/app/jsx/grading/GradingPeriodForm.jsx b/app/jsx/grading/GradingPeriodForm.jsx index 27510064675..36bcc4bf547 100644 --- a/app/jsx/grading/GradingPeriodForm.jsx +++ b/app/jsx/grading/GradingPeriodForm.jsx @@ -6,63 +6,41 @@ define([ 'instructure-ui/Button', 'i18n!external_tools', 'jsx/due_dates/DueDateCalendarPicker', - 'jsx/shared/helpers/accessibleDateFormat' + 'jsx/shared/helpers/accessibleDateFormat', + 'jsx/shared/helpers/numberHelper', + 'compiled/util/round' ], function(React, ReactDOM, update, _, { default: Button }, I18n, - DueDateCalendarPicker, accessibleDateFormat) { + DueDateCalendarPicker, accessibleDateFormat, numberHelper, round) { const Types = React.PropTypes; - const buildPeriod = function(attr) { + function roundWeight (val) { + const value = numberHelper.parse(val); + return isNaN(value) ? null : round(value, 2); + }; + + function buildPeriod (attr) { return { id: attr.id, title: attr.title, + weight: roundWeight(attr.weight), startDate: attr.startDate, endDate: attr.endDate, closeDate: attr.closeDate }; }; - const hasDistinctCloseDate = ({ endDate, closeDate }) => { - return closeDate && !_.isEqual(endDate, closeDate); - }; - - const mergePeriod = (form, attr) => { - return update(form.state.period, {$merge: attr}); - } - - const changeTitle = function(e) { - let period = mergePeriod(this, {title: e.target.value}); - this.setState({period: period}); - }; - - const changeStartDate = function(date) { - let period = mergePeriod(this, {startDate: date}); - this.setState({period: period}); - }; - - const changeEndDate = function(date) { - let attr = {endDate: date}; - if (!this.state.preserveCloseDate && !hasDistinctCloseDate(this.state.period)) { - attr.closeDate = date; - } - let period = mergePeriod(this, attr); - this.setState({period: period}); - }; - - const changeCloseDate = function(date) { - let period = mergePeriod(this, {closeDate: date}); - this.setState({period: period, preserveCloseDate: !!date}); - }; - let GradingPeriodForm = React.createClass({ propTypes: { period: Types.shape({ id: Types.string.isRequired, title: Types.string.isRequired, + weight: Types.number, startDate: Types.instanceOf(Date).isRequired, endDate: Types.instanceOf(Date).isRequired, closeDate: Types.instanceOf(Date) }), + weighted: Types.bool.isRequired, disabled: Types.bool.isRequired, onSave: Types.func.isRequired, onCancel: Types.func.isRequired @@ -72,7 +50,7 @@ define([ let period = buildPeriod(this.props.period || {}); return { period: period, - preserveCloseDate: hasDistinctCloseDate(period) + preserveCloseDate: this.hasDistinctCloseDate(period) }; }, @@ -93,6 +71,43 @@ define([ } }, + hasDistinctCloseDate: function ({ endDate, closeDate }) { + return closeDate && !_.isEqual(endDate, closeDate); + }, + + mergePeriod: function (attr) { + return update(this.state.period, {$merge: attr}); + }, + + changeTitle: function (e) { + const period = this.mergePeriod({title: e.target.value}); + this.setState({period}); + }, + + changeWeight: function (e) { + const period = this.mergePeriod({weight: roundWeight(e.target.value)}); + this.setState({period}); + }, + + changeStartDate: function (date) { + const period = this.mergePeriod({startDate: date}); + this.setState({period}); + }, + + changeEndDate: function (date) { + let attr = {endDate: date}; + if (!this.state.preserveCloseDate && !this.hasDistinctCloseDate(this.state.period)) { + attr.closeDate = date; + } + const period = this.mergePeriod(attr); + this.setState({period}); + }, + + changeCloseDate: function (date) { + const period = this.mergePeriod({closeDate: date}); + this.setState({period: period, preserveCloseDate: !!date}); + }, + hackTheDatepickers: function() { // This can be replaced when we have an extensible datepicker let $form = ReactDOM.findDOMNode(this); @@ -147,6 +162,28 @@ define([ ); }, + renderWeightInput: function () { + if (!this.props.weighted) return null; + return ( +
+ +
+ { this.weightInput = ref }} + type="text" + className="span1" + defaultValue={I18n.n(this.state.period.weight)} + onChange={this.changeWeight} + /> + % +
+
+ ); + }, + render: function() { return (
@@ -163,7 +200,7 @@ define([ className='ic-Input' title={I18n.t('Grading Period Title')} defaultValue={this.state.period.title} - onChange={changeTitle.bind(this)} + onChange={this.changeTitle} type='text' />
@@ -178,7 +215,7 @@ define([ dateValue = {this.state.period.startDate} ref = "startDate" dateType = "due_at" - handleUpdate = {changeStartDate.bind(this)} + handleUpdate = {this.changeStartDate} rowKey = "start-date" labelledBy = "start-date-label" isFancyMidnight = {false} @@ -195,7 +232,7 @@ define([ dateValue = {this.state.period.endDate} ref = "endDate" dateType = "due_at" - handleUpdate = {changeEndDate.bind(this)} + handleUpdate = {this.changeEndDate} rowKey = "end-date" labelledBy = "end-date-label" isFancyMidnight = {true} @@ -212,12 +249,14 @@ define([ dateValue = {this.state.period.closeDate} ref = "closeDate" dateType = "due_at" - handleUpdate = {changeCloseDate.bind(this)} + handleUpdate = {this.changeCloseDate} rowKey = "close-date" labelledBy = "close-date-label" isFancyMidnight = {true} />
+ + {this.renderWeightInput()}
diff --git a/app/jsx/grading/GradingPeriodSet.jsx b/app/jsx/grading/GradingPeriodSet.jsx index 42d5a997371..49e395392e0 100644 --- a/app/jsx/grading/GradingPeriodSet.jsx +++ b/app/jsx/grading/GradingPeriodSet.jsx @@ -33,11 +33,15 @@ define([ !isNaN(date.getTime()); }; - const validatePeriods = function(periods) { + const validatePeriods = function(periods, weighted) { if (_.any(periods, (period) => { return !(period.title || "").trim() })) { return [I18n.t('All grading periods must have a title')]; } + if (weighted && _.any(periods, (period) => { return isNaN(period.weight) || period.weight < 0 })) { + return [I18n.t('All weights must be greater than or equal to 0')]; + } + let validDates = _.all(periods, (period) => { return isValidDate(period.startDate) && isValidDate(period.endDate) && @@ -101,7 +105,8 @@ define([ set: shape({ id: string.isRequired, - title: string.isRequired + title: string.isRequired, + weighted: bool }).isRequired, urls: shape({ @@ -121,6 +126,7 @@ define([ getInitialState() { return { title: this.props.set.title, + weighted: this.props.set.weighted || false, gradingPeriods: sortPeriods(this.props.gradingPeriods), newPeriod: { period: null, @@ -199,7 +205,7 @@ define([ saveNewPeriod(period) { let periods = this.state.gradingPeriods.concat([period]); - let validations = validatePeriods(periods); + let validations = validatePeriods(periods, this.state.weighted); if (_.isEmpty(validations)) { this.setNewPeriod({saving: true}); gradingPeriodsApi.batchUpdate(this.props.set.id, periods) @@ -236,7 +242,7 @@ define([ let periods = _.reject(this.state.gradingPeriods, function(_period) { return period.id === _period.id; }).concat([period]); - let validations = validatePeriods(periods); + let validations = validatePeriods(periods, this.state.weighted); if (_.isEmpty(validations)) { this.setEditPeriod({ saving: true }); gradingPeriodsApi.batchUpdate(this.props.set.id, periods) @@ -334,6 +340,7 @@ define([ className = 'GradingPeriodList__period--editing pad-box'> @@ -344,6 +351,7 @@ define([ diff --git a/app/jsx/grading/NewGradingPeriodSetForm.jsx b/app/jsx/grading/NewGradingPeriodSetForm.jsx index 629ab7d6253..5ea2ad66727 100644 --- a/app/jsx/grading/NewGradingPeriodSetForm.jsx +++ b/app/jsx/grading/NewGradingPeriodSetForm.jsx @@ -3,11 +3,12 @@ define([ 'underscore', 'jquery', 'instructure-ui/Button', + 'instructure-ui/Checkbox', 'i18n!grading_periods', 'compiled/api/gradingPeriodSetsApi', 'jsx/grading/EnrollmentTermInput', 'compiled/jquery.rails_flash_notifications' -], function(React, _, $, { default: Button }, I18n, setsApi, EnrollmentTermInput) { +], function(React, _, $, { default: Button }, { default: Checkbox }, I18n, setsApi, EnrollmentTermInput) { let NewGradingPeriodSetForm = React.createClass({ propTypes: { @@ -24,6 +25,7 @@ define([ return { buttonsDisabled: false, title: "", + weighted: false, selectedEnrollmentTermIDs: [] }; }, @@ -55,7 +57,7 @@ define([ event.preventDefault(); this.setState({ buttonsDisabled: true }, () => { if(this.isValid()) { - let set = { title: this.state.title.trim() }; + let set = { title: this.state.title.trim(), weighted: this.state.weighted }; set.enrollmentTermIDs = this.state.selectedEnrollmentTermIDs; setsApi.create(set) .then(this.submitSucceeded) @@ -80,6 +82,10 @@ define([ this.setState({ title: event.target.value }); }, + onSetWeightedChange(event) { + this.setState({ weighted: event.target.checked }); + }, + render() { return (
@@ -100,6 +106,15 @@ define([ selectedIDs = {this.state.selectedEnrollmentTermIDs} setSelectedEnrollmentTermIDs = {this.setSelectedEnrollmentTermIDs} /> +
+ { this.weightedCheckbox = ref }} + label={I18n.t('Weighted grading periods')} + value="weighted" + checked={this.state.weighted} + onChange={this.onSetWeightedChange} + /> +
diff --git a/app/jsx/grading/gradingPeriod.jsx b/app/jsx/grading/gradingPeriod.jsx index 830fdaf6c0a..9aa046cbd65 100644 --- a/app/jsx/grading/gradingPeriod.jsx +++ b/app/jsx/grading/gradingPeriod.jsx @@ -40,7 +40,7 @@ define([ title: nextProps.title, startDate: nextProps.startDate, endDate: nextProps.endDate, - weight: nextProps.weight, + weight: nextProps.weight }); }, diff --git a/app/jsx/grading/gradingPeriodCollection.jsx b/app/jsx/grading/gradingPeriodCollection.jsx index c22a785a3d1..afb150e5d92 100644 --- a/app/jsx/grading/gradingPeriodCollection.jsx +++ b/app/jsx/grading/gradingPeriodCollection.jsx @@ -25,8 +25,7 @@ function(React, update, GradingPeriod, $, I18n, _, ConvertCase) { periods: null, readOnly: false, disabled: false, - saveDisabled: true, - canChangeGradingPeriodsSetting: false + saveDisabled: true }; }, @@ -41,7 +40,6 @@ function(React, update, GradingPeriod, $, I18n, _, ConvertCase) { self.setState({ periods: self.deserializePeriods(periods), readOnly: periods.grading_periods_read_only, - canChangeGradingPeriodsSetting: periods.can_toggle_grading_periods, disabled: false, saveDisabled: _.isEmpty(periods.grading_periods) }); @@ -200,22 +198,8 @@ function(React, update, GradingPeriod, $, I18n, _, ConvertCase) { }); }, - renderLinkToSettingsPage: function () { - if (this.state.canChangeGradingPeriodsSetting && this.state.periods && this.state.periods.length <= 1) { - return ( - - {I18n.t('You can disable this feature ')} - {I18n.t('here.')} - ); - } - }, - renderSaveButton: function () { if (periodsAreLoaded(this.state) && !this.state.readOnly && _.all(this.state.periods, period => period.permissions.update)) { - let buttonText = this.state.disabled ? I18n.t('Updating') : I18n.t('Save'); return (
); @@ -257,9 +241,6 @@ function(React, update, GradingPeriod, $, I18n, _, ConvertCase) { render: function () { return (
-
- {this.renderLinkToSettingsPage()} -
{this.renderGradingPeriods()}
diff --git a/app/models/course.rb b/app/models/course.rb index 6b61c38f7c8..607f2d155bd 100644 --- a/app/models/course.rb +++ b/app/models/course.rb @@ -1044,10 +1044,15 @@ class Course < ActiveRecord::Base end end - def recompute_student_scores(student_ids = nil, grading_period_id: nil) + def recompute_student_scores(student_ids = nil, grading_period_id: nil, update_all_grading_period_scores: true) student_ids ||= self.student_ids Rails.logger.info "GRADES: recomputing scores in course=#{global_id} students=#{student_ids.inspect}" - GradeCalculator.recompute_final_score(student_ids, self.id, grading_period_id: grading_period_id) + Enrollment.recompute_final_score( + student_ids, + self.id, + grading_period_id: grading_period_id, + update_all_grading_period_scores: update_all_grading_period_scores + ) end handle_asynchronously_if_production :recompute_student_scores, :singleton => proc { |c| "recompute_student_scores:#{ c.global_id }" } @@ -1666,7 +1671,6 @@ class Course < ActiveRecord::Base update_all(:grade_publishing_status => 'error', :grade_publishing_message => "Timed out.") end - def gradebook_to_csv_in_background(filename, user, options = {}) progress = progresses.build(tag: 'gradebook_to_csv') progress.save! @@ -3092,6 +3096,18 @@ class Course < ActiveRecord::Base effective_due_dates.any_in_closed_grading_period? end + # Does this course have grading periods? + # checks for both legacy and account-level grading period groups + def grading_periods? + return @has_grading_periods unless @has_grading_periods.nil? + + @has_grading_periods = shard.activate do + GradingPeriodGroup.active. + where("id = ? or course_id = ?", enrollment_term.grading_period_group_id, id). + exists? + end + end + def quiz_lti_tool query = { tool_id: 'Quizzes 2' } context_external_tools.active.find_by(query) || diff --git a/app/models/enrollment_term.rb b/app/models/enrollment_term.rb index d659d47509e..7a5edfa04d2 100644 --- a/app/models/enrollment_term.rb +++ b/app/models/enrollment_term.rb @@ -35,6 +35,7 @@ class EnrollmentTerm < ActiveRecord::Base before_validation :verify_unique_sis_source_id after_save :update_courses_later_if_necessary + after_save :recompute_course_scores, if: :grading_period_group_id_has_changed? include StickySisFields are_sis_sticky :name, :start_at, :end_at @@ -64,6 +65,12 @@ class EnrollmentTerm < ActiveRecord::Base self.courses.touch_all end + def recompute_course_scores(update_all_grading_period_scores: true) + courses.active.each do |course| + course.recompute_student_scores(update_all_grading_period_scores: update_all_grading_period_scores) + end + end + def update_courses_and_states_later(enrollment_type=nil) return if new_record? @@ -186,4 +193,12 @@ class EnrollmentTerm < ActiveRecord::Base scope :not_started, -> { where('enrollment_terms.start_at IS NOT NULL AND enrollment_terms.start_at > ?', Time.now.utc) } scope :not_default, -> { where.not(name: EnrollmentTerm::DEFAULT_TERM_NAME)} scope :by_name, -> { order(best_unicode_collation_key('name')) } + + private + + def grading_period_group_id_has_changed? + # migration 20111111214313_add_trust_link_for_default_account + # will throw an error without this check + respond_to?(:grading_period_group_id_changed?) && grading_period_group_id_changed? + end end diff --git a/app/models/feature_flag.rb b/app/models/feature_flag.rb index 9579c78e970..ad55779ecee 100644 --- a/app/models/feature_flag.rb +++ b/app/models/feature_flag.rb @@ -53,11 +53,12 @@ class FeatureFlag < ActiveRecord::Base def clear_cache if self.context self.class.connection.after_transaction_commit { MultiCache.delete(self.context.feature_flag_cache_key(feature)) } - self.context.touch if Feature.definitions[feature].touch_context + self.context.touch if Feature.definitions[feature].try(:touch_context) end end -private + private + def valid_state errors.add(:state, "is not valid in context") unless %w(off on).include?(state) || context.is_a?(Account) && state == 'allowed' end diff --git a/app/models/grading_period.rb b/app/models/grading_period.rb index 51fdaad544a..2d4159341dc 100644 --- a/app/models/grading_period.rb +++ b/app/models/grading_period.rb @@ -1,5 +1,5 @@ # -# Copyright (C) 2015-2016 Instructure, Inc. +# Copyright (C) 2015 - 2016 Instructure, Inc. # # This file is part of Canvas. # @@ -23,6 +23,7 @@ class GradingPeriod < ActiveRecord::Base has_many :scores, -> { active } validates :title, :start_date, :end_date, :close_date, :grading_period_group_id, presence: true + validates :weight, numericality: true, allow_nil: true validate :start_date_is_before_end_date validate :close_date_is_on_or_after_end_date validate :not_overlapping, unless: :skip_not_overlapping_validator? @@ -30,7 +31,7 @@ class GradingPeriod < ActiveRecord::Base before_validation :adjust_close_date_for_course_period before_validation :ensure_close_date - after_save :recompute_scores, if: :dates_changed? + after_save :recompute_scores, if: :dates_or_weight_changed? after_destroy :destroy_grading_period_set, if: :last_remaining_legacy_period? after_destroy :destroy_scores @@ -151,7 +152,7 @@ class GradingPeriod < ActiveRecord::Base def as_json_with_user_permissions(user) as_json( - only: [:id, :title, :start_date, :end_date, :close_date], + only: [:id, :title, :start_date, :end_date, :close_date, :weight], permissions: { user: user }, methods: [:is_last, :is_closed], ).fetch(:grading_period) @@ -230,13 +231,31 @@ class GradingPeriod < ActiveRecord::Base courses = [grading_period_group.course] else term_ids = grading_period_group.enrollment_terms.pluck(:id) - courses = Course.where(enrollment_term_id: term_ids) + courses = Course.active.where(enrollment_term_id: term_ids) + end + + courses.each do |course| + course.recompute_student_scores( + # different assignments could fall in this period if time + # boundaries changed so we need to recalculate scores. + # otherwise, weight must have changed, in which case we + # do not need to recompute the grading period scores (we + # only need to recompute the overall course score) + grading_period_id: time_boundaries_changed? ? id : nil, + update_all_grading_period_scores: false + ) end - # Course#recompute_student_scores is asynchronous - courses.each { |course| course.recompute_student_scores(grading_period_id: self.id) } end - def dates_changed? + def weight_actually_changed? + grading_period_group.weighted && weight_changed? + end + + def time_boundaries_changed? start_date_changed? || end_date_changed? end + + def dates_or_weight_changed? + time_boundaries_changed? || weight_actually_changed? + end end diff --git a/app/models/grading_period_group.rb b/app/models/grading_period_group.rb index d55cfe4b876..c4571708fe4 100644 --- a/app/models/grading_period_group.rb +++ b/app/models/grading_period_group.rb @@ -26,31 +26,28 @@ class GradingPeriodGroup < ActiveRecord::Base validate :associated_with_course_or_root_account, if: :active? + after_save :recompute_course_scores, if: :weighted_changed? after_destroy :dissociate_enrollment_terms set_policy do given do |user| - multiple_grading_periods_enabled? && (course || root_account).grants_right?(user, :read) end can :read given do |user| root_account && - multiple_grading_periods_enabled? && root_account.associated_user?(user) end can :read given do |user| - multiple_grading_periods_enabled? && (course || root_account).grants_right?(user, :manage) end can :update and can :delete given do |user| root_account && - multiple_grading_periods_enabled? && root_account.grants_right?(user, :manage) end can :create @@ -62,12 +59,14 @@ class GradingPeriodGroup < ActiveRecord::Base root_account.grading_period_groups.active end - def multiple_grading_periods_enabled? - multiple_grading_periods_on_course? || multiple_grading_periods_on_account? - end - private + def recompute_course_scores + return course.recompute_student_scores(update_all_grading_period_scores: false) if course_id.present? + + enrollment_terms.each { |term| term.recompute_course_scores(update_all_grading_period_scores: false) } + end + def associated_with_course_or_root_account if course_id.blank? && account_id.blank? errors.add(:course_id, t("cannot be nil when account_id is nil")) @@ -84,17 +83,6 @@ class GradingPeriodGroup < ActiveRecord::Base end end - def multiple_grading_periods_on_account? - root_account.present? && ( - root_account.feature_enabled?(:multiple_grading_periods) || - root_account.feature_allowed?(:multiple_grading_periods) - ) - end - - def multiple_grading_periods_on_course? - course.present? && course.feature_enabled?(:multiple_grading_periods) - end - def dissociate_enrollment_terms enrollment_terms.update_all(grading_period_group_id: nil) end diff --git a/app/models/group.rb b/app/models/group.rb index a54f4fd4fe6..dd9232a8618 100644 --- a/app/models/group.rb +++ b/app/models/group.rb @@ -715,6 +715,10 @@ class Group < ActiveRecord::Base context.feature_enabled?(feature) end + def grading_periods? + !!context.try(:grading_periods?) + end + def serialize_permissions(permissions_hash, user, session) permissions_hash.merge( create_discussion_topic: DiscussionTopic.context_allows_user_to_create?(self, user, session), diff --git a/app/serializers/grading_period_set_serializer.rb b/app/serializers/grading_period_set_serializer.rb index f8b78f84c63..caa56d201ba 100644 --- a/app/serializers/grading_period_set_serializer.rb +++ b/app/serializers/grading_period_set_serializer.rb @@ -4,6 +4,7 @@ class GradingPeriodSetSerializer < Canvas::APISerializer attributes :id, :title, + :weighted, :account_id, :course_id, :grading_periods, diff --git a/app/views/gradebooks/grade_summary.html.erb b/app/views/gradebooks/grade_summary.html.erb index 081d6f0c354..9c8df37ad7f 100644 --- a/app/views/gradebooks/grade_summary.html.erb +++ b/app/views/gradebooks/grade_summary.html.erb @@ -54,7 +54,7 @@