|
| 1 | +package Mojolicious::WeBWorK::Tasks::LTISetDateSync; |
| 2 | +use Mojo::Base 'Minion::Job', -signatures, -async_await; |
| 3 | + |
| 4 | +use Mojo::UserAgent; |
| 5 | +use Mojo::Date; |
| 6 | + |
| 7 | +use WeBWorK::Authen::LTIAdvantage::SubmitGrade; |
| 8 | +use WeBWorK::CourseEnvironment; |
| 9 | +use WeBWorK::DB; |
| 10 | +use WeBWorK::Utils::DateTime qw(formatDateTime); |
| 11 | + |
| 12 | +# Synchronize requested set dates to the LMS. |
| 13 | +sub run ($job, $setIDs, $syncToLMS = 1) { |
| 14 | + # Establish a lock guard that only allows 1 job at a time (technically more than one could run at a time if a job |
| 15 | + # takes more than an hour to complete). As soon as a job completes (or fails) the lock is released and a new job |
| 16 | + # can start. New jobs retry every minute until they can acquire their own lock. |
| 17 | + return $job->retry({ delay => 60 }) unless my $guard = $job->minion->guard('lti_set_date_sync', 3600); |
| 18 | + |
| 19 | + # Minion does not support asynchronous jobs with notification of job completion, and so the Mojolicious::Promise |
| 20 | + # wait method must be used. The synchronizeSetDates method is used so that the async/await syntax can be used |
| 21 | + # instead of using the wait method on each method that needs to be awaited which would be tedious. So the wait |
| 22 | + # method only needs to be used once here. |
| 23 | + $job->synchronizeSetDates($setIDs, $syncToLMS)->wait(); |
| 24 | + |
| 25 | + return; |
| 26 | +} |
| 27 | + |
| 28 | +async sub synchronizeSetDates ($job, $setIDs, $syncToLMS) { |
| 29 | + my $courseID = $job->info->{notes}{courseID}; |
| 30 | + return $job->fail('The course id was not passed when this job was enqueued.') unless $courseID; |
| 31 | + |
| 32 | + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $courseID }) }; |
| 33 | + return $job->fail('Could not construct course environment.') unless $ce; |
| 34 | + |
| 35 | + $job->{language_handle} = WeBWorK::Localize::getLoc($ce->{language} || 'en'); |
| 36 | + |
| 37 | + return $job->fail($job->maketext('This course is not configured to synchronize set dates with the LMS via LTI.')) |
| 38 | + if !$ce->{LTIVersion} || $ce->{LTIVersion} ne 'v1p3' || $ce->{LTIGradeMode} ne 'homework'; |
| 39 | + |
| 40 | + my $db = WeBWorK::DB->new($ce); |
| 41 | + return $job->fail($job->maketext('Could not obtain database connection.')) unless $db; |
| 42 | + |
| 43 | + my $lineitemsURL = $db->getSettingValue('LTILineitemsURL'); |
| 44 | + return $job->fail($job->maketext('Could not perform date synchronization. The lineitems URL is not available.')) |
| 45 | + unless $lineitemsURL; |
| 46 | + |
| 47 | + my $accessToken = |
| 48 | + await WeBWorK::Authen::LTIAdvantage::SubmitGrade->new(({ ce => $ce, db => $db, app => $job->app }, 1)) |
| 49 | + ->get_access_token; |
| 50 | + return $job->fail($job->maketext('Could not perform date synchronization. Unable to obtain access token.')) |
| 51 | + unless $accessToken; |
| 52 | + |
| 53 | + my $ua = Mojo::UserAgent->new; |
| 54 | + |
| 55 | + my $lineitemsResult = |
| 56 | + (await $ua->get_p( |
| 57 | + $lineitemsURL, { Authorization => "$accessToken->{token_type} $accessToken->{access_token}" }))->result; |
| 58 | + |
| 59 | + return $job->fail($job->maketext( |
| 60 | + 'There was an error obtaining the current lineitems from the LMS: [_1]', |
| 61 | + $lineitemsResult->message |
| 62 | + )) |
| 63 | + unless $lineitemsResult->is_success; |
| 64 | + |
| 65 | + my %lineitems = map { $_->{resourceId} => $_ } grep { defined $_->{resourceId} } @{ $lineitemsResult->json }; |
| 66 | + |
| 67 | + my @messages; |
| 68 | + |
| 69 | + for my $set ($db->getGlobalSetsWhere({ set_id => $setIDs })) { |
| 70 | + unless ($lineitems{ $set->set_id }) { |
| 71 | + # If a link to a set was not created via deep linking, then the lineitem obtained from the lineitems URL |
| 72 | + # will not have the resourceId. But if the link was used by someone, then the lineitem URL for the set will |
| 73 | + # be in the lis_source_did column for the set. So that can be used to get the current lineitem information |
| 74 | + # from the LMS. |
| 75 | + if ($set->lis_source_did) { |
| 76 | + my $lineitemResult = (await $ua->get_p( |
| 77 | + $set->lis_source_did, |
| 78 | + { Authorization => "$accessToken->{token_type} $accessToken->{access_token}" } |
| 79 | + ))->result; |
| 80 | + |
| 81 | + if ($lineitemResult->is_success) { |
| 82 | + $lineitems{ $set->set_id } = $lineitemResult->json; |
| 83 | + |
| 84 | + # Set the resourceId so that the LMS sends it the next time that date synchronization occurs. |
| 85 | + $lineitems{ $set->set_id }{resourceId} = $set->set_id; |
| 86 | + |
| 87 | + # If not synchronizing dates to the LMS, then update the lineitem to the LMS now, so that the |
| 88 | + # resourceId will be set in the LMS. If synchronizing dates to the LMS this will be included when |
| 89 | + # the dates are sent, so it isn't needed now. |
| 90 | + if (!$syncToLMS) { |
| 91 | + my $updateLineitemResult = (await $ua->put_p( |
| 92 | + $lineitems{ $set->set_id }{id}, |
| 93 | + { |
| 94 | + Authorization => "$accessToken->{token_type} $accessToken->{access_token}", |
| 95 | + 'Content-Type' => 'application/vnd.ims.lis.v2.lineitem+json' |
| 96 | + }, |
| 97 | + json => $lineitems{ $set->set_id } |
| 98 | + ))->result; |
| 99 | + |
| 100 | + # Don't add a message about this to the job. This is an internal implementation detail the |
| 101 | + # instructor that queued the job doesn't need to know about. Just log it. |
| 102 | + $job->app->log->error('Failed to update the resource id for set ' |
| 103 | + . $set->set_id |
| 104 | + . ' while performering date synchronization.') |
| 105 | + if !$updateLineitemResult->is_success; |
| 106 | + } |
| 107 | + } |
| 108 | + } |
| 109 | + unless ($lineitems{ $set->set_id }) { |
| 110 | + push( |
| 111 | + @messages, |
| 112 | + $job->maketext( |
| 113 | + 'Skipping synchronization of dates for "[_1]" as the lineitem for this set is not available.', |
| 114 | + $set->set_id |
| 115 | + ) |
| 116 | + ); |
| 117 | + next; |
| 118 | + } |
| 119 | + } |
| 120 | + |
| 121 | + # Save the lineitem URL for the set if it is not yet in the database. |
| 122 | + if (!defined $set->lis_source_did || $set->lis_source_did ne $lineitems{ $set->set_id }{id}) { |
| 123 | + $set->lis_source_did($lineitems{ $set->set_id }{id}); |
| 124 | + $db->putGlobalSet($set); |
| 125 | + } |
| 126 | + |
| 127 | + if ($syncToLMS) { |
| 128 | + $lineitems{ $set->set_id }{startDateTime} = formatDateTime($set->open_date, '%Y-%m-%dT%H:%M:%S%z'); |
| 129 | + $lineitems{ $set->set_id }{endDateTime} = formatDateTime($set->due_date, '%Y-%m-%dT%H:%M:%S%z'); |
| 130 | + |
| 131 | + my $updateLineitemResult = (await $ua->put_p( |
| 132 | + $lineitems{ $set->set_id }{id}, |
| 133 | + { |
| 134 | + Authorization => "$accessToken->{token_type} $accessToken->{access_token}", |
| 135 | + 'Content-Type' => 'application/vnd.ims.lis.v2.lineitem+json' |
| 136 | + }, |
| 137 | + json => $lineitems{ $set->set_id } |
| 138 | + ))->result; |
| 139 | + |
| 140 | + if ($updateLineitemResult->is_success) { |
| 141 | + push(@messages, $job->maketext('Submitted dates for "[_1]" to the LMS.', $set->set_id)); |
| 142 | + } else { |
| 143 | + push( |
| 144 | + @messages, |
| 145 | + $job->maketext( |
| 146 | + 'Failed to submit dates for "[_1]" to the LMS: [_2]', $set->set_id, |
| 147 | + $updateLineitemResult->message |
| 148 | + ) |
| 149 | + ); |
| 150 | + } |
| 151 | + } else { |
| 152 | + my ($openDateChanged, $closeDateChanged) = (0, 0); |
| 153 | + if ($lineitems{ $set->set_id }{startDateTime}) { |
| 154 | + my $newOpenDate = Mojo::Date->new($lineitems{ $set->set_id }{startDateTime})->epoch; |
| 155 | + if (defined $newOpenDate) { |
| 156 | + $openDateChanged = 1 if $newOpenDate != $set->open_date; |
| 157 | + $set->open_date($newOpenDate); |
| 158 | + } |
| 159 | + } |
| 160 | + if ($lineitems{ $set->set_id }{endDateTime}) { |
| 161 | + my $newCloseDate = Mojo::Date->new($lineitems{ $set->set_id }{endDateTime})->epoch; |
| 162 | + if (defined $newCloseDate) { |
| 163 | + $closeDateChanged = 1 if $newCloseDate != $set->due_date; |
| 164 | + $set->due_date($newCloseDate); |
| 165 | + } |
| 166 | + } |
| 167 | + |
| 168 | + # Only change dates if at least one date was received from the LMS. Some LMSs do not support dates and will |
| 169 | + # not send them at all, or the dates may just not be set in the LMS in which case they also will not be |
| 170 | + # sent. |
| 171 | + unless ($openDateChanged || $closeDateChanged) { |
| 172 | + push(@messages, $job->maketext('The dates for "[_1]" were not changed.', $set->set_id)); |
| 173 | + next; |
| 174 | + } |
| 175 | + |
| 176 | + # The following assumes that if the instructor is using synchronization of dates from the LMS, then the |
| 177 | + # instructor wants those dates to be used. As such, this tries to make the dates work with the other dates |
| 178 | + # for the set. |
| 179 | + |
| 180 | + if ($set->open_date > $set->due_date) { |
| 181 | + if ($lineitems{ $set->set_id }{startDateTime} && $lineitems{ $set->set_id }{endDateTime}) { |
| 182 | + push( |
| 183 | + @messages, |
| 184 | + $job->maketext( |
| 185 | + 'Error setting dates for [_1]: Invalid dates received from the LMS. ' |
| 186 | + . 'The start date was not before the end date.', |
| 187 | + $set->set_id |
| 188 | + ) |
| 189 | + ); |
| 190 | + next; |
| 191 | + } |
| 192 | + # If one of the dates was received from the LMS, but not the other, and the current date stored for the |
| 193 | + # other does not work with the received date, then adjust the other date to make it work. |
| 194 | + if ($openDateChanged && !$closeDateChanged) { |
| 195 | + $set->due_date($set->open_date + 60 * $ce->{pg}{assignOpenPriorToDue}); |
| 196 | + } elsif (!$openDateChanged && $closeDateChanged) { |
| 197 | + $set->open_date($set->due_date - 60 * $ce->{pg}{assignOpenPriorToDue}); |
| 198 | + } |
| 199 | + } |
| 200 | + |
| 201 | + $set->answer_date($set->due_date + 60 * $ce->{pg}{answersOpenAfterDueDate}) |
| 202 | + if $set->answer_date < $set->due_date; |
| 203 | + |
| 204 | + if (!$set->reduced_scoring_date |
| 205 | + || $set->reduced_scoring_date < $set->open_date |
| 206 | + || $set->reduced_scoring_date > $set->due_date) |
| 207 | + { |
| 208 | + if ($ce->{pg}{ansEvalDefaults}{enableReducedScoring} && $set->enable_reduced_scoring) { |
| 209 | + $set->reduced_scoring_date($set->due_date - 60 * $ce->{pg}{ansEvalDefaults}{reducedScoringPeriod}); |
| 210 | + |
| 211 | + # If using the reducedScoringPeriod results in a time before the open date, |
| 212 | + # then just use the due date. |
| 213 | + $set->reduced_scoring_date($set->due_date) if $set->reduced_scoring_date < $set->open_date; |
| 214 | + } else { |
| 215 | + $set->reduced_scoring_date($set->due_date); |
| 216 | + } |
| 217 | + } |
| 218 | + |
| 219 | + $db->putGlobalSet($set); |
| 220 | + |
| 221 | + if ($ce->{pg}{ansEvalDefaults}{enableReducedScoring} && $set->enable_reduced_scoring) { |
| 222 | + push( |
| 223 | + @messages, |
| 224 | + $job->maketext( |
| 225 | + 'Changed dates for "[_1]" to: open date: [_2], reduced scoring date: [_3], ' |
| 226 | + . 'close date: [_4], answer date: [_5]', |
| 227 | + $set->set_id, |
| 228 | + ( |
| 229 | + map { |
| 230 | + formatDateTime($set->$_, 'datetime_format_short', $ce->{siteDefaults}{timezone}, |
| 231 | + $ce->{language}) |
| 232 | + } 'open_date', |
| 233 | + 'reduced_scoring_date', |
| 234 | + 'due_date', |
| 235 | + 'answer_date' |
| 236 | + ) |
| 237 | + ) |
| 238 | + ); |
| 239 | + } else { |
| 240 | + push( |
| 241 | + @messages, |
| 242 | + $job->maketext( |
| 243 | + 'Changed dates for "[_1]" to: open date: [_2], close date: [_3], answer date: [_4]', |
| 244 | + $set->set_id, |
| 245 | + ( |
| 246 | + map { |
| 247 | + formatDateTime($set->$_, 'datetime_format_short', $ce->{siteDefaults}{timezone}, |
| 248 | + $ce->{language}) |
| 249 | + } 'open_date', |
| 250 | + 'due_date', |
| 251 | + 'answer_date' |
| 252 | + ) |
| 253 | + ) |
| 254 | + ); |
| 255 | + } |
| 256 | + } |
| 257 | + } |
| 258 | + |
| 259 | + return $job->finish(@messages > 1 ? \@messages : $messages[0]); |
| 260 | +} |
| 261 | + |
| 262 | +sub maketext ($job, @args) { |
| 263 | + return &{ $job->{language_handle} }(@args); |
| 264 | +} |
| 265 | + |
| 266 | +1; |
0 commit comments