Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 3.11.x will end 14 Nov 2022 (12 months plus 6 months extension).
  • Bug fixes for security issues in 3.11.x will end 13 Nov 2023 (18 months plus 12 months extension).
  • PHP version: minimum PHP 7.3.0 Note: minimum PHP version has increased since Moodle 3.10. PHP 7.4.x is supported too.

Differences Between: [Versions 310 and 311] [Versions 311 and 400] [Versions 311 and 401] [Versions 311 and 402] [Versions 311 and 403] [Versions 39 and 311]

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