diff --git a/conf/authen_LTI.conf.dist b/conf/authen_LTI.conf.dist index 8e901ece14..e889d862fd 100644 --- a/conf/authen_LTI.conf.dist +++ b/conf/authen_LTI.conf.dist @@ -135,18 +135,58 @@ $LTIGradeMode = ''; #$LTIGradeMode = 'course'; #$LTIGradeMode = 'homework'; -# When set this variable sends grades back to the LMS every time a user submits an answer. This -# keeps students grades up to date but can be a drain on the server. -$LTIGradeOnSubmit = 1; - -# If $LTICheckPrior is set to 1 then the current LMS grade will be checked first, and if the grade -# has not changed then the grade will not be updated. This is intended to reduce changes to LMS -# records when no real grade change occurred. It requires a 2 round process, first querying the -# current grade from the LMS and then when needed making the grade submission. +# Controls for when to report grades to the LMS. + +# This variable can be set to 'open_date', 'reduced_scoring_date', 'close_date', 'answer_date', +# or 'never', corresponding to the open, reduced scoring, close, and answer dates for a set, or +# never. After this type of date has passed, WeBWorK will send grades to the LMS, subject to the +# other controls described below. Prior to this date, WeBWorK will only send grades to the LMS if +# the set has met the condition set with $LTISendGradesEarlyThreshold. For example, if it is set +# to 'close', then once the close date is reached for a set and something triggers a potential +# grade passback, scores will be sent to the LMS regardless of whether or not the set has been +# attempted or has reached a threshold score. Prior to the close date, if something triggers a +# potential grade passback, whether or not the set's scores are passed depends on the value of +# $LTISendGradesEarlyThreshold. In 'course' grade mode, this is controlling whether or not the +# set is included in the overall course grade computation. In 'homework' grade mode, it is only +# about that set's score. For a test, the date that matters is the earliest date among the versions +# or the set template. +$LTISendScoresAfterDate = 'open_date'; + +# This variable can be set to a number from 0 to 1 inclusive, or set to the string 'attempted'. +# When something triggers a potential grade passback, if it is earlier than $LTISendScoresAfterDate, +# the condition described by this variable must be met or else no grade will be sent. (In 'course' +# grade mode, the set will not be included in the overall grade calculation.) If this variable is +# a number, then the set will need to have a score that reaches or exceeds this number for its +# score to be sent to the LMS (or included in the 'course' grade calcuation). If this variable is +# set to 'attempted', then the set needs to have been attempted for its score to be sent to the LMS +# (or included in the 'course' grade calcuation). For a regular or jitar set, 'attempted' means that +# at least one exercise was attempted. For a test, 'attempted' means that either multiple versions +# exist or there is one version with a graded submission. +$LTISendGradesEarlyThreshold = 0; + +# When this variable is set, then each time WeBWorK would send a grade to the LMS, it first +# requests the current score from the LMS. If there is not a significant difference between the +# current score in the LMS and the score that WeBWorK is possibly going to send, then WeBWorK will +# not send a score to the LMS. This is intended to avoid frequent insignificant updates to the +# student's grades in the LMS. With some LMSs, some students receive notifications each time there +# is a grade update, and setting this variable will prevent too many notifications for them. This +# does create a two-phase process, first querying the current grade from the LMS and then when +# needed making the grade submission. $LTICheckPrior = 0; -# The system periodically updates student grades on the LMS. This variable controls how often -# that happens. Set to -1 to disable. +# When this variable is set then every time a user submits an answer, WeBWorK may send grades back +# to the LMS. This is subject to the above settings. If using 'course' grade mode, WeBWorK will +# possibly send a new overall score for this user. If using 'homework' grade mode, WeBWorK will +# only send a new score for this user for this set. Since this may put a strain on the server, it +# can be turned off and grades will only be sent to the LMS according to the $LTIMassUpdateInterval +# or when an instructor initiates a mass grade passback using LTI Grade Update. +$LTIGradeOnSubmit = 1; + +# The system periodically updates student grades on the LMS. This variable controls how often +# that happens. Set this to -1 to disable mass passback. These mass passback events must be triggered +# by someone in the course doing something after enough time has passed, even as simple as loading a +# page. So for example this cannot be set in such a way as to make sure the passback events happen +# at a particular time each day. $LTIMassUpdateInterval = 86400; #in seconds ################################################################################################ @@ -170,6 +210,9 @@ $LTIMassUpdateInterval = 86400; #in seconds #'LTI{v1p3}{LMS_url}', #'external_auth', #'LTIGradeMode', + #'LTISendScoresAfterDate', + #'LTISendGradesEarlyThreshold', + #'LTICheckPrior', #'LTIGradeOnSubmit', #'LTIMassUpdateInterval', #'LMSManageUserData', diff --git a/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm b/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm index a3ec411f47..fb4d0541a6 100644 --- a/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm +++ b/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm @@ -30,7 +30,7 @@ use Digest::SHA qw(sha1_base64); use WeBWorK::Debug; use WeBWorK::Utils qw(wwRound); -use WeBWorK::Utils::Sets qw(grade_set grade_gateway grade_all_sets); +use WeBWorK::Utils::Sets qw(grade_set grade_gateway gateway_attempted earliest_gateway_date grade_all_sets get_date); # This package contains utilities for submitting grades to the LMS sub new ($invocant, $c, $post_processing_mode = 0) { @@ -128,7 +128,8 @@ async sub submit_course_grade ($self, $userID) { $self->warning("lis_source_did is not available for user: $userID") if !$user->lis_source_did && ($ce->{debug_lti_grade_passback} || $self->{post_processing_mode}); - return await $self->submit_grade($user->lis_source_did, scalar(grade_all_sets($db, $userID))); + return await $self->submit_grade($user->lis_source_did, + scalar(grade_all_sets($db, $userID, $ce->{LTISendScoresAfterDate}, $ce->{LTISendGradesEarlyThreshold}))); } # Computes and submits the set grade for $userID and $setID to the LMS. For gateways the best score is used. @@ -147,18 +148,37 @@ async sub submit_set_grade ($self, $userID, $setID) { $self->warning('lis_source_did is not available for this set.') if !$userSet->lis_source_did && ($ce->{debug_lti_grade_passback} || $self->{post_processing_mode}); - return await $self->submit_grade( - $userSet->lis_source_did, - scalar( - $userSet->assignment_type =~ /gateway/ - ? grade_gateway($db, $userSet, $userSet->set_id, $userID) - : grade_set($db, $userSet, $userID, 0) - ) - ); + my $score; + my $incorrect_attempts = []; + my $attempted; + my $date; + + if ($userSet->assignment_type =~ /gateway/) { + $score = scalar(grade_gateway($db, $userSet, $userSet->set_id, $userID)); + $attempted = gateway_attempted($db, $userSet, $userSet->set_id, $userID); + $date = earliest_gateway_date($db, $userSet->set_id, $userID, $ce->{LTISendScoresAfterDate}); + } else { + my $totalRight; + my $total; + ($totalRight, $total, $incorrect_attempts) = (grade_set($db, $userSet, $userID, 0, 1))[ 0, 1, 3 ]; + $score = ($total == 0) ? 0 : $totalRight / $total; + $attempted = 1 if ($totalRight || grep $_ > 0, @$incorrect_attempts); + $date = get_date($userSet, $ce->{LTISendScoresAfterDate}); + } + + my $beforeSendScoresAfterDate = $ce->{LTISendScoresAfterDate} eq 'never' || before($date); + if ($beforeSendScoresAfterDate) { + return if ($ce->{LTISendGradesEarlyThreshold} eq 'attempted' && !$attempted); + return if ($score < $ce->{LTISendGradesEarlyThreshold}); + } + +# $beforeSendScoresAfterDate needs to be passed so that if LTICheckPrior is set, submit_grade() can decide between calling +# an empty grade equivalent to 0 before the SendScoresAfterDate versus not equivalent after the SendScoresAfterDate + return await $self->submit_grade($userSet->lis_source_did, $score, $beforeSendScoresAfterDate); } # Submits a score of $score to the lms with $sourcedid as the identifier. -async sub submit_grade ($self, $sourcedid, $score) { +async sub submit_grade ($self, $sourcedid, $score, $beforeSendScoresAfterDate = 1) { my $c = $self->{c}; my $ce = $c->{ce}; my $db = $c->{db}; @@ -301,11 +321,15 @@ EOS # See: https://webwork.maa.org/moodle/mod/forum/discuss.php?d=5002 debug("LMS grade will be updated. sourcedid: $sourcedid; Old score: $oldScore; New score: $score") if $ce->{debug_lti_grade_passback}; - } elsif ($oldScore ne '' && abs($score - $oldScore) < 0.001) { + } elsif (abs($score - $oldScore) < 0.001 + && ($score != 1 || $oldScore == 1) + && ($score != 0 || $oldScore ne '' || $beforeSendScoresAfterDate)) + { # LMS has essentially the same score, no reason to update it - debug("LMS grade will NOT be updated - grade unchanges. Old score: $oldScore; New score: $score") - if $ce->{debug_lti_grade_passback}; - $self->warning('LMS grade will NOT be updated - grade unchanged. ' + debug( + "LMS grade will NOT be updated - grade has not significantly changed. Old score: $oldScore; New score: $score" + ) if $ce->{debug_lti_grade_passback}; + $self->warning('LMS grade will NOT be updated - grade has not significantly changed. ' . "Old score: $oldScore; New score: $score") if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode}; return 1; diff --git a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm index 688c94d36e..89dda49de0 100644 --- a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm +++ b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm @@ -39,7 +39,7 @@ use Time::HiRes; use WeBWorK::Debug; use WeBWorK::Utils qw(wwRound); -use WeBWorK::Utils::Sets qw(grade_set grade_gateway grade_all_sets); +use WeBWorK::Utils::Sets qw(grade_set grade_gateway gateway_attempted earliest_gateway_date grade_all_sets get_date); # This package contains utilities for submitting grades to the LMS via LTI 1.3. sub new ($invocant, $c, $post_processing_mode = 0) { @@ -207,7 +207,8 @@ async sub submit_course_grade ($self, $userID) { $self->warning('LMS user id is not available for this user.') unless $user->lis_source_did; $self->warning('LMS lineitem is not available for the course.') unless $lineitem; - return await $self->submit_grade($user->lis_source_did, $lineitem, grade_all_sets($db, $userID)); + return await $self->submit_grade($user->lis_source_did, $lineitem, + grade_all_sets($db, $userID, $ce->{LTISendScoresAfterDate}, $ce->{LTISendGradesEarlyThreshold})); } # Computes and submits the set grade for $userID and $setID to the LMS. For gateways the best score is used. @@ -225,14 +226,36 @@ async sub submit_set_grade ($self, $userID, $setID) { $self->warning('LMS user id is not available for this user.') unless $user->lis_source_did; $self->warning('LMS lineitem is not available for this set.') unless $userSet->lis_source_did; - return await $self->submit_grade($user->lis_source_did, $userSet->lis_source_did, - $userSet->assignment_type =~ /gateway/ - ? grade_gateway($db, $userSet, $userSet->set_id, $userID) - : (grade_set($db, $userSet, $userID, 0))[ 0, 1 ]); + my $totalRight; + my $total; + my $incorrect_attempts = []; + my $attempted; + my $date; + + if ($userSet->assignment_type =~ /gateway/) { + ($totalRight, $total) = grade_gateway($db, $userSet, $userSet->set_id, $userID); + $attempted = gateway_attempted($db, $userSet, $userSet->set_id, $userID); + $date = earliest_gateway_date($db, $userSet->set_id, $userID, $ce->{LTISendScoresAfterDate}); + } else { + ($totalRight, $total, $incorrect_attempts) = (grade_set($db, $userSet, $userID, 0, 1))[ 0, 1, 3 ]; + $attempted = 1 if ($totalRight || grep $_ > 0, @$incorrect_attempts); + $date = get_date($userSet, $ce->{LTISendScoresAfterDate}); + } + + my $beforeSendScoresAfterDate = $ce->{LTISendScoresAfterDate} eq 'never' || before($date); + if ($beforeSendScoresAfterDate) { + return if ($ce->{LTISendGradesEarlyThreshold} eq 'attempted' && !$attempted); + return if ($total > 0 && $totalRight / $total < $ce->{LTISendGradesEarlyThreshold}); + } + +# $beforeSendScoresAfterDate needs to be passed so that if LTICheckPrior is set, submit_grade() can decide between calling +# an empty grade equivalent to 0 before the SendScoresAfterDate versus not equivalent after the SendScoresAfterDate + return await $self->submit_grade($user->lis_source_did, $userSet->lis_source_did, $totalRight, $total, + $beforeSendScoresAfterDate); } # Submits scoreGiven and scoreMaximum to the lms with $sourcedid as the identifier. -async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum) { +async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum, $beforeSendScoresAfterDate = 1) { my $c = $self->{c}; my $ce = $c->{ce}; @@ -275,9 +298,16 @@ async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum && $priorData->[0]{resultMaximum} ? $priorData->[0]{resultScore} / $priorData->[0]{resultMaximum} : 0; my $score = $scoreGiven / $scoreMaximum; - if (abs($score - $priorScore) < 0.001) { + # we want to update the LMS score if the difference is significant, + # or if the new score is 1 but the LMS score was not 1 (but possibly insignificantly different) + # or if the new score is 0 and the LMS score was empty and it is past the SendScoresAfterDate + if (abs($score - $priorScore) < 0.001 + && ($score != 1 || $priorScore == 1) + && ($score != 0 || @$priorData && $priorData->[0]{resultScore} ne '' || $beforeSendScoresAfterDate)) + { $self->warning( - "LMS grade will NOT be updated as the grade is unchanged. Old score: $priorScore, New score: $score."); + "LMS grade will NOT be updated as the grade has not significantly changed. Old score: $priorScore, New score: $score." + ); return 1; } diff --git a/lib/WeBWorK/ConfigValues.pm b/lib/WeBWorK/ConfigValues.pm index 24cb77116c..2c5895af19 100644 --- a/lib/WeBWorK/ConfigValues.pm +++ b/lib/WeBWorK/ConfigValues.pm @@ -874,7 +874,7 @@ sub getConfigValues ($ce) { }, 'LTI{v1p1}{LMS_url}' => { var => 'LTI{v1p1}{LMS_url}', - doc => x('A URL for the LMS'), + doc => x('URL for the LMS'), doc2 => x( 'An address that can be used to log in to the LMS. This is used in messages to users ' . 'that direct them to go back to the LMS to access something in the WeBWorK course.' @@ -912,6 +912,59 @@ sub getConfigValues ($ce) { labels => { '' => 'None', 'course' => 'Course', 'homework' => 'Homework' }, type => 'popuplist' }, + LTISendScoresAfterDate => { + var => 'LTISendScoresAfterDate', + doc => x('When to send scores to the LMS regardless of status'), + doc2 => x( + 'After this type of date has passed, WeBWorK will send grades to the LMS, subject to the other ' + . 'controls for grade passback. Prior to this date, WeBWorK will only send grades to the LMS if ' + . "the set has met the condition set by \$LTISendGradesEarlyThreshold. In 'course' grade mode, " + . 'this is controlling whether or not the set is included in the overall course grade computation. ' + . "In 'homework' grade mode, it is only about that set's score. For a test, the date that matters " + . 'is the earliest date among the versions or the set template.' + ), + values => [qw(open_date reduced_scoring_date close_date answer_date never)], + labels => { + open_date => 'After the open date', + reduced_scoring_date => 'After the reduced scoring date', + close_date => 'After the close date', + answer_date => 'After the answer date', + never_date => 'Never' + }, + type => 'popuplist' + }, + LTISendGradesEarlyThreshold => { + var => 'LTISendGradesEarlyThreshold', + doc => x('Condition under which scores can be sent to an LMS early'), + doc2 => x( + "This can be set to a number from 0 to 1 inclusive, or set to the string 'attempted'. When something " + . 'triggers a potential grade passback, if it is earlier than $LTISendScoresAfterDate, the ' + . "condition described by this variable must be met or else no grade will be sent. (In 'course' " + . 'grade mode, the set will not be included in the overall grade calculation.) If this variable is ' + . 'a number, then the set will need to have a score that reaches or exceeds this number for its ' + . "score to be sent to the LMS (or included in the 'course' grade calcuation). If this variable is " + . "set to 'attempted', then the set needs to have been attempted for its score to be sent to the " + . "LMS (or included in the 'course' grade calcuation). For a regular or jitar set, 'attempted' " + . "means that at least one exercise was attempted. For a test, 'attempted' means that either " + . 'multiple versions exist or there is one version with a graded submission.' + ), + type => 'text', + }, + LTICheckPrior => { + var => 'LTICheckPrior', + doc => x('Check a score in the LMS before sending a score to the LMS'), + doc2 => x( + 'When this is set, then each time WeBWorK would send a grade to the LMS, it first requests the current ' + . 'score from the LMS. If there is not a significant difference between the current score in the ' + . 'LMS and the score that WeBWorK is possibly going to send, then WeBWorK will not send a score to ' + . "the LMS. This is intended to avoid frequent insignificant updates to the student's grades in " + . 'the LMS. With some LMSs, some students receive notifications each time there is a grade update, ' + . 'and setting this variable will prevent too many notifications for them. This does create a ' + . 'two-step process, first querying the current grade from the LMS and then when needed making ' + . 'the grade submission.' + ), + type => 'boolean' + }, LTIGradeOnSubmit => { var => 'LTIGradeOnSubmit', doc => x('Update LMS Grade Each Submit'), diff --git a/lib/WeBWorK/Utils/Sets.pm b/lib/WeBWorK/Utils/Sets.pm index 5c2d69c868..fca65219f3 100644 --- a/lib/WeBWorK/Utils/Sets.pm +++ b/lib/WeBWorK/Utils/Sets.pm @@ -20,7 +20,7 @@ use Carp; use PGrandom; use WeBWorK::Utils qw(wwRound); -use WeBWorK::Utils::DateTime qw(after); +use WeBWorK::Utils::DateTime qw(after before); use WeBWorK::Utils::JITAR qw(jitar_id_to_seq jitar_problem_adjusted_status); our @EXPORT_OK = qw( @@ -28,6 +28,9 @@ our @EXPORT_OK = qw( format_set_name_display grade_set grade_gateway + gateway_attempted + get_date + earliest_gateway_date grade_all_sets is_restricted get_test_problem_position @@ -146,7 +149,45 @@ sub grade_gateway ($db, $set, $setName, $studentName) { } } -sub grade_all_sets ($db, $studentName) { +sub gateway_attempted ($db, $setName, $studentName) { + my @versionNums = $db->listSetVersions($studentName, $setName); + + # it counts as "attempted" if there is more than one version + return 1 if (1 < @versionNums); + + # if there is one version, check for an attempted problem + if (@versionNums) { + my $versionedSet = $db->getSetVersion($studentName, $setName, $versionNums[0]); + + my @problemRecords = + $db->getAllMergedProblemVersions($studentName, $versionedSet->set_id, $versionedSet->version_id); + for my $problemRecord (@problemRecords) { + return 1 if $problemRecord->attempted; + } + } + + return 0; +} + +sub earliest_gateway_date ($db, $setName, $studentName, $dateType) { + return 'never' if ($dateType eq 'never'); + my @versionNums = $db->listSetVersions($studentName, $setName); + + # if there are no versions, use the template's date + unless (@versionNums) { + return get_date($db->getMergedSet($studentName, $setName), $dateType); + } + + # otherwise, use the earliest date among versions + my $date = get_date($db->getSetVersion($studentName, $setName, $versionNums[0]), $dateType); + for my $i (@versionNums) { + my $versionedSetDate = get_date($db->getSetVersion($studentName, $setName, $i), $dateType); + $date = $versionedSetDate if ($versionedSetDate < $date); + } + return $date; +} + +sub grade_all_sets ($db, $studentName, $sendScoresAfterDate = 'open_date', $sendGradesEarlyThreshold = 0) { my @setIDs = $db->listUserSets($studentName); my @userSetIDs = map { [ $studentName, $_ ] } @setIDs; my @userSets = $db->getMergedSets(@userSetIDs); @@ -156,17 +197,29 @@ sub grade_all_sets ($db, $studentName) { for my $userSet (@userSets) { next unless (after($userSet->open_date())); - if ($userSet->assignment_type() =~ /gateway/) { + my $totalRight; + my $total; + my $incorrect_attempts = []; + my $attempted; + my $date; - my ($totalRight, $total) = grade_gateway($db, $userSet, $userSet->set_id, $studentName); - $courseTotalRight += $totalRight; - $courseTotal += $total; + if ($userSet->assignment_type() =~ /gateway/) { + ($totalRight, $total) = grade_gateway($db, $userSet, $userSet->set_id, $studentName); + $attempted = gateway_attempted($db, $userSet->set_id, $studentName); + $date = earliest_gateway_date($db, $userSet->set_id, $studentName, $sendScoresAfterDate); } else { - my ($totalRight, $total) = grade_set($db, $userSet, $studentName, 0); + ($totalRight, $total, $incorrect_attempts) = (grade_set($db, $userSet, $studentName, 0, 1))[ 0, 1, 3 ]; + $attempted = 1 if ($totalRight || grep $_ > 0, @$incorrect_attempts); + $date = get_date($userSet, $sendScoresAfterDate); + } - $courseTotalRight += $totalRight; - $courseTotal += $total; + if ($sendScoresAfterDate eq 'never' || before($date)) { + next if ($sendGradesEarlyThreshold eq 'attempted' && !$attempted); + next if ($total > 0 && $totalRight / $total < $sendGradesEarlyThreshold); } + + $courseTotalRight += $totalRight; + $courseTotal += $total; } if (wantarray) { @@ -178,6 +231,16 @@ sub grade_all_sets ($db, $studentName) { } +sub get_date ($set, $type) { + return { + open_date => $set->open_date(), + reduced_scoring_date => $set->reduced_scoring_date() // $set->close_date(), + close_date => $set->close_date(), + answer_date => $set->answer_date(), + never => 'never' + }->{$type}; +} + sub is_restricted ($db, $set, $studentName) { # all sets open after the due date return () if after($set->due_date());