Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions conf/authen_LTI.conf.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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}',
Expand Down
53 changes: 46 additions & 7 deletions conf/authen_LTI_1_3.conf.dist
Original file line number Diff line number Diff line change
Expand Up @@ -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
################################################################################################
Expand All @@ -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
################################################################################################
Expand Down Expand Up @@ -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.
11 changes: 9 additions & 2 deletions htdocs/js/ProblemSetList/problemsetlist.js
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand All @@ -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();
Expand Down
1 change: 1 addition & 0 deletions lib/Mojolicious/WeBWorK.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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');

Expand Down
266 changes: 266 additions & 0 deletions lib/Mojolicious/WeBWorK/Tasks/LTISetDateSync.pm
Original file line number Diff line number Diff line change
@@ -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;
4 changes: 4 additions & 0 deletions lib/WeBWorK/Authen/LTIAdvantage.pm
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
Loading
Loading