diff --git a/conf/authen_LTI.conf.dist b/conf/authen_LTI.conf.dist index b32287f517..017706f424 100644 --- a/conf/authen_LTI.conf.dist +++ b/conf/authen_LTI.conf.dist @@ -241,6 +241,8 @@ $LTIMassUpdateInterval = 86400; #'LTIMassUpdateInterval', #'LMSManageUserData', #'LTI{v1p1}{BasicConsumerSecret}', + #'LTI{v1p3}{ignoreMissingSourcedID}', + #'LTI{v1p3}{autoSyncSetDatesToLMS}', #'LTI{v1p3}{PlatformID}', #'LTI{v1p3}{ClientID}', #'LTI{v1p3}{DeploymentID}', diff --git a/conf/authen_LTI_1_3.conf.dist b/conf/authen_LTI_1_3.conf.dist index 1aaa7a4976..ea5d6c92fe 100644 --- a/conf/authen_LTI_1_3.conf.dist +++ b/conf/authen_LTI_1_3.conf.dist @@ -83,6 +83,27 @@ $LTI{v1p3}{strip_domain_from_email} = 0; # lowercase. $LTI{v1p3}{lowercase_username} = 0; +# When the names/roles service URL is used for roster synchronization the LMS may use a +# different key in the sent data than is used when a user signs in via LTI authentication. Set +# the following keys to the correct key for your LMS that provides the same identifier via the +# names/roles service URL as when LTI authentication is used if the key for the +# preferred_source_of_username or fallback_source_of_username is different. Note that the +# 'email' key is the same for all LMSs according to the LTI 1.3 specification. So if +# preferred_source_of_username or fallback_source_of_username is 'email', then you should not +# set the respective setting below. Also, the 'sub' key obtained during LTI authentication will +# always match the 'user_id' key obtained from the names/roles service URL according to the LTI +# 1.3 specification. So if preferred_source_of_username or fallback_source_of_username is +# 'sub', then set the respective setting to 'user_id' below. For any other values of +# preferred_source_of_username or fallback_source_of_username, set debug_lti_parameters to 1, +# and compare the results when LTI authentication is performed and when using LMS roster +# synchronization in the accounts manager to determine what (if anything) will work here. +# Unfortunately, for values other than 'email' or 'sub', there is no guarantee that there will +# be any valid key provided from the names/roles service URL, and so if you do not see anything +# valid to use using debug_lti_parameters, then LTI roster synchronization in the accounts +# manager is just not going to work for you. +$LTI{v1p3}{namesroles_service_preferred_source_of_username} = ''; +$LTI{v1p3}{namesroles_service_fallback_source_of_username} = ''; + ################################################################################################ # LTI 1.3 Preferred source of Student Id ################################################################################################ @@ -92,6 +113,11 @@ $LTI{v1p3}{lowercase_username} = 0; # LMS. There may be no claim value that provides this. $LTI{v1p3}{preferred_source_of_student_id} = ''; +# This is much the same as the namesroles_service_preferred_source_of_username and +# namesroles_service_fallback_source_of_username above. See the documentation for those to +# understand this setting. +$LTI{v1p3}{namesroles_service_preferred_source_of_student_id} = ''; + ################################################################################################ # LTI 1.3 Basic Authentication Parameters ################################################################################################ @@ -212,14 +238,27 @@ $LTI{v1p3}{AllowInstitutionRoles} = 0; # Miscellaneous ################################################################################################ -# When grade passback mode is 'homework', someone must use a set-specific link from the LMS in -# order for grade passback to begin happening for that set. Use of the set-specific link lets -# WeBWorK store the set's "sourced_ID". So if there is no sourced_ID, the default behavior is -# that a user in WeBWorK sees the sets as disabled and there is a message about needing to -# access the set from the LMS. The following option can be set to allow users to work on the set -# anyway. There will be no grade passback until some later time when an LMS user clicks the -# set-specific link. In some LMSs, it is possible for the instructor to activate the link. +# When grade passback mode is 'homework', webwork2 needs to have the lineitem URL for a set in +# order for grade passback to occur. For manually created links to a webwork2 set in the LMS, +# webwork2 obtains that URL the first time that someone uses the link, but for links created via +# deep linking (content selection in the LMS), webwork2 can obtain that URL anytime it is +# needed. If webwork2 does not have the lineitem URL stored in the database, the default +# behavior is that a user in webwork2 sees the sets as disabled, and there is a message about +# needing to access the set from the LMS. The following option can be set to allow users to work +# on the set anyway. For manually created links, there will be no grade passback until some +# later time when an LMS user uses the set-specific link. Note that in most LMSs, the instructor +# can activate a link by using it. For links created via deep linking, grade passback will +# always work, but webwork2 will not store the lineitem URL until the first time that grade +# passback occurs, someone uses the set-specific link, or set dates are synchronized to the LMS. +# So if you create all links in the LMS using deep linking (content selection), then you will +# most likely want to set the following option, because the message about needing to access the +# set from the LMS that is shown in the LMS is simply not true anyway. $LTI{v1p3}{ignoreMissingSourcedID} = 0; +# Set the following option if you want webwork2 to automatically synchronize set dates to the +# LMS anytime that a set's open and close dates are changed. + +$LTI{v1p3}{autoSyncSetDatesToLMS} = 0; + 1; # final line of the file to reassure perl that it was read properly. diff --git a/htdocs/js/ProblemSetList/problemsetlist.js b/htdocs/js/ProblemSetList/problemsetlist.js index b86bdebbc7..b56724ca86 100644 --- a/htdocs/js/ProblemSetList/problemsetlist.js +++ b/htdocs/js/ProblemSetList/problemsetlist.js @@ -49,7 +49,14 @@ err_msg?.classList.remove('d-none'); if (!('set_table_id' in event_listeners)) { event_listeners.set_table_id = hide_errors( - ['filter_select', 'edit_select', 'publish_filter_select', 'export_select', 'score_select'], + [ + 'filter_select', + 'edit_select', + 'publish_filter_select', + 'export_select', + 'score_select', + 'lms_date_sync_select' + ], [err_msg] ); document.getElementById('set_table_id')?.addEventListener('change', event_listeners.set_table_id); @@ -72,7 +79,7 @@ e.stopPropagation(); show_errors(['filter_err_msg'], [filter_select, filter_text]); } - } else if (['edit', 'publish', 'export', 'score'].includes(action)) { + } else if (['edit', 'publish', 'export', 'score', 'lms_date_sync'].includes(action)) { const action_select = document.getElementById(`${action}_select`); if (action_select.value === 'selected' && !is_set_selected()) { e.preventDefault(); diff --git a/lib/Mojolicious/WeBWorK.pm b/lib/Mojolicious/WeBWorK.pm index 428d411144..244ab0fee6 100644 --- a/lib/Mojolicious/WeBWorK.pm +++ b/lib/Mojolicious/WeBWorK.pm @@ -75,6 +75,7 @@ sub startup ($app) { # WeBWorK::ContentGenerator::Instructor::JobManager. $app->plugin(Minion => { $ce->{job_queue}{backend} => $ce->{job_queue}{database_dsn} }); $app->minion->add_task(lti_mass_update => 'Mojolicious::WeBWorK::Tasks::LTIMassUpdate'); + $app->minion->add_task(lti_set_date_sync => 'Mojolicious::WeBWorK::Tasks::LTISetDateSync'); $app->minion->add_task(send_instructor_email => 'Mojolicious::WeBWorK::Tasks::SendInstructorEmail'); $app->minion->add_task(send_achievement_email => 'Mojolicious::WeBWorK::Tasks::AchievementNotification'); diff --git a/lib/Mojolicious/WeBWorK/Tasks/LTISetDateSync.pm b/lib/Mojolicious/WeBWorK/Tasks/LTISetDateSync.pm new file mode 100644 index 0000000000..1d5a6a26da --- /dev/null +++ b/lib/Mojolicious/WeBWorK/Tasks/LTISetDateSync.pm @@ -0,0 +1,266 @@ +package Mojolicious::WeBWorK::Tasks::LTISetDateSync; +use Mojo::Base 'Minion::Job', -signatures, -async_await; + +use Mojo::UserAgent; +use Mojo::Date; + +use WeBWorK::Authen::LTIAdvantage::SubmitGrade; +use WeBWorK::CourseEnvironment; +use WeBWorK::DB; +use WeBWorK::Utils::DateTime qw(formatDateTime); + +# Synchronize requested set dates to the LMS. +sub run ($job, $setIDs, $syncToLMS = 1) { + # Establish a lock guard that only allows 1 job at a time (technically more than one could run at a time if a job + # takes more than an hour to complete). As soon as a job completes (or fails) the lock is released and a new job + # can start. New jobs retry every minute until they can acquire their own lock. + return $job->retry({ delay => 60 }) unless my $guard = $job->minion->guard('lti_set_date_sync', 3600); + + # Minion does not support asynchronous jobs with notification of job completion, and so the Mojolicious::Promise + # wait method must be used. The synchronizeSetDates method is used so that the async/await syntax can be used + # instead of using the wait method on each method that needs to be awaited which would be tedious. So the wait + # method only needs to be used once here. + $job->synchronizeSetDates($setIDs, $syncToLMS)->wait(); + + return; +} + +async sub synchronizeSetDates ($job, $setIDs, $syncToLMS) { + my $courseID = $job->info->{notes}{courseID}; + return $job->fail('The course id was not passed when this job was enqueued.') unless $courseID; + + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $courseID }) }; + return $job->fail('Could not construct course environment.') unless $ce; + + $job->{language_handle} = WeBWorK::Localize::getLoc($ce->{language} || 'en'); + + return $job->fail($job->maketext('This course is not configured to synchronize set dates with the LMS via LTI.')) + if !$ce->{LTIVersion} || $ce->{LTIVersion} ne 'v1p3' || $ce->{LTIGradeMode} ne 'homework'; + + my $db = WeBWorK::DB->new($ce); + return $job->fail($job->maketext('Could not obtain database connection.')) unless $db; + + my $lineitemsURL = $db->getSettingValue('LTILineitemsURL'); + return $job->fail($job->maketext('Could not perform date synchronization. The lineitems URL is not available.')) + unless $lineitemsURL; + + my $accessToken = + await WeBWorK::Authen::LTIAdvantage::SubmitGrade->new(({ ce => $ce, db => $db, app => $job->app }, 1)) + ->get_access_token; + return $job->fail($job->maketext('Could not perform date synchronization. Unable to obtain access token.')) + unless $accessToken; + + my $ua = Mojo::UserAgent->new; + + my $lineitemsResult = + (await $ua->get_p( + $lineitemsURL, { Authorization => "$accessToken->{token_type} $accessToken->{access_token}" }))->result; + + return $job->fail($job->maketext( + 'There was an error obtaining the current lineitems from the LMS: [_1]', + $lineitemsResult->message + )) + unless $lineitemsResult->is_success; + + my %lineitems = map { $_->{resourceId} => $_ } grep { defined $_->{resourceId} } @{ $lineitemsResult->json }; + + my @messages; + + for my $set ($db->getGlobalSetsWhere({ set_id => $setIDs })) { + unless ($lineitems{ $set->set_id }) { + # If a link to a set was not created via deep linking, then the lineitem obtained from the lineitems URL + # will not have the resourceId. But if the link was used by someone, then the lineitem URL for the set will + # be in the lis_source_did column for the set. So that can be used to get the current lineitem information + # from the LMS. + if ($set->lis_source_did) { + my $lineitemResult = (await $ua->get_p( + $set->lis_source_did, + { Authorization => "$accessToken->{token_type} $accessToken->{access_token}" } + ))->result; + + if ($lineitemResult->is_success) { + $lineitems{ $set->set_id } = $lineitemResult->json; + + # Set the resourceId so that the LMS sends it the next time that date synchronization occurs. + $lineitems{ $set->set_id }{resourceId} = $set->set_id; + + # If not synchronizing dates to the LMS, then update the lineitem to the LMS now, so that the + # resourceId will be set in the LMS. If synchronizing dates to the LMS this will be included when + # the dates are sent, so it isn't needed now. + if (!$syncToLMS) { + my $updateLineitemResult = (await $ua->put_p( + $lineitems{ $set->set_id }{id}, + { + Authorization => "$accessToken->{token_type} $accessToken->{access_token}", + 'Content-Type' => 'application/vnd.ims.lis.v2.lineitem+json' + }, + json => $lineitems{ $set->set_id } + ))->result; + + # Don't add a message about this to the job. This is an internal implementation detail the + # instructor that queued the job doesn't need to know about. Just log it. + $job->app->log->error('Failed to update the resource id for set ' + . $set->set_id + . ' while performering date synchronization.') + if !$updateLineitemResult->is_success; + } + } + } + unless ($lineitems{ $set->set_id }) { + push( + @messages, + $job->maketext( + 'Skipping synchronization of dates for "[_1]" as the lineitem for this set is not available.', + $set->set_id + ) + ); + next; + } + } + + # Save the lineitem URL for the set if it is not yet in the database. + if (!defined $set->lis_source_did || $set->lis_source_did ne $lineitems{ $set->set_id }{id}) { + $set->lis_source_did($lineitems{ $set->set_id }{id}); + $db->putGlobalSet($set); + } + + if ($syncToLMS) { + $lineitems{ $set->set_id }{startDateTime} = formatDateTime($set->open_date, '%Y-%m-%dT%H:%M:%S%z'); + $lineitems{ $set->set_id }{endDateTime} = formatDateTime($set->due_date, '%Y-%m-%dT%H:%M:%S%z'); + + my $updateLineitemResult = (await $ua->put_p( + $lineitems{ $set->set_id }{id}, + { + Authorization => "$accessToken->{token_type} $accessToken->{access_token}", + 'Content-Type' => 'application/vnd.ims.lis.v2.lineitem+json' + }, + json => $lineitems{ $set->set_id } + ))->result; + + if ($updateLineitemResult->is_success) { + push(@messages, $job->maketext('Submitted dates for "[_1]" to the LMS.', $set->set_id)); + } else { + push( + @messages, + $job->maketext( + 'Failed to submit dates for "[_1]" to the LMS: [_2]', $set->set_id, + $updateLineitemResult->message + ) + ); + } + } else { + my ($openDateChanged, $closeDateChanged) = (0, 0); + if ($lineitems{ $set->set_id }{startDateTime}) { + my $newOpenDate = Mojo::Date->new($lineitems{ $set->set_id }{startDateTime})->epoch; + if (defined $newOpenDate) { + $openDateChanged = 1 if $newOpenDate != $set->open_date; + $set->open_date($newOpenDate); + } + } + if ($lineitems{ $set->set_id }{endDateTime}) { + my $newCloseDate = Mojo::Date->new($lineitems{ $set->set_id }{endDateTime})->epoch; + if (defined $newCloseDate) { + $closeDateChanged = 1 if $newCloseDate != $set->due_date; + $set->due_date($newCloseDate); + } + } + + # Only change dates if at least one date was received from the LMS. Some LMSs do not support dates and will + # not send them at all, or the dates may just not be set in the LMS in which case they also will not be + # sent. + unless ($openDateChanged || $closeDateChanged) { + push(@messages, $job->maketext('The dates for "[_1]" were not changed.', $set->set_id)); + next; + } + + # The following assumes that if the instructor is using synchronization of dates from the LMS, then the + # instructor wants those dates to be used. As such, this tries to make the dates work with the other dates + # for the set. + + if ($set->open_date > $set->due_date) { + if ($lineitems{ $set->set_id }{startDateTime} && $lineitems{ $set->set_id }{endDateTime}) { + push( + @messages, + $job->maketext( + 'Error setting dates for [_1]: Invalid dates received from the LMS. ' + . 'The start date was not before the end date.', + $set->set_id + ) + ); + next; + } + # If one of the dates was received from the LMS, but not the other, and the current date stored for the + # other does not work with the received date, then adjust the other date to make it work. + if ($openDateChanged && !$closeDateChanged) { + $set->due_date($set->open_date + 60 * $ce->{pg}{assignOpenPriorToDue}); + } elsif (!$openDateChanged && $closeDateChanged) { + $set->open_date($set->due_date - 60 * $ce->{pg}{assignOpenPriorToDue}); + } + } + + $set->answer_date($set->due_date + 60 * $ce->{pg}{answersOpenAfterDueDate}) + if $set->answer_date < $set->due_date; + + if (!$set->reduced_scoring_date + || $set->reduced_scoring_date < $set->open_date + || $set->reduced_scoring_date > $set->due_date) + { + if ($ce->{pg}{ansEvalDefaults}{enableReducedScoring} && $set->enable_reduced_scoring) { + $set->reduced_scoring_date($set->due_date - 60 * $ce->{pg}{ansEvalDefaults}{reducedScoringPeriod}); + + # If using the reducedScoringPeriod results in a time before the open date, + # then just use the due date. + $set->reduced_scoring_date($set->due_date) if $set->reduced_scoring_date < $set->open_date; + } else { + $set->reduced_scoring_date($set->due_date); + } + } + + $db->putGlobalSet($set); + + if ($ce->{pg}{ansEvalDefaults}{enableReducedScoring} && $set->enable_reduced_scoring) { + push( + @messages, + $job->maketext( + 'Changed dates for "[_1]" to: open date: [_2], reduced scoring date: [_3], ' + . 'close date: [_4], answer date: [_5]', + $set->set_id, + ( + map { + formatDateTime($set->$_, 'datetime_format_short', $ce->{siteDefaults}{timezone}, + $ce->{language}) + } 'open_date', + 'reduced_scoring_date', + 'due_date', + 'answer_date' + ) + ) + ); + } else { + push( + @messages, + $job->maketext( + 'Changed dates for "[_1]" to: open date: [_2], close date: [_3], answer date: [_4]', + $set->set_id, + ( + map { + formatDateTime($set->$_, 'datetime_format_short', $ce->{siteDefaults}{timezone}, + $ce->{language}) + } 'open_date', + 'due_date', + 'answer_date' + ) + ) + ); + } + } + } + + return $job->finish(@messages > 1 ? \@messages : $messages[0]); +} + +sub maketext ($job, @args) { + return &{ $job->{language_handle} }(@args); +} + +1; diff --git a/lib/WeBWorK/Authen/LTIAdvantage.pm b/lib/WeBWorK/Authen/LTIAdvantage.pm index 8be03a7c7a..11e09a89b0 100644 --- a/lib/WeBWorK/Authen/LTIAdvantage.pm +++ b/lib/WeBWorK/Authen/LTIAdvantage.pm @@ -197,6 +197,10 @@ sub get_credentials ($self) { $c->stash->{lti_lms_user_id} = $claims->{sub}; $c->stash->{lti_lms_lineitem} = $extract_claim->('https://purl.imsglobal.org/spec/lti-ags/claim/endpoint#lineitem'); + $c->stash->{lti_lms_lineitems_url} = + $extract_claim->('https://purl.imsglobal.org/spec/lti-ags/claim/endpoint#lineitems'); + $c->stash->{lti_lms_namesrolesservice_url} = + $extract_claim->('https://purl.imsglobal.org/spec/lti-nrps/claim/namesroleservice#context_memberships_url'); # Extract a possible setID from the target_link_uri. This may not be an actual setID. # That will be verified later in WeBWorK::Authen::LTIAdvantage::SubmitGrade::update_sourcedid. diff --git a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm index 97ba568beb..d0f75fed3f 100644 --- a/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm +++ b/lib/WeBWorK/Authen/LTIAdvantage/SubmitGrade.pm @@ -93,6 +93,11 @@ sub update_passback_data ($self, $userID) { } } + # Save the general lineitems URL and namesroleservice URL if they were in the request. + $db->setSettingValue('LTILineitemsURL', $c->stash->{lti_lms_lineitems_url}) if ($c->stash->{lti_lms_lineitems_url}); + $db->setSettingValue('LTINamesRolesServiceURL', $c->stash->{lti_lms_namesrolesservice_url}) + if ($c->stash->{lti_lms_namesrolesservice_url}); + # Update the access token if neccessary. No need to wait for it to finish here since the token is not needed yet. # This just obtains it if needed for later. $self->get_access_token; @@ -154,7 +159,8 @@ async sub get_access_token ($self) { 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/score'), + 'https://purl.imsglobal.org/spec/lti-ags/scope/score', + 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'), client_assertion => $jwt } )->catch(sub ($err) { @@ -193,8 +199,13 @@ async sub submit_course_grade ($self, $userID, $submittedSet = undef) { my $lineitem = $db->getSettingValue('LTIAdvantageCourseLineitem'); unless ($lineitem) { - $self->warning('LMS lineitem is not available for the course.'); - return 0; + my $updatedLineitems = await $self->get_lineitems; + if (defined $updatedLineitems->{course}) { + $lineitem = $updatedLineitems->{course}; + } else { + $self->warning('LMS lineitem is not available for the course.'); + return 0; + } } unless ($user->lis_source_did) { @@ -237,8 +248,13 @@ async sub submit_set_grade ($self, $userID, $setID, $submittedSet = undef) { my $userSet = $submittedSet // $db->getMergedSet($userID, $setID); unless ($userSet->lis_source_did) { - $self->warning('LMS lineitem is not available for this set.'); - return 0; + my $updatedLineitems = await $self->get_lineitems; + if (defined $updatedLineitems->{ $userSet->set_id }) { + $userSet->lis_source_did($updatedLineitems->{ $userSet->set_id }); + } else { + $self->warning('LMS lineitem is not available for this set.'); + return 0; + } } my $score = getSetPassbackScore($db, $ce, $userID, $userSet, !$self->{post_processing_mode}); @@ -349,6 +365,52 @@ async sub submit_grade ($self, $LMSuserID, $lineitem, $scoreGiven, $scoreMaximum return 0; } +# If $LTIGradeMode is 'homework', then this gets all lineitem URLs for links to sets created via deep linking from the +# LMS and updates them in the database, or if $LTIGradeMode is 'course' and a link for the course grade was created via +# deep linking, then this gets the lineitem URL for the course grade and updates it in the course settings. A hash of +# set_id and lineitem URLs is returned if $LTIGradeMode is 'homework, and a hash with 'course' and the grade lineitem +# URL is returned if $LTIGradeMode is 'course'. If no lineitem URLs can be identified, then an empty hash is returned. +async sub get_lineitems ($self) { + return $self->{lineitemURLs} if $self->{lineitemURLs}; + $self->{lineitemURLs} = {}; + + my $db = $self->{c}{db}; + + my $lineitemsURL = $db->getSettingValue('LTILineitemsURL'); + return $self->{lineitemURLs} unless $lineitemsURL; + + return $self->{lineitemURLs} unless (my $accessToken = await $self->get_access_token); + + my $ua = Mojo::UserAgent->new; + + my $lineitemsResult = + ( + await $ua->get_p($lineitemsURL, + { Authorization => "$accessToken->{token_type} $accessToken->{access_token}" }))->result; + + if ($lineitemsResult->is_success) { + my $lineitems = $lineitemsResult->json; + for my $lineitem (@$lineitems) { + next unless $lineitem->{resourceId}; + if ($self->{c}{ce}{LTIGradeMode} eq 'homework') { + next unless (my $set = $db->getGlobalSet($lineitem->{resourceId})); + if (!defined $set->lis_source_did || $set->lis_source_did ne $lineitem->{id}) { + $set->lis_source_did($lineitem->{id}); + $db->putGlobalSet($set); + } + $self->{lineitemURLs}{ $set->{set_id} } = $lineitem->{id}; + } elsif ($self->{c}{ce}{LTIGradeMode} eq 'course' && $lineitem->{resourceId} eq 'course_grade') { + $db->setSettingValue('LTIAdvantageCourseLineitem', $lineitem->{id}); + $self->{lineitemURLs}{course} = $lineitem->{id}; + } + } + } else { + $self->warning("Unable to obtain lineitems from the lineitems URL:\n" . $lineitemsResult->message); + } + + return $self->{lineitemURLs}; +} + # Load and possibly generate private/public keys for the site. This is only generates new keys if the files do not # already exist. If $private is true then the JSON decoded private key is returned, otherwise the JSON decoded public # key is returned as a keyset. If an error occurs in this process then the returned key will be undefined, and the error diff --git a/lib/WeBWorK/ConfigValues.pm b/lib/WeBWorK/ConfigValues.pm index 8b4423f437..aa01fd0d19 100644 --- a/lib/WeBWorK/ConfigValues.pm +++ b/lib/WeBWorK/ConfigValues.pm @@ -1097,6 +1097,31 @@ sub getConfigValues ($ce) { type => 'text', secret => 1 }, + 'LTI{v1p3}{ignoreMissingSourcedID}' => { + var => 'LTI{v1p3}{ignoreMissingSourcedID}', + doc => x('Allow set access when the lineitem URL for a set is missing'), + doc2 => x( + 'When grade passback mode is "homework", WeBWorK needs to have the lineitem URL for a set in order ' + . 'for grade passback to occur. If WeBWorK has not yet stored this lineitem URL, then by default ' + . 'students will not be able to access and work a set, and a message will be displayed for ' + . 'students stating that the assignment must be accessed from the LMS before the assignment can ' + . 'be worked. Set this option to true to disable that message and allow students to access and ' + . 'work the set regardless of if WeBWorK has stored the lineitem URL.' + ), + type => 'boolean' + }, + 'LTI{v1p3}{autoSyncSetDatesToLMS}' => { + var => 'LTI{v1p3}{autoSyncSetDatesToLMS}', + doc => x('Automatically synchronize set dates to LMS'), + doc2 => x( + 'Set this to true if you want WeBWorK to automatically synchronize set dates to the LMS anytime that ' + . q{a set's open or close date is changed in WeBWorK. Note that this will only work for sets for } + . 'which WeBWorK has or can obtain the lineitem URL. This will always work for sets created via ' + . 'content selection from the LMS, but will only work for a manually created link in the LMS after ' + . 'the link has been used.' + ), + type => 'boolean' + }, 'LTI{v1p3}{PlatfromID}' => { var => 'LTI{v1p3}{PlatformID}', doc => x('LMS platform ID for LTI 1.3'), diff --git a/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm b/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm index 57145907a0..3e74fc1084 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/JobManager.pm @@ -14,6 +14,7 @@ use constant ACTION_FORMS => [ [ filter => x('Filter') ], [ sort => x('Sort') ], # All tasks added in the Mojolicious::WeBWorK module need to be listed here. use constant TASK_NAMES => { lti_mass_update => x('LTI Mass Update'), + lti_set_date_sync => x('LTI Set Date Synchronization'), send_instructor_email => x('Send Instructor Email'), send_achievement_email => x('Send Achiement Email') }; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm index 19c2c557aa..db6dddf3b9 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetDetail.pm @@ -1482,6 +1482,9 @@ sub initialize ($c) { } } } else { + my $originalOpenDate = $setRecord->open_date; + my $originalDueDate = $setRecord->due_date; + foreach my $field (@{ SET_FIELDS() }) { next unless canChange($forUsers, $field); @@ -1510,6 +1513,15 @@ sub initialize ($c) { } $db->putGlobalSet($setRecord); + if ($ce->{LTIVersion} + && $ce->{LTIVersion} eq 'v1p3' + && $ce->{LTIGradeMode} eq 'homework' + && $ce->{LTI}{v1p3}{autoSyncSetDatesToLMS} + && ($originalOpenDate != $setRecord->open_date || $originalDueDate != $setRecord->due_date)) + { + $c->minion->enqueue(lti_set_date_sync => [$setID], { notes => { courseID => $ce->{courseName} } }); + } + # Save IP restriction Location information if (defined($c->param("set.$setID.restrict_ip")) && $c->param("set.$setID.restrict_ip") ne 'No') { my @selectedLocations = $c->param("set.$setID.selected_ip_locations"); diff --git a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm index 5e33ee8369..217da5050a 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/ProblemSetList.pm @@ -73,7 +73,7 @@ use WeBWorK::File::SetDef qw(importSetsFromDef exportSetsToDef); use constant HIDE_SETS_THRESHOLD => 500; use constant EDIT_FORMS => [qw(save_edit cancel_edit)]; -use constant VIEW_FORMS => [qw(filter sort edit publish import export score create delete)]; +use constant VIEW_FORMS => [qw(filter sort edit publish import export score create delete lms_date_sync)]; use constant EXPORT_FORMS => [qw(save_export cancel_export)]; # Prepare the tab titles for translation by maketext @@ -90,7 +90,8 @@ use constant FORM_TITLES => { create => x("Create"), delete => x("Delete"), save_export => x("Save Export"), - cancel_export => x("Cancel Export") + cancel_export => x("Cancel Export"), + lms_date_sync => x("Synchronize Set Dates with LMS") }; use constant VIEW_FIELD_ORDER => @@ -101,15 +102,16 @@ use constant EXPORT_FIELD_ORDER => [qw(set_id problems users)]; # permissions needed to perform a given action use constant FORM_PERMS => { - save_edit => "modify_problem_sets", - edit => "modify_problem_sets", - publish => "modify_problem_sets", - import => "create_and_delete_problem_sets", - export => "modify_set_def_files", - save_export => "modify_set_def_files", - score => "score_sets", - create => "create_and_delete_problem_sets", - delete => "create_and_delete_problem_sets", + save_edit => "modify_problem_sets", + edit => "modify_problem_sets", + publish => "modify_problem_sets", + import => "create_and_delete_problem_sets", + export => "modify_set_def_files", + save_export => "modify_set_def_files", + score => "score_sets", + create => "create_and_delete_problem_sets", + delete => "create_and_delete_problem_sets", + lms_date_sync => "modify_problem_sets" }; # Note that these are the only fields that are ever shown on this page. @@ -617,20 +619,24 @@ sub save_edit_handler ($c) { my $db = $c->db; my $ce = $c->ce; + my @setsToSyncToLMS; + my @visibleSetIDs = @{ $c->{visibleSetIDs} }; - foreach my $setID (@visibleSetIDs) { + for my $setID (@visibleSetIDs) { next unless defined($setID); my $Set = $db->getGlobalSet($setID); - # FIXME: we may not want to die on bad sets, they're not as bad as bad users - die "record for visible set $setID not found" unless $Set; + next unless $Set; + + my $originalOpenDate = $Set->open_date; + my $originalDueDate = $Set->due_date; - foreach my $field ($Set->NONKEYFIELDS()) { + for my $field ($Set->NONKEYFIELDS()) { my $value = $c->param("set.$setID.$field"); if (defined $value) { if ($field =~ /_date/) { $Set->$field($value); } elsif ($field eq 'enable_reduced_scoring') { - # If we are enableing reduced scoring, make sure the reduced scoring date + # If we are enabling reduced scoring, make sure the reduced scoring date # is set and in a proper interval. $Set->enable_reduced_scoring($value); if (!$Set->reduced_scoring_date) { @@ -652,7 +658,7 @@ sub save_edit_handler ($c) { return (0, $c->maketext('Error: Answer date must come after close date in set [_1].', $setID)); } - # check that the reduced scoring date is in the right place + # Check that the reduced scoring date is in the right place. my $enable_reduced_scoring = $ce->{pg}{ansEvalDefaults}{enableReducedScoring} && ( defined($c->param("set.$setID.enable_reduced_scoring")) @@ -675,9 +681,24 @@ sub save_edit_handler ($c) { ); } + push(@setsToSyncToLMS, $setID) + if $Set->open_date != $originalOpenDate || $Set->due_date != $originalDueDate; + $db->putGlobalSet($Set); } + if (@setsToSyncToLMS + && $ce->{LTIVersion} + && $ce->{LTIVersion} eq 'v1p3' + && $ce->{LTIGradeMode} eq 'homework' + && $ce->{LTI}{v1p3}{autoSyncSetDatesToLMS}) + { + $c->minion->enqueue( + lti_set_date_sync => [ \@setsToSyncToLMS ], + { notes => { courseID => $ce->{courseName} } } + ); + } + if (defined $c->param("prev_visible_sets")) { $c->{visibleSetIDs} = [ $c->param("prev_visible_sets") ]; } elsif (defined $c->param("no_prev_visble_sets")) { @@ -691,4 +712,25 @@ sub save_edit_handler ($c) { return (1, $c->maketext('Changes saved.')); } +sub lms_date_sync_handler ($c) { + my $ce = $c->ce; + + return (0, $c->maketext('This course is not configured to synchronize set dates with the LMS via LTI.')) + if !$ce->{LTIVersion} || $ce->{LTIVersion} ne 'v1p3' || $ce->{LTIGradeMode} ne 'homework'; + + my $lineitemsURL = $c->db->getSettingValue('LTILineitemsURL'); + return (0, $c->maketext('Unable to perform date synchronization as the lineitems URL is not available.')) + unless $lineitemsURL; + + $c->minion->enqueue( + lti_set_date_sync => [ + $c->param('action.lms_date_sync.scope') eq 'all' ? $c->{allSetIDs} : [ $c->param('selected_sets') ], + $c->param('action.lms_date_sync.direction') eq 'to' + ], + { notes => { courseID => $ce->{courseName} } } + ); + + return (1, $c->maketext('Date synchronization for the requested sets queued.')); +} + 1; diff --git a/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm b/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm index b554d4eef6..317b88ff57 100644 --- a/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm +++ b/lib/WeBWorK/ContentGenerator/Instructor/UserList.pm @@ -1,5 +1,5 @@ package WeBWorK::ContentGenerator::Instructor::UserList; -use Mojo::Base 'WeBWorK::ContentGenerator', -signatures; +use Mojo::Base 'WeBWorK::ContentGenerator', -signatures, -async_await; =head1 NAME @@ -47,36 +47,40 @@ Export users: use Mojo::File; -use WeBWorK::File::Classlist qw(parse_classlist write_classlist); -use WeBWorK::Utils qw(cryptPassword x); +use WeBWorK::File::Classlist qw(parse_classlist write_classlist); +use WeBWorK::Utils qw(cryptPassword x); +use WeBWorK::Utils::Instructor qw(assignSetsToUsers); +use WeBWorK::Authen::LTIAdvantage::SubmitGrade; use constant HIDE_USERS_THRESHHOLD => 200; use constant EDIT_FORMS => [qw(save_edit cancel_edit)]; -use constant VIEW_FORMS => [qw(filter sort edit import export add delete reset_2fa)]; +use constant VIEW_FORMS => [qw(filter sort edit import export add delete reset_2fa lms_roster_sync)]; # Prepare the tab titles for translation by maketext use constant FORM_TITLES => { - save_edit => x('Save Edit'), - cancel_edit => x('Cancel Edit'), - filter => x('Filter'), - sort => x('Sort'), - edit => x('Edit'), - import => x('Import'), - export => x('Export'), - add => x('Add'), - delete => x('Delete'), - reset_2fa => x('Reset Two Factor Authentication') + save_edit => x('Save Edit'), + cancel_edit => x('Cancel Edit'), + filter => x('Filter'), + sort => x('Sort'), + edit => x('Edit'), + import => x('Import'), + export => x('Export'), + add => x('Add'), + delete => x('Delete'), + reset_2fa => x('Reset Two Factor Authentication'), + lms_roster_sync => x('Synchronize LMS Roster') }; # permissions needed to perform a given action use constant FORM_PERMS => { - save_edit => 'modify_student_data', - edit => 'modify_student_data', - reset_2fa => 'change_password', - import => 'modify_student_data', - export => 'modify_classlist_files', - add => 'modify_student_data', - delete => 'modify_student_data', + save_edit => 'modify_student_data', + edit => 'modify_student_data', + reset_2fa => 'change_password', + import => 'modify_student_data', + export => 'modify_classlist_files', + add => 'modify_student_data', + delete => 'modify_student_data', + lms_roster_sync => 'modify_student_data' }; use constant SORT_SUBS => { @@ -136,7 +140,7 @@ use constant FIELD_PROPERTIES => { password => { name => x('Password'), type => 'password' }, }; -sub pre_header_initialize ($c) { +async sub pre_header_initialize ($c) { my $authz = $c->authz; my $ce = $c->ce; my $db = $c->db; @@ -229,7 +233,10 @@ sub pre_header_initialize ($c) { if (!FORM_PERMS()->{$actionID} || $authz->hasPermissions($user, FORM_PERMS()->{$actionID})) { # Call the action handler my $actionHandler = "${actionID}_handler"; - $c->addgoodmessage($c->$actionHandler); + my $actionResult = $c->$actionHandler; + $actionResult = await $actionResult + if ref $actionResult eq 'Future' || ref $actionResult eq 'Mojo::Promise'; + $c->addgoodmessage($actionResult); } else { $c->addbadmessage($c->maketext('You are not authorized to perform this action.')); } @@ -377,7 +384,7 @@ sub delete_handler ($c) { my $confirm = $c->param('action.delete.confirm'); my $num = 0; - return $c->maketext('Deleted [_1] users.', $num) unless ($confirm eq 'yes'); + return $c->maketext('Deleted [_1] [plural,_1,user].', $num) unless ($confirm eq 'yes'); # grep on userIsEditable would still enforce permissions, but no UI feedback my @userIDsToDelete = keys %{ $c->{selectedUserIDs} }; @@ -401,7 +408,7 @@ sub delete_handler ($c) { $num++; } - unshift @resultText, $c->maketext('Deleted [_1] users.', $num); + unshift @resultText, $c->maketext('Deleted [_1] [plural,_1,user].', $num); return join(' ', @resultText); } @@ -451,8 +458,11 @@ sub import_handler ($c) { my $numAdded = @$added; my $numSkipped = @$skipped; - return $c->maketext('[_1] users replaced, [_2] users added, [_3] users skipped. Skipped users: ([_4])', - $numReplaced, $numAdded, $numSkipped, join(', ', @$skipped)); + return $c->maketext( + '[_1] [plural,_1,user] replaced, [_2] [plural,_2,user] added, [_3] [plural,_3,user] skipped. ' + . 'Skipped [plural,_3,user]: ([_4])', + $numReplaced, $numAdded, $numSkipped, join(', ', @$skipped) + ); } sub export_handler ($c) { @@ -478,7 +488,7 @@ sub export_handler ($c) { my @userIDsToExport = $scope eq 'all' ? @{ $c->{allUserIDs} } : keys %{ $c->{selectedUserIDs} }; $c->exportUsersToCSV($fileName, @userIDsToExport); - return $c->maketext('[_1] users exported to file [_2]', scalar @userIDsToExport, "$dir/$fileName"); + return $c->maketext('[_1] [plural,_1,user] exported to file [_2]', scalar @userIDsToExport, "$dir/$fileName"); } sub reset_2fa_handler ($c) { @@ -488,7 +498,7 @@ sub reset_2fa_handler ($c) { my $confirm = $c->param('action.reset_2fa.confirm'); my $num = 0; - return $c->maketext('Reset two factor authentication for [_1] users.', $num) unless $confirm eq 'yes'; + return $c->maketext('Reset two factor authentication for [_1] [plural,_1,user].', $num) unless $confirm eq 'yes'; # grep on userIsEditable would still enforce permissions, but no UI feedback my @userIDsForReset = keys %{ $c->{selectedUserIDs} }; @@ -515,6 +525,242 @@ sub reset_2fa_handler ($c) { return join(' ', @resultText); } +async sub lms_roster_sync_handler ($c) { + my $db = $c->db; + my $ce = $c->ce; + + return $c->maketext('This course is not configured to import users from the LMS via LTI.') + if !$ce->{LTIVersion} + || $ce->{LTIVersion} ne 'v1p3' + || !$ce->{LTI}{v1p3}{preferred_source_of_username}; + + my $namesRolesServiceURL = $db->getSettingValue('LTINamesRolesServiceURL'); + return $c->maketext('The LTI names/roles service URL is not available.') unless $namesRolesServiceURL; + + my $accessToken = await WeBWorK::Authen::LTIAdvantage::SubmitGrade->new($c)->get_access_token; + return $c->maketext('Unable to obtain access token.') unless $accessToken; + + my $namesRolesServiceResult = (await Mojo::UserAgent->new->get_p( + $namesRolesServiceURL, { Authorization => "$accessToken->{token_type} $accessToken->{access_token}" } + ))->result; + + if ($namesRolesServiceResult->is_success) { + my $namesRoles = $namesRolesServiceResult->json->{members}; + return $c->maketext('Invalid data received from the LMS.') unless ref $namesRoles eq 'ARRAY'; + + my (@addedUsers, @userAchievementRecordsToAdd, @globalAchievementRecordsToAdd, %usersInLMSCourse); + my $updatedUsers = 0; + + my @achievements = $db->getAchievementsWhere({ enabled => 1 }, ['achievement_id']); + + my $preferredSourceOfUsername = $ce->{LTI}{v1p3}{namesroles_service_preferred_source_of_username} + || $ce->{LTI}{v1p3}{preferred_source_of_username}; + my $fallbackPasswordSource = $ce->{LTI}{v1p3}{namesroles_service_fallback_source_of_username} + || $ce->{LTI}{v1p3}{fallback_source_of_username}; + my $preferredSourceOfStudentId = $ce->{LTI}{v1p3}{namesroles_service_preferred_source_of_student_id} + || $ce->{LTI}{v1p3}{preferred_source_of_student_id}; + + for my $user (@$namesRoles) { + my ($userIdSource, $typeOfSource) = ('', ''); + my $userId = $user->{$preferredSourceOfUsername}; + if (defined $userId) { + $userIdSource = $preferredSourceOfUsername; + $typeOfSource = + "$userIdSource which was " + . ($ce->{LTI}{v1p3}{namesroles_service_preferred_source_of_username} + ? 'namesroles_service_preferred_source_of_username' + : 'preferred_source_of_username'); + } elsif ($fallbackPasswordSource && !defined $userId && defined $user->{$fallbackPasswordSource}) { + $userIdSource = $fallbackPasswordSource; + $typeOfSource = + "$userIdSource which was" + . ($ce->{LTI}{v1p3}{namesroles_service_fallback_source_of_username} + ? 'namesroles_service_fallback_source_of_username' + : 'fallback_source_of_username'); + $userId = $user->{$fallbackPasswordSource}; + } + + unless (defined $userId) { + warn "=====================================\n" + . "Unable to determine a webwork user id for LMS user:\n" + . $c->dumper($user) + . "\n=====================================\n" + if $ce->{debug_lti_parameters}; + next; + } + + $userId =~ s/@.*$// if $userIdSource eq 'email' && $ce->{LTI}{v1p3}{strip_domain_from_email}; + $userId = lc($userId) if $ce->{LTI}{v1p3}{lowercase_username}; + + my $studentId = $preferredSourceOfStudentId ? ($user->{$preferredSourceOfStudentId} // '') : ''; + + if ($ce->{debug_lti_parameters}) { + warn "=========== USER SUMMARY ============\n"; + warn "----------- LMS USER DATA -----------\n"; + warn $c->dumper($user); + warn "\n-------------------------------------\n"; + warn "User id is |$userId| (obtained from $typeOfSource)\n"; + warn "User email address is |$user->{email}|\n"; + warn "Student id is |$studentId|\n"; + warn "=====================================\n"; + } + + $usersInLMSCourse{$userId} = 1; + + if ($userId eq $c->param('user')) { + warn "Skipping $userId because this is you.\n" if $ce->{debug_lti_parameters}; + next; + } + + # Note that the only reliably obtained roles here are the membership roles. The issue is that these roles + # are allowed to be abbreviated (i.e., the http://purl.imsglobal.org/... part may be entirely omitted + # according to the specification). Moodle does this, but Canvas does not. However, both seem to add a prefix + # for non-membership roles. Also, "institution" roles are not sent, so it is not even possible to honor the + # $ce->{LTI}{v1p3}{AllowInstitutionRoles} setting. + my @LTIroles = map {s|^http://purl.imsglobal.org/vocab/lis/v2/membership#||r} @{ $user->{roles} }; + + warn "The LTI roles defined for $userId are: \n-- " . join("\n-- ", @LTIroles) . "\n" + if $ce->{debug_lti_parameters}; + + if (!defined($ce->{userRoles}{ $ce->{LTI}{v1p3}{LMSrolesToWeBWorKroles}{ $LTIroles[0] } })) { + warn "Skipping $userId. Cannot find a WeBWorK role that corresponds to the " + . "LMS role of $LTIroles[0] for this user.\n" + if $ce->{debug_lti_parameters}; + next; + } + + my $permissionLevel = $ce->{userRoles}{ $ce->{LTI}{v1p3}{LMSrolesToWeBWorKroles}{ $LTIroles[0] } }; + if (@LTIroles > 1) { + for (@LTIroles[ 1 .. $#LTIroles ]) { + my $wwRole = $ce->{LTI}{v1p3}{LMSrolesToWeBWorKroles}{$_}; + next unless defined $wwRole; + $permissionLevel = $ce->{userRoles}{$wwRole} if $permissionLevel < $ce->{userRoles}{$wwRole}; + } + } + if ($permissionLevel > $ce->{userRoles}{ $ce->{LTIAccountCreationCutoff} }) { + warn "Skipping $userId. User has a role above the LTI " + . "account creation cutoff of $ce->{LTIAccountCreationCutoff}.\n" + if $ce->{debug_lti_parameters}; + next; + } + + if ($c->{allUsers}{$userId}) { + next unless $ce->{LMSManageUserData}; + + # Create a temporary user with the LMS credentials and compare the user to the existing user. + my $tempUser = $db->newUser( + user_id => $userId, + lis_source_did => $user->{user_id}, + last_name => $user->{family_name} =~ s/\+/ /gr, + first_name => $user->{given_name} =~ s/\+/ /gr, + email_address => $user->{email}, + status => $user->{status} eq 'Active' ? 'C' : 'D', + comment => $c->formatDateTime(time), + student_id => $studentId, + section => '', + recitation => '' + ); + + my $change_made = 0; + for my $element (qw(last_name first_name email_address status student_id)) { + if ($c->{allUsers}{$userId}->$element ne $tempUser->$element) { + $change_made = 1; + warn "WeBWorK user has $element: " + . $c->{allUsers}{$userId}->$element + . ", but LMS user has $element: " + . $tempUser->$element . "\n" + if $ce->{debug_lti_parameters}; + # Update the data for this page. + $c->{allUsers}{$userId}->$element($tempUser->$element); + } + } + + if ($change_made) { + ++$updatedUsers; + $tempUser->comment($c->formatDateTime(time)); + eval { $db->putUser($tempUser) }; + if ($@) { + $c->log->error("Failed to update user $userId when importing LMS user: $@"); + warn "Failed to update user $userId.\n" if $ce->{debug_lti_parameters}; + } else { + warn "Updated user $userId.\n" if $ce->{debug_lti_parameters}; + } + } else { + warn "$userId not changed.\n" if $ce->{debug_lti_parameters}; + } + } else { + warn "Adding user $userId with permission level $permissionLevel.\n" if $ce->{debug_lti_parameters}; + push(@addedUsers, $userId); + + my $newUser = $db->newUser( + user_id => $userId, + lis_source_did => $user->{user_id}, + last_name => $user->{family_name} =~ s/\+/ /gr, + first_name => $user->{given_name} =~ s/\+/ /gr, + email_address => $user->{email}, + status => $user->{status} eq 'Active' ? 'C' : 'D', + comment => $c->formatDateTime(time), + student_id => $studentId, + section => '', + recitation => '' + ); + $db->addUser($newUser); + + $db->addPermissionLevel($db->newPermissionLevel(user_id => $userId, permission => $permissionLevel)); + + for (@achievements) { + push(@userAchievementRecordsToAdd, + $db->newUserAchievement(user_id => $userId, achievement_id => $_->achievement_id)); + } + push(@globalAchievementRecordsToAdd, + $db->newGlobalUserAchievement(user_id => $userId, achivement_points => 0)); + + # Update the data for this page. + $newUser->{permission} = $permissionLevel; + $newUser->{passwordExists} = 0; + $c->{allUsers}{$userId} = $newUser; + $c->{visibleUserIDs}{$userId} = 1; + $c->{userIsEditable}{$userId} = 1; + } + } + + # Assign visible sets to the added users. + assignSetsToUsers($db, $ce, [ map { $_->[0] } $db->listGlobalSetsWhere({ visible => 1 }) ], \@addedUsers) + if @addedUsers; + + # Assign achievements to the added users. + $db->UserAchievement->insert_records(\@userAchievementRecordsToAdd) if @userAchievementRecordsToAdd; + $db->GlobalUserAchievement->insert_records(\@globalAchievementRecordsToAdd) + if @globalAchievementRecordsToAdd; + + # Mark all users not in the LMS roster and at or below the LTIAccountCreationCutoff as dropped. + my @droppedUsers; + for my $user (values %{ $c->{allUsers} }) { + next + if $usersInLMSCourse{ $user->user_id } + || $user->{permission} > $ce->{userRoles}{ $ce->{LTIAccountCreationCutoff} } + || $user->status eq 'D'; + $user->status('D'); + push(@droppedUsers, $user); + } + $db->User->update_records(\@droppedUsers) if @droppedUsers; + + return $ce->{LMSManageUserData} + ? $c->maketext( + '[_1] [plural,_1,user] added, [_2] [plural,_2,user] updated, ' + . '[_3] [plural,_3,user] not in LMS [plural,_3,was,were] dropped', + scalar(@addedUsers), + $updatedUsers, + scalar(@droppedUsers) + ) + : $c->maketext('[_1] [plural,_1,user] added, [_2] [plural,_2,user] not in LMS [plural,_2,was,were] dropped', + scalar(@addedUsers), scalar(@droppedUsers)); + } else { + return $c->maketext('There was an error obtaining the list of users from the LMS: [_1]', + $namesRolesServiceResult->message); + } +} + sub cancel_edit_handler ($c) { if (defined $c->param('prev_visible_users')) { $c->{visibleUserIDs} = { map { $_ => 1 } @{ $c->every_param('prev_visible_users') } }; diff --git a/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm b/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm index 75b58e5d6c..7da7d6ab8b 100644 --- a/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm +++ b/lib/WeBWorK/ContentGenerator/LTIAdvantage.pm @@ -181,7 +181,6 @@ sub launch ($c) { %{ Mojo::URL->new($c->stash->{LTILaunchRedirect})->query->to_hash }, $c->stash->{isContentSelection} ? ( - courseID => $c->stash->{courseID}, initial_request => 1, accept_multiple => @@ -258,7 +257,9 @@ sub content_selection ($c) { type => 'ltiResourceLink', title => $c->maketext('WeBWorK Assignments'), url => $c->url_for('set_list', courseID => $c->stash->{courseID})->to_abs->to_string, - $c->ce->{LTIGradeMode} eq 'course' ? (lineItem => { scoreMaximum => 100 }) : () + $c->ce->{LTIGradeMode} eq 'course' + ? (lineItem => { resourceId => 'course_grade', scoreMaximum => 100 }) + : () } : (), map { { @@ -269,7 +270,19 @@ sub content_selection ($c) { $c->url_for('problem_list', courseID => $c->stash->{courseID}, setID => $_->set_id) ->to_abs->to_string, $c->ce->{LTIGradeMode} eq 'homework' - ? (lineItem => { scoreMaximum => $setMaxScores{ $_->set_id } }) + ? ( + lineItem => { + resourceId => $_->set_id, + scoreMaximum => $setMaxScores{ $_->set_id } + }, + available => { + startDateTime => $c->formatDateTime($_->open_date, '%Y-%m-%dT%H:%M:%S%z') + }, + submission => { + endDateTime => $c->formatDateTime($_->due_date, '%Y-%m-%dT%H:%M:%S%z') + }, + window => { targetName => '_blank' } + ) : () } } @selectedSets ] @@ -482,7 +495,8 @@ async sub registration ($c) { 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem', 'https://purl.imsglobal.org/spec/lti-ags/scope/lineitem.readonly', 'https://purl.imsglobal.org/spec/lti-ags/scope/result.readonly', - 'https://purl.imsglobal.org/spec/lti-ags/scope/score'), + 'https://purl.imsglobal.org/spec/lti-ags/scope/score', + 'https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly'), 'https://purl.imsglobal.org/spec/lti-tool-configuration' => { domain => $rootURL->host_port, target_link_uri => $rootURL->to_string, diff --git a/templates/ContentGenerator/Instructor/ProblemSetList.html.ep b/templates/ContentGenerator/Instructor/ProblemSetList.html.ep index 46a55b57af..bdeb11e0bd 100644 --- a/templates/ContentGenerator/Instructor/ProblemSetList.html.ep +++ b/templates/ContentGenerator/Instructor/ProblemSetList.html.ep @@ -78,6 +78,13 @@ % my $default_choice; % % for my $actionID (@$formsToShow) { + % next + % if $actionID eq 'lms_roster_sync' && ( + % !$ce->{LTIVersion} + % || $ce->{LTIVersion} ne 'v1p3' + % || $ce->{LTIGradeMode} ne 'homework' + % || !$db->getSettingValue('LTILineitemsURL') + % ); % # Check permissions % next if $formPerms->{$actionID} && !$authz->hasPermissions(param('user'), $formPerms->{$actionID}); % diff --git a/templates/ContentGenerator/Instructor/ProblemSetList/lms_date_sync_form.html.ep b/templates/ContentGenerator/Instructor/ProblemSetList/lms_date_sync_form.html.ep new file mode 100644 index 0000000000..0e82adb910 --- /dev/null +++ b/templates/ContentGenerator/Instructor/ProblemSetList/lms_date_sync_form.html.ep @@ -0,0 +1,29 @@ +
+
+ <%= label_for lms_date_sync_select => maketext('Synchronize dates for which sets?'), + class => 'col-form-label col-form-label-sm col-auto' =%> +
+ <%= select_field 'action.lms_date_sync.scope' => [ + [ maketext('all course sets') => 'all' ], + [ maketext('selected sets') => 'selected', selected => undef ] + ], + id => 'lms_date_sync_select', class => 'form-select form-select-sm' =%> +
+
+
+ <%= label_for lms_date_sync_direction => maketext('Synchronize dates in which direction?'), + class => 'col-form-label col-form-label-sm col-auto' =%> +
+ <%= select_field 'action.lms_date_sync.direction' => [ + [ maketext('to the LMS') => 'to', selected => undef ], + [ maketext('from the LMS') => 'from' ] + ], + id => 'lms_date_sync_direction', class => 'form-select form-select-sm' =%> +
+
+
+ + <%= maketext('Note: Only dates of sets for which the linitem URL is available can be synchronized.') =%> + +
+
diff --git a/templates/ContentGenerator/Instructor/UserList.html.ep b/templates/ContentGenerator/Instructor/UserList.html.ep index 424b0c2d9b..6972328e63 100644 --- a/templates/ContentGenerator/Instructor/UserList.html.ep +++ b/templates/ContentGenerator/Instructor/UserList.html.ep @@ -48,6 +48,13 @@ % % for my $actionID (@$formsToShow) { % next if $actionID eq 'reset_2fa' && !$ce->two_factor_authentication_enabled; + % next + % if $actionID eq 'lms_roster_sync' && ( + % !$ce->{LTIVersion} + % || $ce->{LTIVersion} ne 'v1p3' + % || !$ce->{LTI}{v1p3}{preferred_source_of_username} + % || !$db->getSettingValue('LTINamesRolesServiceURL') + % ); % next if $formPerms->{$actionID} && !$authz->hasPermissions(param('user'), $formPerms->{$actionID}); % % my $disabled = $actionID eq 'import' && !@$CSVList ? ' disabled' : ''; diff --git a/templates/ContentGenerator/Instructor/UserList/lms_roster_sync_form.html.ep b/templates/ContentGenerator/Instructor/UserList/lms_roster_sync_form.html.ep new file mode 100644 index 0000000000..b064e49274 --- /dev/null +++ b/templates/ContentGenerator/Instructor/UserList/lms_roster_sync_form.html.ep @@ -0,0 +1 @@ +<%= maketext('Synchronize roster from the LMS.') =%> diff --git a/templates/HelpFiles/InstructorProblemSetList.html.ep b/templates/HelpFiles/InstructorProblemSetList.html.ep index ea50ecd46a..7504db7150 100644 --- a/templates/HelpFiles/InstructorProblemSetList.html.ep +++ b/templates/HelpFiles/InstructorProblemSetList.html.ep @@ -76,4 +76,22 @@
<%= maketext('Delete') %>
<%= maketext('Delete the sets checked below. Be careful, this cannot be undone.') %>
+ + % if ( + % $ce->{LTIVersion} + % && $ce->{LTIVersion} eq 'v1p3' + % && $ce->{LTIGradeMode} eq 'homework' + % && $db->getSettingValue('LTILineitemsURL') + % ) { +
<%= maketext('Synchronize Set Dates with LMS.') %>
+
+ <%= maketext('Synchronize the dates for a single set or multiple sets to or from the LMS. This task is ' + . 'performed in the job queue, and this page only enqueus that task. Go to the Job Manager to see the ' + . 'status of the enqueued job. This will only work for sets for which the lineitem for the set is ' + . 'available or can be obtained. That is all sets that are created via content selection from the LMS, ' + . 'or manually created links in the LMS that have been used at least once. Note that date ' + . 'synchronization via LTI is not supported by all LMSs.' + ) =%> +
+ % } diff --git a/templates/HelpFiles/InstructorUserList.html.ep b/templates/HelpFiles/InstructorUserList.html.ep index b1b071cc6f..f9d44fee64 100644 --- a/templates/HelpFiles/InstructorUserList.html.ep +++ b/templates/HelpFiles/InstructorUserList.html.ep @@ -176,6 +176,25 @@
<%= maketext('Change the number of atttempts allowed on a problem.') %>
<%= maketext('This is done from the "Sets Manager" page or the "Instructor tools" page.') %>
+ + % if ( + % $ce->{LTIVersion} + % && $ce->{LTIVersion} eq 'v1p3' + % && $ce->{LTI}{v1p3}{preferred_source_of_username} + % && $db->getSettingValue('LTINamesRolesServiceURL') + % ) { +
<%= maketext('Synchronize users with the associated course in the LMS.') %>
+
+ <%= maketext('Select the "Synchronize LMS Roster" tab and click "Synchronize LMS Roster". ' + . 'This will enqueue a job that will contact the LMS and retrieve the list of users in the associated ' + . 'LMS course. Users that are in the LMS course but not in the WeBWorK course will be added. Users ' + . 'that are not in the LMS course will have their status changed to "Drop". Users that are in the LMS ' + . 'course and the WeBWorK course will have their data updated unless $LMSManageUserData is false. Note ' + . 'that users that have a permission level above the $LTIAccountCreationCutoff will not be added or ' + . 'modified. Go to the Job Manager to see the status of this job.' + ) =%> +
+ % }