diff --git a/conf/authen_LTI.conf.dist b/conf/authen_LTI.conf.dist index 8e901ece14..9e2ab3ee76 100644 --- a/conf/authen_LTI.conf.dist +++ b/conf/authen_LTI.conf.dist @@ -135,19 +135,84 @@ $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; +# There are several controls for when to report scores to the LMS. Sometimes these controls +# interact with each other, and the details of how they work may depend on whether $LTIGradeMode +# is set to 'course' or 'homework'. So it is recommended to understand all of them and then +# decide how to set them. + +# If $LTICheckPrior is 1, then any time WeBWorK is about to send a score to the LMS, it will +# first request from the LMS what that score currently is. Then if there is no significant +# difference between the LMS score and the WeBWorK score, WeBWorK will not follow through with +# updating the LMS score. This is to avoid frequent insignificant updates to a student's scores +# in the LMS. With some LMSs, students may receive notifications each time a score is updated, +# and setting this variable will prevent too many notifications for them. This does create a +# two-phase process, first querying the current score from the LMS and then actually updating +# the score (if there is a significant difference). -# 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. +# Additional details: +# - If the LMS score is not 100%, but the WeBWorK score is, then even if the LMS score is only +# insignificantly less than 100%, it will be updated anyway. +# - If the LMS score is null and the WeBWorK score is 0, this is considered an insignificant +# difference and the LMS score will not be updated to 0. However if it is after the +# $LTISendScoresAfterDate (described below), then the null score will be updated to 0 anyway. +# - "Significant" means an absolute difference of 0.001, or 0.1%. At this time this is not +# configurable. $LTICheckPrior = 0; -# The system periodically updates student grades on the LMS. This variable controls how often -# that happens. Set to -1 to disable. -$LTIMassUpdateInterval = 86400; #in seconds +# If $LTIGradeOnSubmit is set to 1, then each time a user submits an answer or scores a test, +# that will trigger WeBWorK possibly reporting a score to the LMS. See $LTICheckPrior for one +# reason that WeBWorK might not ultimately send a score. But there are other reasons too. +# WeBWorK will send the score (the assignment's score if $LTIGradeMode is 'homework' or the +# overall course score if $LTIGradeMode is 'course') to the LMS only if either the assignment's +# $LTISendGradesEarlyThreshold (described below) has been met or if it is past that assignment's +# $LTISendScoresAfterDate (also described below). +$LTIGradeOnSubmit = 1; + +# In addition to scores possibly being sent to the LMS upon submission, they can be sent by an +# instructor or admin user using the LTI Grades Update Tool. And thirdly, the system can +# periodically update student scores on the LMS on its own. For all three possible triggers for +# scores to be passed to the LMS, $LTISendScoresAfterDate and $LTISendGradesEarlyThreshold can +# affect what is sent. $LTISendScoresAfterDate can be 'open_date', 'reduced_scoring_date', +# 'due_date', 'answer_date', or 'never'. For a given assignment, if it is after the +# $LTISendScoresAfterDate, then WeBWorK will send scores. If $LTISendScoresAfterDate is 'never', +# then there is no date after which WeBWorK is guaranteed to send scores. In that case, scores +# are only sent when a set's $LTISendGradesEarlyThreshold is met (see below). +# - For 'course' grade passback mode, the assignment will be included in the overall course +# grade calculation. +# - For 'homework' grade passback mode, the assignment's score will be sent. + +# If $LTISendScoresAfterDate is 'reduced_scoring_date' and an assignment has no reduced scoring +# date or reduced scoring is disabled for that assignment, the fallback is to use the due date. + +# For a given assignment, if $LTISendScoresAfterDate is 'never' or if it is before the date +# specified by $LTISendScoresAfterDate, WeBWorK may send a score to the LMS depending on the +# value of $LTISendGradesEarlyThreshold. This variable can either be the string 'attempted' or a +# number from 0 to 1. If this variable is 'attempted', a given set must have been attempted for +# the threshold to have been met, and then the score can be used even if it is before the +# $LTISendScoresAfterDate. For a non-test set, 'attempted' just means that some exercise in the +# set was attempted using the Submit button. For a test, 'attempted' means that either there is +# one version with a graded submission, or there are at least two versions. + +# If $LTISendGradesEarlyThreshold is a number from 0 to 1, the score for an assignment needs to +# have reached that number for the threshold to be met, and then the score can be used even if +# it is before the $LTISendScoresAfterDate. + +#$LTISendScoresAfterDate = 'open_date'; +$LTISendScoresAfterDate = 'reduced_scoring_date'; +#$LTISendScoresAfterDate = 'due_date'; +#$LTISendScoresAfterDate = 'answer_date'; +#$LTISendScoresAfterDate = 'never'; + +$LTISendGradesEarlyThreshold = 'attempted'; +#$LTISendGradesEarlyThreshold = 0; +#$LTISendGradesEarlyThreshold = 0.7; +#$LTISendGradesEarlyThreshold = 1; + +# The system periodically updates student scores on the LMS. If it has been at least this many +# seconds since the last mass passback event and someone in the course does anything to load a +# page, then a new mass passback job will begin. Set this to -1 to disable mass passback. +$LTIMassUpdateInterval = 86400; + ################################################################################################ # Add an 'LTI' tab to the Course Configuration page @@ -170,7 +235,10 @@ $LTIMassUpdateInterval = 86400; #in seconds #'LTI{v1p3}{LMS_url}', #'external_auth', #'LTIGradeMode', + #'LTICheckPrior', #'LTIGradeOnSubmit', + #'LTISendScoresAfterDate', + #'LTISendGradesEarlyThreshold', #'LTIMassUpdateInterval', #'LMSManageUserData', #'LTI{v1p1}{BasicConsumerSecret}', diff --git a/conf/authen_LTI_1_1.conf.dist b/conf/authen_LTI_1_1.conf.dist index 0f1d46dd61..c2c8bb7f62 100644 --- a/conf/authen_LTI_1_1.conf.dist +++ b/conf/authen_LTI_1_1.conf.dist @@ -227,7 +227,4 @@ $LTI{v1p1}{LMSrolesToWeBWorKroles} = { # $userSet->answer_date($niceAnswerTime); #}; -# Do not change this. -$LTI{v1p1}{grader} = 'WeBWorK::Authen::LTIAdvanced::SubmitGrade'; - 1; # final line of the file to reassure perl that it was read properly. diff --git a/conf/authen_LTI_1_3.conf.dist b/conf/authen_LTI_1_3.conf.dist index 48c443070d..823155e1cc 100644 --- a/conf/authen_LTI_1_3.conf.dist +++ b/conf/authen_LTI_1_3.conf.dist @@ -209,7 +209,4 @@ $LTI{v1p3}{AllowInstitutionRoles} = 0; $LTI{v1p3}{ignoreMissingSourcedID} = 0; -# Do not change this. -$LTI{v1p3}{grader} = 'WeBWorK::Authen::LTIAdvantage::SubmitGrade'; - 1; # final line of the file to reassure perl that it was read properly. diff --git a/lib/Caliper/Entity.pm b/lib/Caliper/Entity.pm index 13d9561a79..78883b765e 100644 --- a/lib/Caliper/Entity.pm +++ b/lib/Caliper/Entity.pm @@ -372,7 +372,7 @@ sub problem_set_attempt { my $extensions = { 'attempt_score' => $score, }; if ($version_id) { - $extensions->{'gateway_score'} = grade_gateway($db, $problem_set_user, $problem_set_user->set_id, $user_id); + $extensions->{'gateway_score'} = grade_gateway($db, $problem_set_user->set_id, $user_id); } my $problem_set_attempt = { diff --git a/lib/WeBWorK/Authen/LTI/GradePassback.pm b/lib/WeBWorK/Authen/LTI/GradePassback.pm new file mode 100644 index 0000000000..9ee61b50c0 --- /dev/null +++ b/lib/WeBWorK/Authen/LTI/GradePassback.pm @@ -0,0 +1,185 @@ +############################################################################### +# WeBWorK Online Homework Delivery System +# Copyright © 2000-2024 The WeBWorK Project, https://github.com/openwebwork +# +# This program is free software; you can redistribute it and/or modify it under +# the terms of either: (a) the GNU General Public License as published by the +# Free Software Foundation; either version 2, or (at your option) any later +# version, or (b) the "Artistic License" which comes with this package. +# +# This program 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 either the GNU General Public License or the +# Artistic License for more details. +################################################################################ + +package WeBWorK::Authen::LTI::GradePassback; +use Mojo::Base 'Exporter', -signatures, -async_await; + +=head1 NAME + +WeBWorK::Authen::LTI::GradePassback - Grade passback utilities for LTI authentication + +=cut + +use WeBWorK::Utils::DateTime qw(after before); +use WeBWorK::Utils::Sets qw(grade_set grade_gateway); + +our @EXPORT_OK = qw(massUpdate passbackGradeOnSubmit getSetPassbackScore); + +# These must be required and not used, and must be after the exports are defined above. +# Otherwise this will create a circular dependency with the SubmitGrade modules. +require WeBWorK::Authen::LTIAdvanced::SubmitGrade; +require WeBWorK::Authen::LTIAdvantage::SubmitGrade; + +# Perform a mass update of all grades. This is all user grades for course grade mode and all user set grades for +# homework grade mode if $manual_update is false. Otherwise what is updated is determined by a combination of the grade +# mode and the useriD and setID parameters. Note that the only required parameter is $c which should be a +# WeBWorK::Controller object with a valid course environment and database. +sub massUpdate ($c, $manual_update = 0, $userID = undef, $setID = undef) { + my $ce = $c->ce; + my $db = $c->db; + + # Sanity check. + unless (ref($ce)) { + warn('course environment is not defined'); + return; + } + unless (ref($db)) { + warn('database reference is not defined'); + return; + } + + # Only run an automatic update if the time interval has passed. + if (!$manual_update) { + my $lastUpdate = $db->getSettingValue('LTILastUpdate') || 0; + my $updateInterval = $ce->{LTIMassUpdateInterval} // -1; + return unless ($updateInterval != -1 && time - $lastUpdate > $updateInterval); + $db->setSettingValue('LTILastUpdate', time); + } + + # Send warning if debug_lti_grade_passback is set. + if ($ce->{debug_lti_grade_passback}) { + if ($setID && $userID && $ce->{LTIGradeMode} eq 'homework') { + warn "LTI Mass Update: Queueing grade update for user $userID and set $setID.\n"; + } elsif ($setID && $ce->{LTIGradeMode} eq 'homework') { + warn "LTI Mass Update: Queueing grade update for all users assigned to set $setID.\n"; + } elsif ($userID) { + warn "LTI Mass Update: Queueing grade update of all sets assigned to user $userID.\n"; + } else { + warn "LTI Mass Update: Queueing grade update for all sets and users.\n"; + } + } + + $c->minion->enqueue(lti_mass_update => [ $userID, $setID ], { notes => { courseID => $ce->{courseName} } }); + + return; +} + +async sub passbackGradeOnSubmit ($c, $userID, $set) { + my $ce = $c->ce; + + my $LMSname = $ce->{LTI}{ $ce->{LTIVersion} }{LMS_name}; + + if ($ce->{LTIGradeOnSubmit}) { + my $LTIGradeResult = 0; + + my $grader = + $ce->{LTIVersion} eq 'v1p1' + ? WeBWorK::Authen::LTIAdvanced::SubmitGrade->new($c) + : WeBWorK::Authen::LTIAdvantage::SubmitGrade->new($c); + + if ($ce->{LTIGradeMode} eq 'course') { + $LTIGradeResult = await $grader->submit_course_grade($userID, $set); + } elsif ($ce->{LTIGradeMode} eq 'homework') { + $LTIGradeResult = await $grader->submit_set_grade($userID, $set->set_id, $set); + } + if ($LTIGradeResult == 0) { + return $c->maketext('Your score was not successfully sent to [_1].', $LMSname); + } elsif ($LTIGradeResult > 0) { + return $c->maketext('Your score was successfully sent to [_1].', $LMSname); + } elsif ($LTIGradeResult < 0) { + return $c->maketext('Your score will be sent to [_1] at a later time.', $LMSname); + } + } elsif ($ce->{LTIMassUpdateInterval} > 0) { + if ($ce->{LTIMassUpdateInterval} < 120) { + return $c->maketext('Scores are sent to [_1] every [quant,_2,second].', + $LMSname, $ce->{LTIMassUpdateInterval}); + } elsif ($ce->{LTIMassUpdateInterval} < 7200) { + return $c->maketext('Scores are sent to [_1] every [quant,_2,minute].', + $LMSname, int($ce->{LTIMassUpdateInterval} / 60 + 0.99)); + } else { + return $c->maketext('Scores are sent to [_1] every [quant,_2,hour].', + $LMSname, int($ce->{LTIMassUpdateInterval} / 3600 + 0.9999)); + } + } +} + +sub setAttempted ($problems, $setVersions = undef) { + return 0 unless ref($problems) eq 'ARRAY'; + + # If this is a test with set versions, then it counts as "attempted" if there is more than one set version. + return 1 if ref($setVersions) eq 'ARRAY' && @$setVersions > 1; + + for (@$problems) { + return 1 if $_->attempted || $_->status > 0; + } + return 0; +} + +sub earliestGatewayDate ($ce, $userSet, $setVersions) { + # If there are no versions, use the template's date. + return getLTISendScoresAfterDate($userSet, $ce) unless ref($setVersions) eq 'ARRAY'; + + # Otherwise, use the earliest date among versions. + my $earliest_date = -1; + for (@$setVersions) { + my $versionedSetDate = getLTISendScoresAfterDate($_, $ce); + $earliest_date = $versionedSetDate if $earliest_date == -1 || $versionedSetDate < $earliest_date; + } + return $earliest_date; +} + +sub getLTISendScoresAfterDate ($set, $ce) { + if ($ce->{LTISendScoresAfterDate} eq 'open_date') { + return $set->open_date; + } elsif ($ce->{LTISendScoresAfterDate} eq 'reduced_scoring_date') { + return ($ce->{pg}{ansEvalDefaults}{enableReducedScoring} + && $set->enable_reduced_scoring + && $set->reduced_scoring_date) ? $set->reduced_scoring_date : $set->due_date; + } elsif ($ce->{LTISendScoresAfterDate} eq 'due_date') { + return $set->due_date; + } elsif ($ce->{LTISendScoresAfterDate} eq 'answer_date') { + return $set->answer_date; + } +} + +# Returns a reference to hash with the keys totalRight, total, and score if the +# set has met the conditions for grade pass back to occur, and undef otherwise. +sub getSetPassbackScore ($db, $ce, $userID, $userSet, $gradingSubmission = 0) { + my ($totalRight, $total, $problemRecords, $setVersions) = + $userSet->assignment_type =~ /gateway/ + ? grade_gateway($db, $userSet->set_id, $userID) + : grade_set($db, $userSet, $userID); + + my $return = { totalRight => $totalRight, total => $total, score => $total ? $totalRight / $total : 0 }; + + return $return if $gradingSubmission && $ce->{LTISendGradesEarlyThreshold} eq 'attempted'; + + my $criticalDate = + $ce->{LTISendScoresAfterDate} ne 'never' + ? ($userSet->assignment_type =~ /gateway/ + ? earliestGatewayDate($ce, $userSet, $setVersions) + : getLTISendScoresAfterDate($userSet, $ce)) + : undef; + + return $return + if ($criticalDate && after($criticalDate)) + || ($ce->{LTISendGradesEarlyThreshold} eq 'attempted' && setAttempted($problemRecords, $setVersions)) + || ($ce->{LTISendGradesEarlyThreshold} ne 'attempted' + && $return->{score} >= $ce->{LTISendGradesEarlyThreshold}); + + return; +} + +1; diff --git a/lib/WeBWorK/Authen/LTI/MassUpdate.pm b/lib/WeBWorK/Authen/LTI/MassUpdate.pm deleted file mode 100644 index 103498a765..0000000000 --- a/lib/WeBWorK/Authen/LTI/MassUpdate.pm +++ /dev/null @@ -1,71 +0,0 @@ -############################################################################### -# WeBWorK Online Homework Delivery System -# Copyright © 2000-2024 The WeBWorK Project, https://github.com/openwebwork -# -# This program is free software; you can redistribute it and/or modify it under -# the terms of either: (a) the GNU General Public License as published by the -# Free Software Foundation; either version 2, or (at your option) any later -# version, or (b) the "Artistic License" which comes with this package. -# -# This program 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 either the GNU General Public License or the -# Artistic License for more details. -################################################################################ - -package WeBWorK::Authen::LTI::MassUpdate; -use Mojo::Base 'Exporter', -signatures; - -=head1 NAME - -WeBWorK::Authen::LTI::MassUpdate - Mass update grades to the LMS with LTI authentication - -=cut - -our @EXPORT_OK = qw(mass_update); - -# Perform a mass update of all grades. This is all user grades for course grade mode and all user set grades for -# homework grade mode if $manual_update is false. Otherwise what is updated is determined by a combination of the grade -# mode and the useriD and setID parameters. Note that the only required parameter is $c which should be a -# WeBWorK::Controller object with a valid course environment and database. -sub mass_update ($c, $manual_update = 0, $userID = undef, $setID = undef) { - my $ce = $c->ce; - my $db = $c->db; - - # Sanity check. - unless (ref($ce)) { - warn('course environment is not defined'); - return; - } - unless (ref($db)) { - warn('database reference is not defined'); - return; - } - - # Only run an automatic update if the time interval has passed. - if (!$manual_update) { - my $lastUpdate = $db->getSettingValue('LTILastUpdate') || 0; - my $updateInterval = $ce->{LTIMassUpdateInterval} // -1; - return unless ($updateInterval != -1 && time - $lastUpdate > $updateInterval); - $db->setSettingValue('LTILastUpdate', time); - } - - # Send warning if debug_lti_grade_passback is set. - if ($ce->{debug_lti_grade_passback}) { - if ($setID && $userID && $ce->{LTIGradeMode} eq 'homework') { - warn "LTI Mass Update: Queueing grade update for user $userID and set $setID.\n"; - } elsif ($setID && $ce->{LTIGradeMode} eq 'homework') { - warn "LTI Mass Update: Queueing grade update for all users assigned to set $setID.\n"; - } elsif ($userID) { - warn "LTI Mass Update: Queueing grade update of all sets assigned to user $userID.\n"; - } else { - warn "LTI Mass Update: Queueing grade update for all sets and users.\n"; - } - } - - $c->minion->enqueue(lti_mass_update => [ $userID, $setID ], { notes => { courseID => $ce->{courseName} } }); - - return; -} - -1; diff --git a/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm b/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm index a3ec411f47..b86c6d5d7b 100644 --- a/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm +++ b/lib/WeBWorK/Authen/LTIAdvanced/SubmitGrade.pm @@ -30,7 +30,8 @@ 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_all_sets); +use WeBWorK::Authen::LTI::GradePassback qw(getSetPassbackScore); # This package contains utilities for submitting grades to the LMS sub new ($invocant, $c, $post_processing_mode = 0) { @@ -115,7 +116,7 @@ sub update_sourcedid ($self, $userID) { # Computes and submits the course grade for userID to the LMS. # The course grade is the average of all sets assigned to the user. -async sub submit_course_grade ($self, $userID) { +async sub submit_course_grade ($self, $userID, $submittedSet = undef) { my $c = $self->{c}; my $ce = $c->{ce}; my $db = $c->{db}; @@ -123,16 +124,36 @@ async sub submit_course_grade ($self, $userID) { my $user = $db->getUser($userID); return 0 unless $user; - $self->warning("submitting all grades for user: $userID") + $self->warning("Preparing to submit overall course grade to LMS for user $userID.") if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode}; - $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))); + unless ($user->lis_source_did) { + $self->warning("lis_source_did is not available for this user") + if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode}; + return 0; + } + + if ($submittedSet && !getSetPassbackScore($db, $ce, $userID, $submittedSet, 1)) { + $self->warning("Set's critical date has not yet passed, and user has not yet met the threshold to send set's " + . 'score early. Not submitting grade.'); + return -1; + } + + my ($courseTotalRight, $courseTotal, $includedSets) = grade_all_sets($db, $ce, $userID, \&getSetPassbackScore); + if (@$includedSets) { + $self->warning( + "Submitting overall score for user $userID for sets: " . join(', ', map { $_->set_id } @$includedSets)) + if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode}; + my $score = $courseTotal ? $courseTotalRight / $courseTotal : 0; + return await $self->submit_grade($user->lis_source_did, $score); + } else { + $self->warning("No sets for user $userID meet criteria to be included in course grade calculation."); + return -1; + } } # Computes and submits the set grade for $userID and $setID to the LMS. For gateways the best score is used. -async sub submit_set_grade ($self, $userID, $setID) { +async sub submit_set_grade ($self, $userID, $setID, $submittedSet = undef) { my $c = $self->{c}; my $ce = $c->{ce}; my $db = $c->{db}; @@ -140,21 +161,24 @@ async sub submit_set_grade ($self, $userID, $setID) { my $user = $db->getUser($userID); return 0 unless $user; - my $userSet = $db->getMergedSet($userID, $setID); - - $self->warning("Submitting grade for user $userID and set $setID.") + $self->warning("Preparing to submit grade to LMS for user $userID and set $setID.") if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode}; - $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 $userSet = $submittedSet // $db->getMergedSet($userID, $setID); + unless ($userSet->lis_source_did) { + $self->warning('lis_source_did is not available for this set.') + if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode}; + return 0; + } + + my $score = getSetPassbackScore($db, $ce, $userID, $userSet, !$self->{post_processing_mode}); + unless ($score) { + $self->warning("Set's critical date has not yet passed, and user has not yet met the threshold to send set's " + . 'score early. Not submitting grade.'); + return -1; + } + + return await $self->submit_grade($userSet->lis_source_did, $score->{score}); } # Submits a score of $score to the lms with $sourcedid as the identifier. @@ -165,9 +189,6 @@ async sub submit_grade ($self, $sourcedid, $score) { $score = wwRound(2, $score); - # Fail gracefully. Some users, like instructors, may not actually have a sourcedid. - return 0 if !$sourcedid; - my $request_url = $db->getSettingValue('lis_outcome_service_url'); if (!$request_url) { $self->warning('Cannot send/retrieve grades to/from the LMS, no lis_outcome_service_url'); @@ -278,50 +299,47 @@ EOS $content =~ /\s*(\w+)\s*<\/imsx_codeMajor>/; my $message = $1; if ($message ne 'success') { - $self->warning( - 'Unable to retrieve prior grade from LMS. Note that if your server time is not correct, ' - . 'this may fail for reasons which are less than obvious from the error messages. Error: ' - . $message); - debug('Unable to retrieve prior grade from LMS. Note that if your server time is not correct, ' - . 'this may fail for reasons which are less than obvious from the error messages. Error: ' - . $message); + $self->warning('Unable to retrieve prior grade from LMS. Error: ' . $message); + debug('Unable to retrieve prior grade from LMS. Error: ' . $message); return 0; } else { - my $oldScore; + my $priorScore; # Possibly no score yet. if ($content =~ //) { - $oldScore = ''; + $priorScore = ''; } else { $content =~ /\s*(\S+)\s*<\/textString>/; - $oldScore = $1; + $priorScore = $1; } - # Do not update the score if no change. - if ($oldScore eq 'success') { - # Blackboard seems to return this when there is no prior grade. - # 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) { + + # Blackboard seems to return this when there is no prior grade. + # See: https://webwork.maa.org/moodle/mod/forum/discuss.php?d=5002 + $priorScore = '' if $priorScore eq 'success'; + + # Do not update the score if there is no significant change. Note that the cases where the webwork score + # is exactly 1 and the LMS score is not exactly 1, and the case where the webwork score is 0 and the LMS + # score is not set are considered significant changes. + if (abs($score - ($priorScore || 0)) < 0.001 + && ($score != 1 || $priorScore == 1) + && ($score != 0 || $priorScore ne '')) + { # 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") + debug('LMS grade will NOT be updated - grade has not significantly changed. ' + . "Old score: $priorScore; New score: $score") if $ce->{debug_lti_grade_passback}; - $self->warning('LMS grade will NOT be updated - grade unchanged. ' - . "Old score: $oldScore; New score: $score") + $self->warning('LMS grade will NOT be updated - grade has not significantly changed. ' + . "Old score: $priorScore; New score: $score") if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode}; return 1; } else { - debug("LMS grade will be updated. sourcedid: $sourcedid; Old score: $oldScore; New score: $score") + debug("LMS grade will be updated. sourcedid: $sourcedid; Old score: $priorScore; New score: $score") if $ce->{debug_lti_grade_passback}; } } } else { - $self->warning('Unable to retrieve prior grade from LMS. Note that if your server time is not correct, ' - . 'this may fail for reasons which are less than obvious from the error messages. Error: ' - . $response->message) + $self->warning('Unable to retrieve prior grade from LMS. Error: ' . $response->message) if $ce->{debug_lti_grade_passback} || $self->{post_processing_mode}; - debug('Unable to retrieve prior grade from LMS. Note that if your server time is not correct, ' - . 'this may fail for reasons which are less than obvious from the error messages. Error: ' - . $response->message); + debug('Unable to retrieve prior grade from LMS. Error: ' . $response->message); debug($response->body); return 0; } diff --git a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm index 688c94d36e..5802e4a663 100644 --- a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm +++ b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm @@ -39,7 +39,8 @@ 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_all_sets); +use WeBWorK::Authen::LTI::GradePassback qw(getSetPassbackScore); # This package contains utilities for submitting grades to the LMS via LTI 1.3. sub new ($invocant, $c, $post_processing_mode = 0) { @@ -193,7 +194,7 @@ async sub get_access_token ($self) { # Computes and submits the course grade for userID to the LMS. # The course grade is the sum of all (weighted) problems assigned to the user. -async sub submit_course_grade ($self, $userID) { +async sub submit_course_grade ($self, $userID, $submittedSet = undef) { my $c = $self->{c}; my $ce = $c->{ce}; my $db = $c->{db}; @@ -201,17 +202,38 @@ async sub submit_course_grade ($self, $userID) { my $user = $db->getUser($userID); return 0 unless $user; + $self->warning("Preparing to submit overall course grade to LMS for user $userID."); + my $lineitem = $db->getSettingValue('LTIAdvantageCourseLineitem'); + unless ($lineitem) { + $self->warning('LMS lineitem is not available for the course.'); + return 0; + } - $self->warning("Submitting all grades for user $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; + unless ($user->lis_source_did) { + $self->warning('LMS user id is not available for this user.'); + return 0; + } - return await $self->submit_grade($user->lis_source_did, $lineitem, grade_all_sets($db, $userID)); + if ($submittedSet && !getSetPassbackScore($db, $ce, $userID, $submittedSet, 1)) { + $self->warning("Set's critical date has not yet passed, and user has not yet met the threshold to send set's " + . 'score early. Not submitting grade.'); + return -1; + } + + my ($courseTotalRight, $courseTotal, $includedSets) = grade_all_sets($db, $ce, $userID, \&getSetPassbackScore); + if (@$includedSets) { + $self->warning("Submitting overall score for user $userID for sets: " + . join(', ', map { $_->set_id } (@$includedSets))); + return await $self->submit_grade($user->lis_source_did, $lineitem, $courseTotalRight, $courseTotal); + } else { + $self->warning("No sets for user $userID meet criteria to be included in course grade calculation."); + return -1; + } } # Computes and submits the set grade for $userID and $setID to the LMS. For gateways the best score is used. -async sub submit_set_grade ($self, $userID, $setID) { +async sub submit_set_grade ($self, $userID, $setID, $submittedSet = undef) { my $c = $self->{c}; my $ce = $c->{ce}; my $db = $c->{db}; @@ -219,16 +241,28 @@ async sub submit_set_grade ($self, $userID, $setID) { my $user = $db->getUser($userID); return 0 unless $user; - my $userSet = $db->getMergedSet($userID, $setID); + $self->warning("Preparing to submit grade to LMS for user $userID and set $setID."); - $self->warning("Submitting grade for user $userID and set $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; + unless ($user->lis_source_did) { + $self->warning('LMS user id is not available for this user.'); + return 0; + } + + my $userSet = $submittedSet // $db->getMergedSet($userID, $setID); + unless ($userSet->lis_source_did) { + $self->warning('LMS lineitem is not available for this set.'); + return 0; + } - 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 $score = getSetPassbackScore($db, $ce, $userID, $userSet, !$self->{post_processing_mode}); + unless ($score) { + $self->warning("Set's critical date has not yet passed, and user has not yet met the threshold to send set's " + . 'score early. Not submitting grade.'); + return -1; + } + + return await $self->submit_grade($user->lis_source_did, $userSet->lis_source_did, $score->{totalRight}, + $score->{total}); } # Submits scoreGiven and scoreMaximum to the lms with $sourcedid as the identifier. @@ -236,7 +270,7 @@ async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum my $c = $self->{c}; my $ce = $c->{ce}; - return 0 unless $LMSuserID && $lineitem && (my $access_token = await $self->get_access_token); + return 0 unless (my $access_token = await $self->get_access_token); $self->warning('Found data required for submitting grades to LMS.'); @@ -270,14 +304,23 @@ async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum return 0; } - my $priorData = decode_json($response->body); - my $priorScore = @$priorData - && $priorData->[0]{resultMaximum} ? $priorData->[0]{resultScore} / $priorData->[0]{resultMaximum} : 0; + my $priorData = decode_json($response->body); + my $priorScore = + (@$priorData && $priorData->[0]{resultMaximum} && defined $priorData->[0]{resultScore}) + ? $priorData->[0]{resultScore} / $priorData->[0]{resultMaximum} + : 0; + + my $score = $scoreMaximum ? $scoreGiven / $scoreMaximum : 0; - my $score = $scoreGiven / $scoreMaximum; - if (abs($score - $priorScore) < 0.001) { - $self->warning( - "LMS grade will NOT be updated as the grade is unchanged. Old score: $priorScore, New score: $score."); + # Do not update the score if there is no significant change. Note that the cases where the webwork score + # is exactly 1 and the LMS score is not exactly 1, and the case where the webwork score is 0 and the LMS + # score is not set are considered significant changes. + if (abs($score - $priorScore) < 0.001 + && ($score != 1 || $priorScore == 1) + && ($score != 0 || (@$priorData && defined $priorData->[0]{resultScore}))) + { + $self->warning('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..a30a0785ff 100644 --- a/lib/WeBWorK/ConfigValues.pm +++ b/lib/WeBWorK/ConfigValues.pm @@ -821,9 +821,9 @@ sub getConfigValues ($ce) { . '
  • Debug: as in Standard, plus the problem environment (debugging data)
  • ' ), labels => { - '0' => 'Simple', - '1' => 'Standard', - '2' => 'Debug' + '0' => x('Simple'), + '1' => x('Standard'), + '2' => x('Debug') }, values => [qw(0 1 2)], type => 'popuplist' @@ -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.' @@ -909,25 +909,122 @@ sub getConfigValues ($ce) { . 'of the LMS course.' ), values => [ '', qw(course homework) ], - labels => { '' => 'None', 'course' => 'Course', 'homework' => 'Homework' }, + labels => { '' => x('None'), 'course' => x('Course'), 'homework' => x('Homework') }, type => 'popuplist' }, + LTICheckPrior => { + var => 'LTICheckPrior', + doc => x('Check a score in the LMS actually needs updating before updating it'), + doc2 => x( + '

    When this is true, any time WeBWorK is about to send a score to the LMS, it will first request ' + . 'from the LMS what that score currently is. Then if there is no significant difference between ' + . 'the LMS score and the WeBWorK score, WeBWorK will not follow through with updating the LMS ' + . 'score. This is to avoid frequent insignificant updates to a student score in the LMS. With some ' + . 'LMSs, students may receive notifications each time a score is updated, and setting this ' + . 'variable will prevent too many notifications for them. This does create a two-step process, ' + . 'first querying the current score from the LMS and then actually updating the score (if there is ' + . 'a significant difference). Additional details:

    • If the LMS score is not 100%, but the ' + . 'WeBWorK score is, then even if the LMS score is only insignificantly less than 100%, it will be ' + . 'updated anyway.
    • If the LMS score is not set and the WeBWorK score is 0, this is ' + . 'considered a significant difference and the LMS score will updated to 0. However, the ' + . 'constraints of the $LTISendScoresAfterDate and the $LTISendGradesEarlyThreshold variables ' + . '(described below) might apply, and the score may still not be updated in this case.
    • ' + . '
    • "Significant" means an absolute difference of 0.001, or 0.1%. At this time this is not ' + . 'configurable.
    ' + ), + type => 'boolean' + }, LTIGradeOnSubmit => { var => 'LTIGradeOnSubmit', doc => x('Update LMS Grade Each Submit'), doc2 => x( - 'Sets if webwork sends grades back to the LMS every time a user submits an answer. ' - . 'This keeps students grades up to date, but can cause additional server load.' + 'If this is set to true, then each time a user submits an answer or grades a test, that will trigger ' + . 'WeBWorK possibly reporting a score to the LMS. See $LTICheckPrior for one reason that WeBWorK ' + . 'might not ultimately send a score. But there are other reasons too. WeBWorK will send the score ' + . "(the assignment's score if \$LTIGradeMode is 'homework' or the overall course score if " + . "\$LTIGradeMode is 'course') to the LMS only if either the assignment's " + . "\$LTISendGradesEarlyThreshold has been met or if it is past that assignment's " + . '$LTISendScoresAfterDate.' ), type => 'boolean' }, + LTISendScoresAfterDate => { + var => 'LTISendScoresAfterDate', + doc => x('Date after which scores will be sent to the LMS'), + doc2 => x( + '

    This can be set to one of the dates associated with assignments, or "Never". For each assignment, ' + . 'if this setting is "After the ... " then if it is after the indicated date, WeBWorK will send ' + . 'scores. If this setting is "Never" then there is no date that will force WeBWorK to send scores ' + . 'and only the $LTISendGradesEarlyThreshold can cause scores to be sent. If scores are sent:

    ' + . "
    • For 'course' grade passback mode, the assignment will be included in the overall course " + . "score calculation.
    • For 'homework' grade passback mode, the assignment's score itself " + . 'will be sent.

    If $LTISendScoresAfterDate is set to "After the reduced scoring date" ' + . 'and an assignment has no reduced scoring date or reduced scoring is disabled, the fallback is ' + . 'to use the close date.

    For a given assignment, WeBWorK will still send a score to the LMS ' + . 'if the $LTISendGradesEarlyThreshold has been met, regardless of how $LTISendScoresAfterDate is ' + . 'set.

    ' + ), + values => [qw(open_date reduced_scoring_date due_date answer_date never)], + labels => { + open_date => x('After the open date'), + reduced_scoring_date => x('After the reduced scoring date'), + due_date => x('After the close date'), + answer_date => x('After the answer date'), + never => x('Never') + }, + type => 'popuplist' + }, + LTISendGradesEarlyThreshold => { + var => 'LTISendGradesEarlyThreshold', + doc => x('Condition under which scores will be sent early to an LMS'), + doc2 => x( + "

    This can either be set to a score or set to 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 score will be sent.

    If this variable is a score, then the ' + . 'set will need to have a score that reaches or exceeds this score for its score to be sent to ' + . "the LMS (or included in the 'course' score calculation). 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' score calculation).

    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.

    ' + ), + values => [ qw( + attempted 0 0.05 0.1 0.15 0.2 0.25 0.3 0.35 0.4 0.45 0.5 0.55 0.6 0.65 0.7 0.75 0.8 0.85 0.9 0.95 1 + ) ], + labels => { + attempted => x('Attempted'), + 0 => '0%', + 0.05 => '5%', + 0.1 => '10%', + 0.15 => '15%', + 0.2 => '20%', + 0.25 => '25%', + 0.3 => '30%', + 0.35 => '35%', + 0.4 => '40%', + 0.45 => '45%', + 0.5 => '50%', + 0.55 => '55%', + 0.6 => '60%', + 0.65 => '65%', + 0.7 => '70%', + 0.75 => '75%', + 0.8 => '80%', + 0.85 => '85%', + 0.9 => '90%', + 0.95 => '95%', + 1 => '100%', + }, + type => 'popuplist' + }, LTIMassUpdateInterval => { var => 'LTIMassUpdateInterval', doc => x('Time in seconds to periodically update LMS grades (-1 to disable)'), doc2 => x( - 'Sets the time in seconds to periodically update the LMS grades. WeBWorK will update all grades on ' + 'Sets the time in seconds to periodically update the LMS scores. WeBWorK will update all scores on ' . 'the LMS if it has been longer than this time since the completion of the last update. This is ' - . 'only an approximate time. 86400 seconds is one day. -1 disables periodic updates.' + . 'only an approximate time. Mass updates of this nature may put significant strain on the server, ' + . 'and should not be set to happen too frequently. -1 disables these periodic updates.' ), type => 'number' }, diff --git a/lib/WeBWorK/ContentGenerator.pm b/lib/WeBWorK/ContentGenerator.pm index 633990ddca..f935a90a7f 100644 --- a/lib/WeBWorK/ContentGenerator.pm +++ b/lib/WeBWorK/ContentGenerator.pm @@ -56,7 +56,7 @@ use WeBWorK::Utils::LanguageAndDirection qw(get_lang_and_dir); use WeBWorK::Utils::Logs qw(writeCourseLog); use WeBWorK::Utils::Routes qw(route_title route_navigation_is_restricted); use WeBWorK::Utils::Sets qw(format_set_name_display); -use WeBWorK::Authen::LTI::MassUpdate qw(mass_update); +use WeBWorK::Authen::LTI::GradePassback qw(massUpdate); =head1 INVOCATION @@ -111,7 +111,7 @@ async sub go ($c) { # If grades are being passed back to the lti, then peroidically update all of the # grades because things can get out of sync if instructors add or modify sets. - mass_update($c) if $c->stash('courseID') && ref($c->db) && $ce->{LTIGradeMode}; + massUpdate($c) if $c->stash('courseID') && ref($c->db) && $ce->{LTIGradeMode}; # Check to determine if this is a problem set response. Individual content generators must check # $c->{invalidSet} and react appropriately. diff --git a/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm b/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm index cfd6b4ab85..438e46f5ad 100644 --- a/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm +++ b/lib/WeBWorK/ContentGenerator/GatewayQuiz.pm @@ -36,9 +36,8 @@ use WeBWorK::Utils::Rendering qw(getTranslatorDebuggingOptions renderPG); use WeBWorK::Utils::Sets qw(is_restricted); use WeBWorK::DB::Utils qw(global2user fake_set fake_set_version fake_problem); use WeBWorK::Debug; -use WeBWorK::Authen::LTIAdvanced::SubmitGrade; -use WeBWorK::Authen::LTIAdvantage::SubmitGrade; use PGrandom; +use WeBWorK::Authen::LTI::GradePassback qw(passbackGradeOnSubmit); use Caliper::Sensor; use Caliper::Entity; @@ -889,7 +888,7 @@ async sub pre_header_initialize ($c) { debug('begin answer processing'); my @scoreRecordedMessage = ('') x scalar(@problems); - my $LTIGradeResult = -1; + my $ltiGradePassbackMessage; # Save results to database as appropriate if ($c->{submitAnswers} || (($c->{previewAnswers} || $c->param('newPage')) && $can{recordAnswers})) { @@ -1032,15 +1031,9 @@ async sub pre_header_initialize ($c) { } } - # Try to update the student score on the LMS if that option is enabled. - if ($c->{submitAnswers} && $will{recordAnswers} && $ce->{LTIGradeMode} && $ce->{LTIGradeOnSubmit}) { - my $grader = $ce->{LTI}{ $ce->{LTIVersion} }{grader}->new($c); - if ($ce->{LTIGradeMode} eq 'course') { - $LTIGradeResult = await $grader->submit_course_grade($effectiveUserID); - } elsif ($ce->{LTIGradeMode} eq 'homework') { - $LTIGradeResult = await $grader->submit_set_grade($effectiveUserID, $setID); - } - } + # Send the score for this set to the LMS if enabled. + $ltiGradePassbackMessage = await passbackGradeOnSubmit($c, $effectiveUserID, $c->{set}) + if $c->{submitAnswers} && $will{recordAnswers} && $ce->{LTIGradeMode}; # Finally, log student answers that are being submitted, provided that answers can be recorded. Note that # this will log an overtime submission (or any case where someone submits the test, or spoofs a request to @@ -1193,8 +1186,8 @@ async sub pre_header_initialize ($c) { } debug('end answer processing'); - $c->{scoreRecordedMessage} = \@scoreRecordedMessage; - $c->{LTIGradeResult} = $LTIGradeResult; + $c->{scoreRecordedMessage} = \@scoreRecordedMessage; + $c->{ltiGradePassbackMessage} = $ltiGradePassbackMessage; # Additional set-level database manipulation: We want to save the time that a set was submitted, and for proctored # tests we want to reset the assignment type after a set is submitted for the last time so that it's possible to diff --git a/lib/WeBWorK/ContentGenerator/Instructor/LTIUpdate.pm b/lib/WeBWorK/ContentGenerator/Instructor/LTIUpdate.pm index 9b2e820ca4..9c2ce76483 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/LTIUpdate.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/LTIUpdate.pm @@ -18,9 +18,8 @@ package WeBWorK::ContentGenerator::Instructor::LTIUpdate; use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; -use WeBWorK::Utils(qw(getAssetURL)); use WeBWorK::Utils::Sets qw(format_set_name_display); -use WeBWorK::Authen::LTI::MassUpdate qw(mass_update); +use WeBWorK::Authen::LTI::GradePassback qw(massUpdate); sub initialize ($c) { my $db = $c->db; @@ -68,7 +67,7 @@ sub initialize ($c) { # Note that if somehow this point is reached with a setID and grade mode is "course", # then the setID will be ignored by the job. - mass_update($c, 1, $userID, $setID); + massUpdate($c, 1, $userID, $setID); return; } diff --git a/lib/WeBWorK/Utils/ProblemProcessing.pm b/lib/WeBWorK/Utils/ProblemProcessing.pm index b2755a5ed9..c4d79b4247 100644 --- a/lib/WeBWorK/Utils/ProblemProcessing.pm +++ b/lib/WeBWorK/Utils/ProblemProcessing.pm @@ -33,8 +33,7 @@ use WeBWorK::Utils qw(encodeAnswers createEmailSenderTransportSMTP); use WeBWorK::Utils::DateTime qw(before after); use WeBWorK::Utils::JITAR qw(jitar_id_to_seq jitar_problem_adjusted_status); use WeBWorK::Utils::Logs qw(writeLog writeCourseLog); -use WeBWorK::Authen::LTIAdvanced::SubmitGrade; -use WeBWorK::Authen::LTIAdvantage::SubmitGrade; +use WeBWorK::Authen::LTI::GradePassback qw(passbackGradeOnSubmit); use Caliper::Sensor; use Caliper::Entity; @@ -248,38 +247,10 @@ async sub process_and_log_answer ($c) { $c->param('startTime', ''); } - # Messages about passing the score back to the LMS + # Send the score for this set to the LMS if enabled. if ($ce->{LTIGradeMode}) { - my $LMSname = $ce->{LTI}{ $ce->{LTIVersion} }{LMS_name}; - my $LTIGradeResult = -1; - if ($ce->{LTIGradeOnSubmit}) { - $LTIGradeResult = 0; - my $grader = $ce->{LTI}{ $ce->{LTIVersion} }{grader}->new($c); - if ($ce->{LTIGradeMode} eq 'course') { - $LTIGradeResult = await $grader->submit_course_grade($problem->user_id); - } elsif ($ce->{LTIGradeMode} eq 'homework') { - $LTIGradeResult = await $grader->submit_set_grade($problem->user_id, $problem->set_id); - } - if ($LTIGradeResult == 0) { - $scoreRecordedMessage .= - $c->tag('br') . $c->maketext('Your score was not successfully sent to [_1].', $LMSname); - } elsif ($LTIGradeResult > 0) { - $scoreRecordedMessage .= - $c->tag('br') . $c->maketext('Your score was successfully sent to [_1].', $LMSname); - } - } elsif ($ce->{LTIMassUpdateInterval} > 0) { - $scoreRecordedMessage .= $c->tag('br'); - if ($ce->{LTIMassUpdateInterval} < 120) { - $scoreRecordedMessage .= $c->maketext('Scores are sent to [_1] every [quant,_2,second].', - $LMSname, $ce->{LTIMassUpdateInterval}); - } elsif ($ce->{LTIMassUpdateInterval} < 7200) { - $scoreRecordedMessage .= $c->maketext('Scores are sent to [_1] every [quant,_2,minute].', - $LMSname, int($ce->{LTIMassUpdateInterval} / 60 + 0.99)); - } else { - $scoreRecordedMessage .= $c->maketext('Scores are sent to [_1] every [quant,_2,hour].', - $LMSname, int($ce->{LTIMassUpdateInterval} / 3600 + 0.9999)); - } - } + my $message = await passbackGradeOnSubmit($c, $problem->user_id, $c->{set}); + $scoreRecordedMessage .= $c->tag('br') . $message if $message; } } else { # The "sticky" answers get saved here when $will{recordAnswers} is false diff --git a/lib/WeBWorK/Utils/Sets.pm b/lib/WeBWorK/Utils/Sets.pm index d0193ddfba..03d797a2f4 100644 --- a/lib/WeBWorK/Utils/Sets.pm +++ b/lib/WeBWorK/Utils/Sets.pm @@ -121,62 +121,53 @@ sub grade_set ($db, $set, $studentName, $setIsVersioned = 0, $wantProblemDetails } } -sub grade_gateway ($db, $set, $setName, $studentName) { - my @versionNums = $db->listSetVersions($studentName, $setName); +sub grade_gateway ($db, $setName, $studentName) { + my $bestSetData = [ 0, 0 ]; - my $bestTotalRight = 0; - my $bestTotal = 0; - - if (@versionNums) { - for my $i (@versionNums) { - my $versionedSet = $db->getSetVersion($studentName, $setName, $i); - - my ($totalRight, $total) = grade_set($db, $versionedSet, $studentName, 1); - if ($totalRight > $bestTotalRight) { - $bestTotalRight = $totalRight; - $bestTotal = $total; - } - } + my @setVersions = $db->getSetVersionsWhere({ user_id => $studentName, set_id => { like => "$setName,v\%" } }); + for (@setVersions) { + my @setData = grade_set($db, $_, $studentName, 1); + $bestSetData = \@setData if $setData[0] > $bestSetData->[0]; } - if (wantarray) { - return ($bestTotalRight, $bestTotal); - } else { - return 0 unless $bestTotal; - return $bestTotalRight / $bestTotal; - } + return wantarray ? (@$bestSetData, \@setVersions) : ($bestSetData->[1] ? $bestSetData->[0] / $bestSetData->[1] : 0); } -sub grade_all_sets ($db, $studentName) { - my @setIDs = $db->listUserSets($studentName); - my @userSetIDs = map { [ $studentName, $_ ] } @setIDs; - my @userSets = $db->getMergedSets(@userSetIDs); - - my $courseTotal = 0; - my $courseTotalRight = 0; - - for my $userSet (@userSets) { - next unless (after($userSet->open_date())); - if ($userSet->assignment_type() =~ /gateway/) { - - my ($totalRight, $total) = grade_gateway($db, $userSet, $userSet->set_id, $studentName); - $courseTotalRight += $totalRight; - $courseTotal += $total; +sub grade_all_sets ( + $db, $ce, + $studentName, + $getSetGradeConditionally = sub ($db, $ce, $studentName, $userSet) { + return unless after($userSet->open_date); + if ($userSet->assignment_type =~ /gateway/) { + my ($totalRight, $total) = grade_gateway($db, $userSet->set_id, $studentName); + return { totalRight => $totalRight, total => $total }; } else { my ($totalRight, $total) = grade_set($db, $userSet, $studentName, 0); - - $courseTotalRight += $totalRight; - $courseTotal += $total; + return { totalRight => $totalRight, total => $total }; } } + ) +{ + croak 'grade_all_sets requires a code reference for its last argument' + unless ref($getSetGradeConditionally) eq 'CODE'; - if (wantarray) { - return ($courseTotalRight, $courseTotal); - } else { - return 0 unless $courseTotal; - return $courseTotalRight / $courseTotal; + my $courseTotalRight = 0; + my $courseTotal = 0; + my $includedSets = []; + + for my $userSet ($db->getMergedSetsWhere({ user_id => $studentName })) { + my $score = $getSetGradeConditionally->($db, $ce, $studentName, $userSet); + next unless $score; + $courseTotalRight += $score->{totalRight}; + $courseTotal += $score->{total}; + push @$includedSets, $userSet; } + return + wantarray + ? ($courseTotalRight, $courseTotal, $includedSets) + : ($courseTotal ? $courseTotalRight / $courseTotal : 0); + } sub is_restricted ($db, $set, $studentName) { @@ -208,7 +199,7 @@ sub is_restricted ($db, $set, $studentName) { $r_score = $v_score if ($v_score > $r_score); } } else { - $r_score = grade_set($db, $restrictor_set, $studentName, 0); + $r_score = grade_set($db, $restrictor_set, $studentName); } # round to evade machine rounding error @@ -301,7 +292,7 @@ This formats set names for display, converting underscores back into spaces. =head2 grade_set -Usage: C +Usage: C The arguments C<$db>, C<$set>, and C<$studentName> are required. If C<$setIsVersioned> is true, then the given set is assumed to be a set version. @@ -317,27 +308,39 @@ In scalar context this returns the percentage correct. =head2 grade_gateway -Usage: C +Usage: C All arguments are required. -In list context this returns a list fo the total number of correct problems for -the highest scoring version of this test, and the total number of problems in -that version. +In list context this returns a list of the total number of correct problems for +the highest scoring version of this test, the total number of problems in that +version, a reference to an array of merged user problem records from that +version, and a reference to an array of merged user set versions for this user +and set. In scalar context this returns the percentage correct for the highest scoring version of this test. =head2 grade_all_sets -Usage: C +Usage: C -All arguments listed are required. +The arguments C<$db>, C<$ce>, and C<$studentName> are rrequired. -In list context this returns the total course score for all sets and the maximum -possible course score. +The C<$getSetGradeConditionally> is an optional argument that if provided should +be a reference to a subroutine that will be passed the arguments $db, $ce, +$studentName listed above, and $userSet which is a merged user set record from +the database, and must either return a reference to a hash containing the keys +totalRight and total with the grade for the set, or C. If it returns +C then the set will not be included in the grade computation. Otherwise +the values for totalRight and total that are returned will be added into the +grade. If the optional last arugment is not provided, then a default method +will be used that returns the set grade if after the open date, and C +otherwise. -In scalar context this returns the percentage score for all sets in the course. +This returns the total course score for all sets, the maximum possible course score, +and an array reference containing references to the user sets that were included in +those two tallies. =head2 is_restricted diff --git a/templates/ContentGenerator/GatewayQuiz.html.ep b/templates/ContentGenerator/GatewayQuiz.html.ep index ce8719b0fb..e6d77dcd54 100644 --- a/templates/ContentGenerator/GatewayQuiz.html.ep +++ b/templates/ContentGenerator/GatewayQuiz.html.ep @@ -168,11 +168,9 @@ % } % } % # Print a message when submitting the score to an LMS. - % if ($c->{LTIGradeResult} != -1) { + % if ($c->{ltiGradePassbackMessage}) {
    - <%= $c->{LTIGradeResult} - ? maketext('Your score was successfully sent to the LMS.') - : maketext('Your score was not successfully sent to the LMS.') =%> + <%= $c->{ltiGradePassbackMessage} =%> % } % }