From c9fe2263bdf39a57a08dc1b39e74be8351f1cca3 Mon Sep 17 00:00:00 2001 From: Glenn Rice Date: Fri, 22 May 2026 08:49:39 -0500 Subject: [PATCH] Add LTI 1.3 features. First, the `lineitems` URL that is sent with any resource link (and even for a deep linking request, i.e., a content selection request) is stored in the course settings whenever one of those occur. This `lineitems` URL is unique for the external tool, and can be used to fetch a list of all resource links that use the external tool. Then when any grade pass back is performed this URL is used to get that list which includes the `lineitem` URL for each specific resource link. That is the link that is used for grade pass back for the set. However, webwork needs to be able to identify the set it belongs to. Since it does not come from a resource link launch the webwork2 set id is not available. To fix this, when deep linking is used to create a resource link, webwork now sets the `resourceId` of the lineitem. Note that this is a reserved value for the tool provider, and the LMS (the tool consumer) is not supposed to ever change it, and must send it when the `lineitems` URL is used. Thus is deep linking is used to create a resource link to a set, then grade pass back will be available immediately for that set without a user ever using the link. Second, implement LMS roster synchronization. The requires that the names and roles service be enabled for the external tool. The registration URL now includes the `https://purl.imsglobal.org/spec/lti-nrps/scope/contextmembership.readonly` scope which makes this so. Note that tools created manually without the registration URL, this service can be enabled for the tool. However, at least for Canvas, if the tool was created using the registration URL before this scope was added, the only way to add the scope is to have the Canvas admin delete the tool and recreate it again using the registration URL with what is in this pull request. Moodle still allows editing of the tool created via the registration URL, so this can be added later. In any case, if the names and roles service is enabled, than any launch request (again including a deep linking request) includes the `namesrolesservice` URL. That can be used to request the list of users in the LMS course. There is now a new tab that will appear in the accounts manager if LTI 1.3 is enabled for a course, the `preferred_source_of_username` is set, and this `namesrolesservice` URL has been obtained (by some user using content selection or a resource link). When this tab is available and its action used, the list of users in the LMS course will be obtained, and users will be created in webwork that do not already exist, users will be updated that do exist (if `LMSManageUserData` is true), and users not in the LMS course will have their status changed to dropped. Note that the data sent when using the `namesrolesservice` URL is not the same as that sent in a launch request. So there are some new options that control what is used for the username and student_id. See the options and documentation in `conf/authen_LTI_1_3.conf.dist`. Also note that this will never add, update, or modify in any way any users that have a role that is above the `LTIAccountCreationCutoff`. Note that when users are created or updated via the tab in the accounts manager and the `namesrolesservice` URL, the LMS `user_id` (or `sub`) is included in the data received and saved to the `lis_source_did` column for the user. So this means that for links created via deep linking with this pull request and users added or updated in this way, grade pass back is available immediately, even if the user never logs in to webwork via the LMS. Also fix plurality of some texts for some other similar action results in the accounts manager. Unfortunately the message on the assignments page stating "You must access this assignment from the LMS before you can start." is still shown even if an instructor uses deep linking to create links in the LMS. The problem is that although grade passabck will work for those sets, the lineitem URL is still not saved in the database for all of those sets. The lineitem is not obtained until grade pass back occurs or as before, a user uses the link from the LMS. So those that create all links via deep linking will most likely want to set `$LTI{v1p3}{ignoreMissingSourcedID}` so that users are not shown that message, and can access the sets. As such, I made that option one that can be made available for instructors to set on the LTI tab in the course configuration. Third, implement set date synchronization with the LMS. Set dates can be synchronized to or from the LMS. There is a new tab for this in the sets manager that is shown if LTI 1.3 with homework grade pass back is enabled and the general lineitems URL is available. Also there is a new LTI 1.3 option `$LTI{v1p3}{autoSyncSetDatesToLMS}`. If this is true, then anytime that dates are changed on the sets manager or problem set detail pages, a job to synchronize dates to the LMS will automatically be queued for the sets with dates changed. Note that only the open and due (or close) dates have an equivalent in the LTI specification, namely the start and end dates. So when synchronizing from an LMS, the reduced scoring date and answer date are adjusted if needed to make them fit into the dates received from the LMS. Of course, when sending dates, only the open and due date are sent. It is also important to note that date synchronization is not supported by all LMSs. Canvas does, but Moodle does not. As usual, I don't know what D2L or Blackboard support. One related small change is that set dates are now also set when grade pass back mode is homework and a link is created to a set via deep linking. Another small change to the deep linking response is that `window => { targetName => '_blank' }` is set. This makes Canvas open the links in a new window instead of being embedded in the page. This does not work for Moodle, and as far as I can tell, there is no way to make this happen from the deep linking response. Fortunately for Moodle users, the external tool can be set so that all links created that use the external tool open in a new window. Canvas on the other hand does not have that. So without this change, Canvas users that create links have to go edit each link and change it to open in a new window. --- conf/authen_LTI.conf.dist | 2 + conf/authen_LTI_1_3.conf.dist | 53 ++- htdocs/js/ProblemSetList/problemsetlist.js | 11 +- lib/Mojolicious/WeBWorK.pm | 1 + .../WeBWorK/Tasks/LTISetDateSync.pm | 266 +++++++++++++++ lib/WeBWorK/Authen/LTIAdvantage.pm | 4 + .../Authen/LTIAdvantage/SubmitGrade.pm | 72 ++++- lib/WeBWorK/ConfigValues.pm | 25 ++ .../ContentGenerator/Instructor/JobManager.pm | 1 + .../Instructor/ProblemSetDetail.pm | 12 + .../Instructor/ProblemSetList.pm | 76 ++++- .../ContentGenerator/Instructor/UserList.pm | 304 ++++++++++++++++-- lib/WeBWorK/ContentGenerator/LTIAdvantage.pm | 22 +- .../Instructor/ProblemSetList.html.ep | 7 + .../ProblemSetList/lms_date_sync_form.html.ep | 29 ++ .../Instructor/UserList.html.ep | 7 + .../UserList/lms_roster_sync_form.html.ep | 1 + .../InstructorProblemSetList.html.ep | 18 ++ .../HelpFiles/InstructorUserList.html.ep | 19 ++ 19 files changed, 866 insertions(+), 64 deletions(-) create mode 100644 lib/Mojolicious/WeBWorK/Tasks/LTISetDateSync.pm create mode 100644 templates/ContentGenerator/Instructor/ProblemSetList/lms_date_sync_form.html.ep create mode 100644 templates/ContentGenerator/Instructor/UserList/lms_roster_sync_form.html.ep 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.' + ) =%> +
+ % }