1<?php 2// This file is part of Moodle - http://moodle.org/ 3// 4// Moodle is free software: you can redistribute it and/or modify 5// it under the terms of the GNU General Public License as published by 6// the Free Software Foundation, either version 3 of the License, or 7// (at your option) any later version. 8// 9// Moodle is distributed in the hope that it will be useful, 10// but WITHOUT ANY WARRANTY; without even the implied warranty of 11// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the 12// GNU General Public License for more details. 13// 14// You should have received a copy of the GNU General Public License 15// along with Moodle. If not, see <http://www.gnu.org/licenses/>. 16 17/** 18 * Handles synchronising members using the enrolment LTI. 19 * 20 * @package enrol_lti 21 * @copyright 2016 Mark Nelson <markn@moodle.com> 22 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 23 */ 24 25namespace enrol_lti\task; 26 27defined('MOODLE_INTERNAL') || die(); 28 29use core\task\scheduled_task; 30use core_user; 31use enrol_lti\data_connector; 32use enrol_lti\helper; 33use IMSGlobal\LTI\ToolProvider\Context; 34use IMSGlobal\LTI\ToolProvider\ResourceLink; 35use IMSGlobal\LTI\ToolProvider\ToolConsumer; 36use IMSGlobal\LTI\ToolProvider\User; 37use stdClass; 38 39require_once($CFG->dirroot . '/user/lib.php'); 40 41/** 42 * Task for synchronising members using the enrolment LTI. 43 * 44 * @package enrol_lti 45 * @copyright 2016 Mark Nelson <markn@moodle.com> 46 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later 47 */ 48class sync_members extends scheduled_task { 49 50 /** @var array Array of user photos. */ 51 protected $userphotos = []; 52 53 /** @var array Array of current LTI users. */ 54 protected $currentusers = []; 55 56 /** @var data_connector $dataconnector A data_connector instance. */ 57 protected $dataconnector; 58 59 /** 60 * Get a descriptive name for this task. 61 * 62 * @return string 63 */ 64 public function get_name() { 65 return get_string('tasksyncmembers', 'enrol_lti'); 66 } 67 68 /** 69 * Performs the synchronisation of members. 70 */ 71 public function execute() { 72 if (!is_enabled_auth('lti')) { 73 mtrace('Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti'))); 74 return; 75 } 76 77 // Check if the enrolment plugin is disabled - isn't really necessary as the task should not run if 78 // the plugin is disabled, but there is no harm in making sure core hasn't done something wrong. 79 if (!enrol_is_enabled('lti')) { 80 mtrace('Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti')); 81 return; 82 } 83 84 $this->dataconnector = new data_connector(); 85 86 // Get all the enabled tools. 87 $tools = helper::get_lti_tools(array('status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1)); 88 foreach ($tools as $tool) { 89 mtrace("Starting - Member sync for published tool '$tool->id' for course '$tool->courseid'."); 90 91 // Variables to keep track of information to display later. 92 $usercount = 0; 93 $enrolcount = 0; 94 $unenrolcount = 0; 95 96 // Fetch consumer records mapped to this tool. 97 $consumers = $this->dataconnector->get_consumers_mapped_to_tool($tool->id); 98 99 // Perform processing for each consumer. 100 foreach ($consumers as $consumer) { 101 mtrace("Requesting membership service for the tool consumer '{$consumer->getRecordId()}'"); 102 103 // Get members through this tool consumer. 104 $members = $this->fetch_members_from_consumer($consumer); 105 106 // Check if we were able to fetch the members. 107 if ($members === false) { 108 mtrace("Skipping - Membership service request failed.\n"); 109 continue; 110 } 111 112 // Fetched members count. 113 $membercount = count($members); 114 mtrace("$membercount members received.\n"); 115 116 // Process member information. 117 list($usercount, $enrolcount) = $this->sync_member_information($tool, $consumer, $members); 118 } 119 120 // Now we check if we have to unenrol users who were not listed. 121 if ($this->should_sync_unenrol($tool->membersyncmode)) { 122 $unenrolcount = $this->sync_unenrol($tool); 123 } 124 125 mtrace("Completed - Synced members for tool '$tool->id' in the course '$tool->courseid'. " . 126 "Processed $usercount users; enrolled $enrolcount members; unenrolled $unenrolcount members.\n"); 127 } 128 129 // Sync the user profile photos. 130 mtrace("Started - Syncing user profile images."); 131 $countsyncedimages = $this->sync_profile_images(); 132 mtrace("Completed - Synced $countsyncedimages profile images."); 133 } 134 135 /** 136 * Fetches the members that belong to a ToolConsumer. 137 * 138 * @param ToolConsumer $consumer 139 * @return bool|User[] 140 */ 141 protected function fetch_members_from_consumer(ToolConsumer $consumer) { 142 $dataconnector = $this->dataconnector; 143 144 // Get membership URL template from consumer profile data. 145 $defaultmembershipsurl = null; 146 if (isset($consumer->profile->service_offered)) { 147 $servicesoffered = $consumer->profile->service_offered; 148 foreach ($servicesoffered as $service) { 149 if (isset($service->{'@id'}) && strpos($service->{'@id'}, 'tcp:ToolProxyBindingMemberships') !== false && 150 isset($service->endpoint)) { 151 $defaultmembershipsurl = $service->endpoint; 152 if (isset($consumer->profile->product_instance->product_info->product_family->vendor->code)) { 153 $vendorcode = $consumer->profile->product_instance->product_info->product_family->vendor->code; 154 $defaultmembershipsurl = str_replace('{vendor_code}', $vendorcode, $defaultmembershipsurl); 155 } 156 $defaultmembershipsurl = str_replace('{product_code}', $consumer->getKey(), $defaultmembershipsurl); 157 break; 158 } 159 } 160 } 161 162 $members = false; 163 164 // Fetch the resource link linked to the consumer. 165 $resourcelink = $dataconnector->get_resourcelink_from_consumer($consumer); 166 if ($resourcelink !== null) { 167 // Try to perform a membership service request using this resource link. 168 $members = $this->do_resourcelink_membership_request($resourcelink); 169 } 170 171 // If membership service can't be performed through resource link, fallback through context memberships. 172 if ($members === false) { 173 // Fetch context records that are mapped to this ToolConsumer. 174 $contexts = $dataconnector->get_contexts_from_consumer($consumer); 175 176 // Perform membership service request for each of these contexts. 177 foreach ($contexts as $context) { 178 $contextmembership = $this->do_context_membership_request($context, $resourcelink, $defaultmembershipsurl); 179 if ($contextmembership) { 180 // Add $contextmembership contents to $members array. 181 if (is_array($members)) { 182 $members = array_merge($members, $contextmembership); 183 } else { 184 $members = $contextmembership; 185 } 186 } 187 } 188 } 189 190 return $members; 191 } 192 193 /** 194 * Method to determine whether to sync unenrolments or not. 195 * 196 * @param int $syncmode The tool's membersyncmode. 197 * @return bool 198 */ 199 protected function should_sync_unenrol($syncmode) { 200 return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_UNENROL_MISSING; 201 } 202 203 /** 204 * Method to determine whether to sync enrolments or not. 205 * 206 * @param int $syncmode The tool's membersyncmode. 207 * @return bool 208 */ 209 protected function should_sync_enrol($syncmode) { 210 return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_ENROL_NEW; 211 } 212 213 /** 214 * Performs synchronisation of member information and enrolments. 215 * 216 * @param stdClass $tool 217 * @param ToolConsumer $consumer 218 * @param User[] $members 219 * @return array An array containing the number of members that were processed and the number of members that were enrolled. 220 */ 221 protected function sync_member_information(stdClass $tool, ToolConsumer $consumer, $members) { 222 global $DB; 223 $usercount = 0; 224 $enrolcount = 0; 225 226 // Process member information. 227 foreach ($members as $member) { 228 $usercount++; 229 230 // Set the user data. 231 $user = new stdClass(); 232 $user->username = helper::create_username($consumer->getKey(), $member->ltiUserId); 233 $user->firstname = core_user::clean_field($member->firstname, 'firstname'); 234 $user->lastname = core_user::clean_field($member->lastname, 'lastname'); 235 $user->email = core_user::clean_field($member->email, 'email'); 236 237 // Get the user data from the LTI consumer. 238 $user = helper::assign_user_tool_data($tool, $user); 239 240 $dbuser = core_user::get_user_by_username($user->username, 'id'); 241 if ($dbuser) { 242 // If email is empty remove it, so we don't update the user with an empty email. 243 if (empty($user->email)) { 244 unset($user->email); 245 } 246 247 $user->id = $dbuser->id; 248 user_update_user($user); 249 250 // Add the information to the necessary arrays. 251 if (!in_array($user->id, $this->currentusers)) { 252 $this->currentusers[] = $user->id; 253 } 254 $this->userphotos[$user->id] = $member->image; 255 } else { 256 if ($this->should_sync_enrol($tool->membersyncmode)) { 257 // If the email was stripped/not set then fill it with a default one. This 258 // stops the user from being redirected to edit their profile page. 259 if (empty($user->email)) { 260 $user->email = $user->username . "@example.com"; 261 } 262 263 $user->auth = 'lti'; 264 $user->id = user_create_user($user); 265 266 // Add the information to the necessary arrays. 267 $this->currentusers[] = $user->id; 268 $this->userphotos[$user->id] = $member->image; 269 } 270 } 271 272 // Sync enrolments. 273 if ($this->should_sync_enrol($tool->membersyncmode)) { 274 // Enrol the user in the course. 275 if (helper::enrol_user($tool, $user->id) === helper::ENROLMENT_SUCCESSFUL) { 276 // Increment enrol count. 277 $enrolcount++; 278 } 279 280 // Check if this user has already been registered in the enrol_lti_users table. 281 if (!$DB->record_exists('enrol_lti_users', ['toolid' => $tool->id, 'userid' => $user->id])) { 282 // Create an initial enrol_lti_user record that we can use later when syncing grades and members. 283 $userlog = new stdClass(); 284 $userlog->userid = $user->id; 285 $userlog->toolid = $tool->id; 286 $userlog->consumerkey = $consumer->getKey(); 287 288 $DB->insert_record('enrol_lti_users', $userlog); 289 } 290 } 291 } 292 293 return [$usercount, $enrolcount]; 294 } 295 296 /** 297 * Performs unenrolment of users that are no longer enrolled in the consumer side. 298 * 299 * @param stdClass $tool The tool record object. 300 * @return int The number of users that have been unenrolled. 301 */ 302 protected function sync_unenrol(stdClass $tool) { 303 global $DB; 304 305 $ltiplugin = enrol_get_plugin('lti'); 306 307 if (!$this->should_sync_unenrol($tool->membersyncmode)) { 308 return 0; 309 } 310 311 if (empty($this->currentusers)) { 312 return 0; 313 } 314 315 $unenrolcount = 0; 316 317 $ltiusersrs = $DB->get_recordset('enrol_lti_users', array('toolid' => $tool->id), 'lastaccess DESC', 'userid'); 318 // Go through the users and check if any were never listed, if so, remove them. 319 foreach ($ltiusersrs as $ltiuser) { 320 if (!in_array($ltiuser->userid, $this->currentusers)) { 321 $instance = new stdClass(); 322 $instance->id = $tool->enrolid; 323 $instance->courseid = $tool->courseid; 324 $instance->enrol = 'lti'; 325 $ltiplugin->unenrol_user($instance, $ltiuser->userid); 326 // Increment unenrol count. 327 $unenrolcount++; 328 } 329 } 330 $ltiusersrs->close(); 331 332 return $unenrolcount; 333 } 334 335 /** 336 * Performs synchronisation of user profile images. 337 */ 338 protected function sync_profile_images() { 339 $counter = 0; 340 foreach ($this->userphotos as $userid => $url) { 341 if ($url) { 342 $result = helper::update_user_profile_image($userid, $url); 343 if ($result === helper::PROFILE_IMAGE_UPDATE_SUCCESSFUL) { 344 $counter++; 345 mtrace("Profile image succesfully downloaded and created for user '$userid' from $url."); 346 } else { 347 mtrace($result); 348 } 349 } 350 } 351 return $counter; 352 } 353 354 /** 355 * Performs membership service request using an LTI Context object. 356 * 357 * If the context has a 'custom_context_memberships_url' setting, we use this to perform the membership service request. 358 * Otherwise, if a context is associated with resource link, we try first to get the members using the 359 * ResourceLink::doMembershipsService() method. 360 * If we're still unable to fetch members from the resource link, we try to build a memberships URL from the memberships URL 361 * endpoint template that is defined in the ToolConsumer profile and substitute the parameters accordingly. 362 * 363 * @param Context $context The context object. 364 * @param ResourceLink $resourcelink The resource link object. 365 * @param string $membershipsurltemplate The memberships endpoint URL template. 366 * @return bool|User[] Array of User objects upon successful membership service request. False, otherwise. 367 */ 368 protected function do_context_membership_request(Context $context, ResourceLink $resourcelink = null, 369 $membershipsurltemplate = '') { 370 $dataconnector = $this->dataconnector; 371 372 // Flag to indicate whether to save the context later. 373 $contextupdated = false; 374 375 // If membership URL is not set, try to generate using the default membership URL from the consumer profile. 376 if (!$context->hasMembershipService()) { 377 if (empty($membershipsurltemplate)) { 378 mtrace("Skipping - No membership service available.\n"); 379 return false; 380 } 381 382 if ($resourcelink === null) { 383 $resourcelink = $dataconnector->get_resourcelink_from_context($context); 384 } 385 386 if ($resourcelink !== null) { 387 // Try to perform a membership service request using this resource link. 388 $resourcelinkmembers = $this->do_resourcelink_membership_request($resourcelink); 389 if ($resourcelinkmembers) { 390 // If we're able to fetch members using this resource link, return these. 391 return $resourcelinkmembers; 392 } 393 } 394 395 // If fetching memberships through resource link failed and we don't have a memberships URL, build one from template. 396 mtrace("'custom_context_memberships_url' not set. Fetching default template: $membershipsurltemplate"); 397 $membershipsurl = $membershipsurltemplate; 398 399 // Check if we need to fetch tool code. 400 $needstoolcode = strpos($membershipsurl, '{tool_code}') !== false; 401 if ($needstoolcode) { 402 $toolcode = false; 403 404 // Fetch tool code from the resource link data. 405 $lisresultsourcedidjson = $resourcelink->getSetting('lis_result_sourcedid'); 406 if ($lisresultsourcedidjson) { 407 $lisresultsourcedid = json_decode($lisresultsourcedidjson); 408 if (isset($lisresultsourcedid->data->typeid)) { 409 $toolcode = $lisresultsourcedid->data->typeid; 410 } 411 } 412 413 if ($toolcode) { 414 // Substitute fetched tool code value. 415 $membershipsurl = str_replace('{tool_code}', $toolcode, $membershipsurl); 416 } else { 417 // We're unable to determine the tool code. End this processing. 418 return false; 419 } 420 } 421 422 // Get context_id parameter and substitute, if applicable. 423 $membershipsurl = str_replace('{context_id}', $context->getId(), $membershipsurl); 424 425 // Get context_type and substitute, if applicable. 426 if (strpos($membershipsurl, '{context_type}') !== false) { 427 $contexttype = $context->type !== null ? $context->type : 'CourseSection'; 428 $membershipsurl = str_replace('{context_type}', $contexttype, $membershipsurl); 429 } 430 431 // Save this URL for the context's custom_context_memberships_url setting. 432 $context->setSetting('custom_context_memberships_url', $membershipsurl); 433 $contextupdated = true; 434 } 435 436 // Perform membership service request. 437 $url = $context->getSetting('custom_context_memberships_url'); 438 mtrace("Performing membership service request from context with URL {$url}."); 439 $members = $context->getMembership(); 440 441 // Save the context if membership request succeeded and if it has been updated. 442 if ($members && $contextupdated) { 443 $context->save(); 444 } 445 446 return $members; 447 } 448 449 /** 450 * Performs membership service request using ResourceLink::doMembershipsService() method. 451 * 452 * @param ResourceLink $resourcelink 453 * @return bool|User[] Array of User objects upon successful membership service request. False, otherwise. 454 */ 455 protected function do_resourcelink_membership_request(ResourceLink $resourcelink) { 456 $members = false; 457 $membershipsurl = $resourcelink->getSetting('ext_ims_lis_memberships_url'); 458 $membershipsid = $resourcelink->getSetting('ext_ims_lis_memberships_id'); 459 if ($membershipsurl && $membershipsid) { 460 mtrace("Performing membership service request from resource link with membership URL: " . $membershipsurl); 461 $members = $resourcelink->doMembershipsService(true); 462 } 463 return $members; 464 } 465} 466