|
| 1 | +package Mojolicious::WeBWorK::Tasks::LMSRosterSync; |
| 2 | +use Mojo::Base 'Minion::Job', -signatures, -async_await; |
| 3 | + |
| 4 | +use Mojo::UserAgent; |
| 5 | +use Mojo::Date; |
| 6 | + |
| 7 | +use WeBWorK::Authen::LTIAdvantage::SubmitGrade; |
| 8 | +use WeBWorK::CourseEnvironment; |
| 9 | +use WeBWorK::DB; |
| 10 | +use WeBWorK::Utils::DateTime qw(formatDateTime); |
| 11 | +use WeBWorK::Utils::Instructor qw(assignSetsToUsers); |
| 12 | + |
| 13 | +# Synchronize requested set dates to the LMS. |
| 14 | +sub run ($job) { |
| 15 | + # Establish a lock guard that only allows 1 job at a time (technically more than one could run at a time if a job |
| 16 | + # takes more than an hour to complete). As soon as a job completes (or fails) the lock is released and a new job |
| 17 | + # can start. New jobs retry every minute until they can acquire their own lock. |
| 18 | + return $job->retry({ delay => 60 }) unless my $guard = $job->minion->guard('lms_roster_sync', 3600); |
| 19 | + |
| 20 | + # Minion does not support asynchronous jobs with notification of job completion, and so the Mojolicious::Promise |
| 21 | + # wait method must be used. The synchronizeSetDates method is used so that the async/await syntax can be used |
| 22 | + # instead of using the wait method on each method that needs to be awaited which would be tedious. So the wait |
| 23 | + # method only needs to be used once here. |
| 24 | + $job->synchronizeRoster->wait(); |
| 25 | + |
| 26 | + return; |
| 27 | +} |
| 28 | + |
| 29 | +async sub synchronizeRoster ($job) { |
| 30 | + my $courseID = $job->info->{notes}{courseID}; |
| 31 | + return $job->fail('The course id was not passed when this job was enqueued.') unless $courseID; |
| 32 | + |
| 33 | + my $ce = eval { WeBWorK::CourseEnvironment->new({ courseName => $courseID }) }; |
| 34 | + return $job->fail('Could not construct course environment.') unless $ce; |
| 35 | + |
| 36 | + $job->{language_handle} = WeBWorK::Localize::getLoc($ce->{language} || 'en'); |
| 37 | + |
| 38 | + return $job->fail($job->maketext('This course is not configured to import users from the LMS via LTI.')) |
| 39 | + if !$ce->{LTIVersion} |
| 40 | + || $ce->{LTIVersion} ne 'v1p3' |
| 41 | + || !$ce->{LTI}{v1p3}{preferred_source_of_username}; |
| 42 | + |
| 43 | + my $db = WeBWorK::DB->new($ce); |
| 44 | + return $job->fail($job->maketext('Could not obtain database connection.')) unless $db; |
| 45 | + |
| 46 | + my $namesRolesServiceURL = $db->getSettingValue('LTINamesRolesServiceURL'); |
| 47 | + return $job->fail($job->maketext('The LTI names/roles service URL is not available.')) unless $namesRolesServiceURL; |
| 48 | + |
| 49 | + my $accessToken = |
| 50 | + await WeBWorK::Authen::LTIAdvantage::SubmitGrade->new(({ ce => $ce, db => $db, app => $job->app }, 1)) |
| 51 | + ->get_access_token; |
| 52 | + return $job->fail($job->maketext('Unable to obtain access token.')) unless $accessToken; |
| 53 | + |
| 54 | + my @namesRoles; |
| 55 | + |
| 56 | + while (1) { |
| 57 | + my $namesRolesServiceRequest = await Mojo::UserAgent->new->get_p($namesRolesServiceURL, |
| 58 | + { Authorization => "$accessToken->{token_type} $accessToken->{access_token}" })->catch(sub ($err) { |
| 59 | + return $err; |
| 60 | + }); |
| 61 | + return $job->fail( |
| 62 | + $job->maketext("Error communicating with the names and roles service URL: $namesRolesServiceRequest\n")) |
| 63 | + unless ref $namesRolesServiceRequest; |
| 64 | + |
| 65 | + my $namesRolesServiceResult = $namesRolesServiceRequest->result; |
| 66 | + if ($namesRolesServiceResult->is_success) { |
| 67 | + my $namesRoles = $namesRolesServiceResult->json->{members}; |
| 68 | + return $job->fail($job->maketext('Invalid data received from the LMS.')) unless ref $namesRoles eq 'ARRAY'; |
| 69 | + push(@namesRoles, @$namesRoles); |
| 70 | + |
| 71 | + if ($namesRolesServiceResult->headers->link |
| 72 | + && $namesRolesServiceResult->headers->link =~ /<([^>]*)>;\s*rel="next"/) |
| 73 | + { |
| 74 | + $namesRolesServiceURL = $1; |
| 75 | + } else { |
| 76 | + last; |
| 77 | + } |
| 78 | + } else { |
| 79 | + return $job->fail($job->maketext( |
| 80 | + 'There was an error obtaining the list of users from the LMS: [_1]', |
| 81 | + $namesRolesServiceResult->message |
| 82 | + )); |
| 83 | + } |
| 84 | + } |
| 85 | + |
| 86 | + my (@messages, @addedUsers, @userAchievementRecordsToAdd, @globalAchievementRecordsToAdd, %usersInLMSCourse); |
| 87 | + my $updatedUsers = 0; |
| 88 | + |
| 89 | + my %users = map { $_->user_id => $_ } $db->getUsersWhere({ user_id => { not_like => 'set_id:%' } }); |
| 90 | + |
| 91 | + my @achievements = $db->getAchievementsWhere({ enabled => 1 }, ['achievement_id']); |
| 92 | + |
| 93 | + my $preferredSourceOfUsername = $ce->{LTI}{v1p3}{namesroles_service_preferred_source_of_username} |
| 94 | + || $ce->{LTI}{v1p3}{preferred_source_of_username}; |
| 95 | + my $fallbackPasswordSource = $ce->{LTI}{v1p3}{namesroles_service_fallback_source_of_username} |
| 96 | + || $ce->{LTI}{v1p3}{fallback_source_of_username}; |
| 97 | + my $preferredSourceOfStudentId = $ce->{LTI}{v1p3}{namesroles_service_preferred_source_of_student_id} |
| 98 | + || $ce->{LTI}{v1p3}{preferred_source_of_student_id}; |
| 99 | + |
| 100 | + for my $user (@namesRoles) { |
| 101 | + my ($userIdSource, $typeOfSource) = ('', ''); |
| 102 | + my $userId = $user->{$preferredSourceOfUsername}; |
| 103 | + if (defined $userId) { |
| 104 | + $userIdSource = $preferredSourceOfUsername; |
| 105 | + $typeOfSource = |
| 106 | + "$userIdSource which was " |
| 107 | + . ($ce->{LTI}{v1p3}{namesroles_service_preferred_source_of_username} |
| 108 | + ? 'namesroles_service_preferred_source_of_username' |
| 109 | + : 'preferred_source_of_username'); |
| 110 | + } elsif ($fallbackPasswordSource && !defined $userId && defined $user->{$fallbackPasswordSource}) { |
| 111 | + $userIdSource = $fallbackPasswordSource; |
| 112 | + $typeOfSource = |
| 113 | + "$userIdSource which was" |
| 114 | + . ($ce->{LTI}{v1p3}{namesroles_service_fallback_source_of_username} |
| 115 | + ? 'namesroles_service_fallback_source_of_username' |
| 116 | + : 'fallback_source_of_username'); |
| 117 | + $userId = $user->{$fallbackPasswordSource}; |
| 118 | + } |
| 119 | + |
| 120 | + unless (defined $userId) { |
| 121 | + $job->app->log->info("\n=====================================\n" |
| 122 | + . "Unable to determine a webwork user id for LMS user:\n" |
| 123 | + . $job->app->dumper($user) |
| 124 | + . "\n=====================================") |
| 125 | + if $ce->{debug_lti_parameters}; |
| 126 | + next; |
| 127 | + } |
| 128 | + |
| 129 | + $userId =~ s/@.*$// if $userIdSource eq 'email' && $ce->{LTI}{v1p3}{strip_domain_from_email}; |
| 130 | + $userId = lc($userId) if $ce->{LTI}{v1p3}{lowercase_username}; |
| 131 | + |
| 132 | + my $studentId = $preferredSourceOfStudentId ? ($user->{$preferredSourceOfStudentId} // '') : ''; |
| 133 | + |
| 134 | + if ($ce->{debug_lti_parameters}) { |
| 135 | + $job->app->log->info("\n=========== USER SUMMARY ============\n" |
| 136 | + . "----------- LMS USER DATA -----------\n" |
| 137 | + . $job->app->dumper($user) |
| 138 | + . "-------------------------------------\n" |
| 139 | + . "User id is |$userId| (obtained from $typeOfSource)\n" |
| 140 | + . "User email address is |$user->{email}|\n" |
| 141 | + . "Student id is |$studentId|\n" |
| 142 | + . "====================================="); |
| 143 | + } |
| 144 | + |
| 145 | + $usersInLMSCourse{$userId} = 1; |
| 146 | + |
| 147 | + # Note that the only reliably obtained roles here are the membership roles. The issue is that these roles |
| 148 | + # are allowed to be abbreviated (i.e., the http://purl.imsglobal.org/... part may be entirely omitted |
| 149 | + # according to the specification). Moodle does this, but Canvas does not. However, both seem to add a prefix |
| 150 | + # for non-membership roles. Also, "institution" roles are not sent, so it is not even possible to honor the |
| 151 | + # $ce->{LTI}{v1p3}{AllowInstitutionRoles} setting. |
| 152 | + my @LTIroles = map {s|^http://purl.imsglobal.org/vocab/lis/v2/membership#||r} @{ $user->{roles} }; |
| 153 | + |
| 154 | + $job->app->log->info("The LTI roles defined for $userId are: \n-- " . join("\n-- ", @LTIroles)) |
| 155 | + if $ce->{debug_lti_parameters}; |
| 156 | + |
| 157 | + if (!defined($ce->{userRoles}{ $ce->{LTI}{v1p3}{LMSrolesToWeBWorKroles}{ $LTIroles[0] } })) { |
| 158 | + $job->app->log->info("Skipping $userId. Cannot find a WeBWorK role that corresponds to the " |
| 159 | + . "LMS role of $LTIroles[0] for this user.") |
| 160 | + if $ce->{debug_lti_parameters}; |
| 161 | + next; |
| 162 | + } |
| 163 | + |
| 164 | + my $permissionLevel = $ce->{userRoles}{ $ce->{LTI}{v1p3}{LMSrolesToWeBWorKroles}{ $LTIroles[0] } }; |
| 165 | + if (@LTIroles > 1) { |
| 166 | + for (@LTIroles[ 1 .. $#LTIroles ]) { |
| 167 | + my $wwRole = $ce->{LTI}{v1p3}{LMSrolesToWeBWorKroles}{$_}; |
| 168 | + next unless defined $wwRole; |
| 169 | + $permissionLevel = $ce->{userRoles}{$wwRole} if $permissionLevel < $ce->{userRoles}{$wwRole}; |
| 170 | + } |
| 171 | + } |
| 172 | + if ($permissionLevel > $ce->{userRoles}{ $ce->{LTIAccountCreationCutoff} }) { |
| 173 | + $job->app->log->info("Skipping $userId. User has a role above the LTI " |
| 174 | + . "account creation cutoff of $ce->{LTIAccountCreationCutoff}.") |
| 175 | + if $ce->{debug_lti_parameters}; |
| 176 | + next; |
| 177 | + } |
| 178 | + |
| 179 | + if ($users{$userId}) { |
| 180 | + next unless $ce->{LMSManageUserData}; |
| 181 | + |
| 182 | + # Create a temporary user with the LMS credentials and compare the user to the existing user. |
| 183 | + my $tempUser = $db->newUser( |
| 184 | + user_id => $userId, |
| 185 | + lis_source_did => $user->{user_id}, |
| 186 | + last_name => $user->{family_name} =~ s/\+/ /gr, |
| 187 | + first_name => $user->{given_name} =~ s/\+/ /gr, |
| 188 | + email_address => $user->{email}, |
| 189 | + status => $user->{status} eq 'Active' ? 'C' : 'D', |
| 190 | + comment => |
| 191 | + formatDateTime(time, 'datetime_format_short', $ce->{siteDefaults}{timezone}, $ce->{language}), |
| 192 | + student_id => $studentId, |
| 193 | + section => '', |
| 194 | + recitation => '' |
| 195 | + ); |
| 196 | + |
| 197 | + my $change_made = 0; |
| 198 | + for my $element (qw(last_name first_name email_address status student_id)) { |
| 199 | + if ($users{$userId}->$element ne $tempUser->$element) { |
| 200 | + $change_made = 1; |
| 201 | + $job->app->log->info("WeBWorK user has $element: " |
| 202 | + . $users{$userId}->$element |
| 203 | + . ", but LMS user has $element: " |
| 204 | + . $tempUser->$element) |
| 205 | + if $ce->{debug_lti_parameters}; |
| 206 | + $users{$userId}->$element($tempUser->$element); |
| 207 | + } |
| 208 | + } |
| 209 | + |
| 210 | + if ($change_made) { |
| 211 | + ++$updatedUsers; |
| 212 | + $tempUser->comment( |
| 213 | + formatDateTime(time, 'datetime_format_short', $ce->{siteDefaults}{timezone}, $ce->{language})); |
| 214 | + eval { $db->putUser($tempUser) }; |
| 215 | + if ($@) { |
| 216 | + $job->app->log->error("Failed to update user $userId when importing LMS user: $@"); |
| 217 | + push(@messages, $job->maketext('Failed to update user [_1].', $userId)); |
| 218 | + } else { |
| 219 | + push(@messages, $job->maketext('Updated user [_1].', $userId)); |
| 220 | + } |
| 221 | + } else { |
| 222 | + push(@messages, $job->maketext("[_1] not changed.", $userId)); |
| 223 | + } |
| 224 | + } else { |
| 225 | + push(@messages, $job->maketext('Added user [_1] with permission level [_2].', $userId, $permissionLevel)); |
| 226 | + push(@addedUsers, $userId); |
| 227 | + |
| 228 | + my $newUser = $db->newUser( |
| 229 | + user_id => $userId, |
| 230 | + lis_source_did => $user->{user_id}, |
| 231 | + last_name => $user->{family_name} =~ s/\+/ /gr, |
| 232 | + first_name => $user->{given_name} =~ s/\+/ /gr, |
| 233 | + email_address => $user->{email}, |
| 234 | + status => $user->{status} eq 'Active' ? 'C' : 'D', |
| 235 | + comment => |
| 236 | + formatDateTime(time, 'datetime_format_short', $ce->{siteDefaults}{timezone}, $ce->{language}), |
| 237 | + student_id => $studentId, |
| 238 | + section => '', |
| 239 | + recitation => '' |
| 240 | + ); |
| 241 | + $db->addUser($newUser); |
| 242 | + |
| 243 | + $db->addPermissionLevel($db->newPermissionLevel(user_id => $userId, permission => $permissionLevel)); |
| 244 | + |
| 245 | + for (@achievements) { |
| 246 | + push(@userAchievementRecordsToAdd, |
| 247 | + $db->newUserAchievement(user_id => $userId, achievement_id => $_->achievement_id)); |
| 248 | + } |
| 249 | + push(@globalAchievementRecordsToAdd, |
| 250 | + $db->newGlobalUserAchievement(user_id => $userId, achievement_points => 0)); |
| 251 | + |
| 252 | + $users{$userId} = $newUser; |
| 253 | + } |
| 254 | + } |
| 255 | + |
| 256 | + # Assign visible sets to the added users. |
| 257 | + assignSetsToUsers($db, $ce, [ map { $_->[0] } $db->listGlobalSetsWhere({ visible => 1 }) ], \@addedUsers) |
| 258 | + if @addedUsers; |
| 259 | + |
| 260 | + # Assign achievements to the added users. |
| 261 | + $db->UserAchievement->insert_records(\@userAchievementRecordsToAdd) if @userAchievementRecordsToAdd; |
| 262 | + $db->GlobalUserAchievement->insert_records(\@globalAchievementRecordsToAdd) |
| 263 | + if @globalAchievementRecordsToAdd; |
| 264 | + |
| 265 | + my %permissionLevels = |
| 266 | + map { $_->user_id => $_->permission } $db->getPermissionLevelsWhere({ user_id => { not_like => 'set_id:%' } }); |
| 267 | + |
| 268 | + # Mark all users not in the LMS roster and at or below the LTIAccountCreationCutoff as dropped. |
| 269 | + my @droppedUsers; |
| 270 | + for my $user (values %users) { |
| 271 | + next |
| 272 | + if $usersInLMSCourse{ $user->user_id } |
| 273 | + || ($permissionLevels{ $user->user_id } // 0) > $ce->{userRoles}{ $ce->{LTIAccountCreationCutoff} } |
| 274 | + || $user->status eq 'D'; |
| 275 | + $user->status('D'); |
| 276 | + push(@messages, $job->maketext('Dropped user [_1]', $user->user_id)); |
| 277 | + push(@droppedUsers, $user); |
| 278 | + } |
| 279 | + $db->User->update_records(\@droppedUsers) if @droppedUsers; |
| 280 | + |
| 281 | + push( |
| 282 | + @messages, |
| 283 | + $ce->{LMSManageUserData} |
| 284 | + ? $job->maketext( |
| 285 | + '[_1] [plural,_1,user] added, [_2] [plural,_2,user] updated, ' |
| 286 | + . '[_3] [plural,_3,user] not in LMS [plural,_3,was,were] dropped', |
| 287 | + scalar(@addedUsers), |
| 288 | + $updatedUsers, |
| 289 | + scalar(@droppedUsers) |
| 290 | + ) |
| 291 | + : $job->maketext( |
| 292 | + '[_1] [plural,_1,user] added, [_2] [plural,_2,user] not in LMS [plural,_2,was,were] dropped', |
| 293 | + scalar(@addedUsers), scalar(@droppedUsers) |
| 294 | + ) |
| 295 | + ); |
| 296 | + return $job->finish(@messages > 1 ? \@messages : $messages[0]); |
| 297 | +} |
| 298 | + |
| 299 | +sub maketext ($job, @args) { |
| 300 | + return &{ $job->{language_handle} }(@args); |
| 301 | +} |
| 302 | + |
| 303 | +1; |
0 commit comments