Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.3.x will end 7 October 2024 (12 months).
  • Bug fixes for security issues in 4.3.x will end 21 April 2025 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.2.x is supported too.

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

   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   * IMS Enterprise file enrolment plugin.
  19   *
  20   * This plugin lets the user specify an IMS Enterprise file to be processed.
  21   * The IMS Enterprise file is mainly parsed on a regular cron,
  22   * but can also be imported via the UI (Admin Settings).
  23   * @package    enrol_imsenterprise
  24   * @copyright  2010 Eugene Venter
  25   * @author     Eugene Venter - based on code by Dan Stowell
  26   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  27   */
  28  
  29  defined('MOODLE_INTERNAL') || die();
  30  
  31  require_once($CFG->dirroot.'/group/lib.php');
  32  
  33  /**
  34   * IMS Enterprise file enrolment plugin.
  35   *
  36   * @copyright  2010 Eugene Venter
  37   * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  38   */
  39  class enrol_imsenterprise_plugin extends enrol_plugin {
  40  
  41      /**
  42       * @var IMSENTERPRISE_ADD imsenterprise add action.
  43       */
  44      const IMSENTERPRISE_ADD = 1;
  45  
  46      /**
  47       * @var IMSENTERPRISE_UPDATE imsenterprise update action.
  48       */
  49      const IMSENTERPRISE_UPDATE = 2;
  50  
  51      /**
  52       * @var IMSENTERPRISE_DELETE imsenterprise delete action.
  53       */
  54      const IMSENTERPRISE_DELETE = 3;
  55  
  56      /**
  57       * @var $logfp resource file pointer for writing log data to.
  58       */
  59      protected $logfp;
  60  
  61      /**
  62       * @var $continueprocessing bool flag to determine if processing should continue.
  63       */
  64      protected $continueprocessing;
  65  
  66      /**
  67       * @var $xmlcache string cache of xml lines.
  68       */
  69      protected $xmlcache;
  70  
  71      /**
  72       * @var $coursemappings array of mappings between IMS data fields and moodle course fields.
  73       */
  74      protected $coursemappings;
  75  
  76      /**
  77       * @var $rolemappings array of mappings between IMS roles and moodle roles.
  78       */
  79      protected $rolemappings;
  80  
  81      /**
  82       * @var $defaultcategoryid id of default category.
  83       */
  84      protected $defaultcategoryid;
  85  
  86      /**
  87       * Read in an IMS Enterprise file.
  88       * Originally designed to handle v1.1 files but should be able to handle
  89       * earlier types as well, I believe.
  90       * This cron feature has been converted to a scheduled task and it can now be scheduled
  91       * from the UI.
  92       */
  93      public function cron() {
  94          global $CFG;
  95  
  96          // Get configs.
  97          $imsfilelocation = $this->get_config('imsfilelocation');
  98          $logtolocation = $this->get_config('logtolocation');
  99          $mailadmins = $this->get_config('mailadmins');
 100          $prevtime = $this->get_config('prev_time');
 101          $prevmd5 = $this->get_config('prev_md5');
 102          $prevpath = $this->get_config('prev_path');
 103  
 104          if (empty($imsfilelocation)) {
 105              $filename = "$CFG->dataroot/1/imsenterprise-enrol.xml";  // Default location.
 106          } else {
 107              $filename = $imsfilelocation;
 108          }
 109  
 110          $this->logfp = false;
 111          if (!empty($logtolocation)) {
 112              $this->logfp = fopen($logtolocation, 'a');
 113          }
 114  
 115          $this->defaultcategoryid = null;
 116  
 117          $fileisnew = false;
 118          if ( file_exists($filename) ) {
 119              core_php_time_limit::raise();
 120              $starttime = time();
 121  
 122              $this->log_line('----------------------------------------------------------------------');
 123              $this->log_line("IMS Enterprise enrol cron process launched at " . userdate(time()));
 124              $this->log_line('Found file '.$filename);
 125              $this->xmlcache = '';
 126  
 127              $categoryseparator = trim($this->get_config('categoryseparator'));
 128              $categoryidnumber = $this->get_config('categoryidnumber');
 129  
 130              // Make sure we understand how to map the IMS-E roles to Moodle roles.
 131              $this->load_role_mappings();
 132              // Make sure we understand how to map the IMS-E course names to Moodle course names.
 133              $this->load_course_mappings();
 134  
 135              $md5 = md5_file($filename); // NB We'll write this value back to the database at the end of the cron.
 136              $filemtime = filemtime($filename);
 137  
 138              // Decide if we want to process the file (based on filepath, modification time, and MD5 hash)
 139              // This is so we avoid wasting the server's efforts processing a file unnecessarily.
 140              if ($categoryidnumber && empty($categoryseparator)) {
 141                  $this->log_line('Category idnumber is enabled but category separator is not defined - skipping processing.');
 142              } else if (empty($prevpath)  || ($filename != $prevpath)) {
 143                  $fileisnew = true;
 144              } else if (isset($prevtime) && ($filemtime <= $prevtime)) {
 145                  $this->log_line('File modification time is not more recent than last update - skipping processing.');
 146              } else if (isset($prevmd5) && ($md5 == $prevmd5)) {
 147                  $this->log_line('File MD5 hash is same as on last update - skipping processing.');
 148              } else {
 149                  $fileisnew = true; // Let's process it!
 150              }
 151  
 152              if ($fileisnew) {
 153  
 154                  // The <properties> tag is allowed to halt processing if we're demanding a matching target.
 155                  $this->continueprocessing = true;
 156  
 157                  // Run through the file and process the group/person entries.
 158                  if (($fh = fopen($filename, "r")) != false) {
 159  
 160                      $line = 0;
 161                      while ((!feof($fh)) && $this->continueprocessing) {
 162  
 163                          $line++;
 164                          $curline = fgets($fh);
 165                          $this->xmlcache .= $curline; // Add a line onto the XML cache.
 166  
 167                          while (true) {
 168                              // If we've got a full tag (i.e. the most recent line has closed the tag) then process-it-and-forget-it.
 169                              // Must always make sure to remove tags from cache so they don't clog up our memory.
 170                              if ($tagcontents = $this->full_tag_found_in_cache('group', $curline)) {
 171                                  $this->process_group_tag($tagcontents);
 172                                  $this->remove_tag_from_cache('group');
 173                              } else if ($tagcontents = $this->full_tag_found_in_cache('person', $curline)) {
 174                                  $this->process_person_tag($tagcontents);
 175                                  $this->remove_tag_from_cache('person');
 176                              } else if ($tagcontents = $this->full_tag_found_in_cache('membership', $curline)) {
 177                                  $this->process_membership_tag($tagcontents);
 178                                  $this->remove_tag_from_cache('membership');
 179                              } else if ($tagcontents = $this->full_tag_found_in_cache('comments', $curline)) {
 180                                  $this->remove_tag_from_cache('comments');
 181                              } else if ($tagcontents = $this->full_tag_found_in_cache('properties', $curline)) {
 182                                  $this->process_properties_tag($tagcontents);
 183                                  $this->remove_tag_from_cache('properties');
 184                              } else {
 185                                  break;
 186                              }
 187                          }
 188                      }
 189                      fclose($fh);
 190                      fix_course_sortorder();
 191                  }
 192  
 193                  $timeelapsed = time() - $starttime;
 194                  $this->log_line('Process has completed. Time taken: '.$timeelapsed.' seconds.');
 195  
 196              }
 197  
 198              // These variables are stored so we can compare them against the IMS file, next time round.
 199              $this->set_config('prev_time', $filemtime);
 200              $this->set_config('prev_md5',  $md5);
 201              $this->set_config('prev_path', $filename);
 202  
 203          } else {
 204              $this->log_line('File not found: '.$filename);
 205          }
 206  
 207          if (!empty($mailadmins) && $fileisnew) {
 208              $timeelapsed = isset($timeelapsed) ? $timeelapsed : 0;
 209              $msg = "An IMS enrolment has been carried out within Moodle.\nTime taken: $timeelapsed seconds.\n\n";
 210              if (!empty($logtolocation)) {
 211                  if ($this->logfp) {
 212                      $msg .= "Log data has been written to:\n";
 213                      $msg .= "$logtolocation\n";
 214                      $msg .= "(Log file size: ".ceil(filesize($logtolocation) / 1024)."Kb)\n\n";
 215                  } else {
 216                      $msg .= "The log file appears not to have been successfully written.\n";
 217                      $msg .= "Check that the file is writeable by the server:\n";
 218                      $msg .= "$logtolocation\n\n";
 219                  }
 220              } else {
 221                  $msg .= "Logging is currently not active.";
 222              }
 223  
 224              $eventdata = new \core\message\message();
 225              $eventdata->courseid          = SITEID;
 226              $eventdata->modulename        = 'moodle';
 227              $eventdata->component         = 'enrol_imsenterprise';
 228              $eventdata->name              = 'imsenterprise_enrolment';
 229              $eventdata->userfrom          = get_admin();
 230              $eventdata->userto            = get_admin();
 231              $eventdata->subject           = "Moodle IMS Enterprise enrolment notification";
 232              $eventdata->fullmessage       = $msg;
 233              $eventdata->fullmessageformat = FORMAT_PLAIN;
 234              $eventdata->fullmessagehtml   = '';
 235              $eventdata->smallmessage      = '';
 236              message_send($eventdata);
 237  
 238              $this->log_line('Notification email sent to administrator.');
 239  
 240          }
 241  
 242          if ($this->logfp) {
 243              fclose($this->logfp);
 244          }
 245  
 246      }
 247  
 248      /**
 249       * Check if a complete tag is found in the cached data, which usually happens
 250       * when the end of the tag has only just been loaded into the cache.
 251       *
 252       * @param string $tagname Name of tag to look for
 253       * @param string $latestline The very last line in the cache (used for speeding up the match)
 254       * @return bool|string false, or the contents of the tag (including start and end).
 255       */
 256      protected function full_tag_found_in_cache($tagname, $latestline) {
 257          // Return entire element if found. Otherwise return false.
 258          if (strpos(strtolower($latestline), '</'.strtolower($tagname).'>') === false) {
 259              return false;
 260          } else if (preg_match('{(<'.$tagname.'\b.*?>.*?</'.$tagname.'>)}is', $this->xmlcache, $matches)) {
 261              return $matches[1];
 262          } else {
 263              return false;
 264          }
 265      }
 266  
 267      /**
 268       * Remove complete tag from the cached data (including all its contents) - so
 269       * that the cache doesn't grow to unmanageable size
 270       *
 271       * @param string $tagname Name of tag to look for
 272       */
 273      protected function remove_tag_from_cache($tagname) {
 274          // Trim the cache so we're not in danger of running out of memory.
 275          // "1" so that we replace only the FIRST instance.
 276          $this->xmlcache = trim(preg_replace('{<'.$tagname.'\b.*?>.*?</'.$tagname.'>}is', '', $this->xmlcache, 1));
 277      }
 278  
 279      /**
 280       * Very simple convenience function to return the "recstatus" found in person/group/role tags.
 281       * 1=Add, 2=Update, 3=Delete, as specified by IMS, and we also use 0 to indicate "unspecified".
 282       *
 283       * @param string $tagdata the tag XML data
 284       * @param string $tagname the name of the tag we're interested in
 285       * @return int recstatus value
 286       */
 287      protected static function get_recstatus($tagdata, $tagname) {
 288          if (preg_match('{<'.$tagname.'\b[^>]*recstatus\s*=\s*["\'](\d)["\']}is', $tagdata, $matches)) {
 289              return intval($matches[1]);
 290          } else {
 291              return 0; // Unspecified.
 292          }
 293      }
 294  
 295      /**
 296       * Process the group tag. This defines a Moodle course.
 297       *
 298       * @param string $tagcontents The raw contents of the XML element
 299       */
 300      protected function process_group_tag($tagcontents) {
 301          global $DB, $CFG;
 302  
 303          // Get configs.
 304          $truncatecoursecodes    = $this->get_config('truncatecoursecodes');
 305          $createnewcourses       = $this->get_config('createnewcourses');
 306          $updatecourses          = $this->get_config('updatecourses');
 307  
 308          if ($createnewcourses) {
 309              require_once("$CFG->dirroot/course/lib.php");
 310          }
 311  
 312          // Process tag contents.
 313          $group = new stdClass();
 314          if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)) {
 315              $group->coursecode = trim($matches[1]);
 316          }
 317  
 318          $matches = array();
 319          if (preg_match('{<description>.*?<long>(.*?)</long>.*?</description>}is', $tagcontents, $matches)) {
 320              $group->long = trim($matches[1]);
 321          }
 322  
 323          $matches = array();
 324          if (preg_match('{<description>.*?<short>(.*?)</short>.*?</description>}is', $tagcontents, $matches)) {
 325              $group->short = trim($matches[1]);
 326          }
 327  
 328          $matches = array();
 329          if (preg_match('{<description>.*?<full>(.*?)</full>.*?</description>}is', $tagcontents, $matches)) {
 330              $group->full = trim($matches[1]);
 331          }
 332  
 333          if (preg_match('{<org>(.*?)</org>}is', $tagcontents, $matchesorg)) {
 334              if (preg_match_all('{<orgunit>(.*?)</orgunit>}is', $matchesorg[1], $matchesorgunit)) {
 335                  $group->categories = array_map('trim', $matchesorgunit[1]);
 336              }
 337          }
 338  
 339          $recstatus = ($this->get_recstatus($tagcontents, 'group'));
 340  
 341          if (empty($group->coursecode)) {
 342              $this->log_line('Error: Unable to find course code in \'group\' element.');
 343          } else {
 344              // First, truncate the course code if desired.
 345              if (intval($truncatecoursecodes) > 0) {
 346                  $group->coursecode = ($truncatecoursecodes > 0)
 347                      ? substr($group->coursecode, 0, intval($truncatecoursecodes))
 348                      : $group->coursecode;
 349              }
 350  
 351              // For compatibility with the (currently inactive) course aliasing, we need this to be an array.
 352              $group->coursecode = array($group->coursecode);
 353  
 354              // Third, check if the course(s) exist.
 355              foreach ($group->coursecode as $coursecode) {
 356                  $coursecode = trim($coursecode);
 357                  $dbcourse = $DB->get_record('course', array('idnumber' => $coursecode));
 358                  if (!$dbcourse) {
 359                      if (!$createnewcourses) {
 360                          $this->log_line("Course $coursecode not found in Moodle's course idnumbers.");
 361                      } else {
 362  
 363                          // Create the (hidden) course(s) if not found.
 364                          $courseconfig = get_config('moodlecourse'); // Load Moodle Course shell defaults.
 365  
 366                          // New course.
 367                          $course = new stdClass();
 368                          foreach ($this->coursemappings as $courseattr => $imsname) {
 369  
 370                              if ($imsname == 'ignore') {
 371                                  continue;
 372                              }
 373  
 374                              // Check if the IMS file contains the mapped tag, otherwise fallback on coursecode.
 375                              if ($imsname == 'coursecode') {
 376                                  $course->{$courseattr} = $coursecode;
 377                              } else if (!empty($group->{$imsname})) {
 378                                  $course->{$courseattr} = $group->{$imsname};
 379                              } else {
 380                                  $this->log_line('No ' . $imsname . ' description tag found for '
 381                                      .$coursecode . ' coursecode, using ' . $coursecode . ' instead');
 382                                  $course->{$courseattr} = $coursecode;
 383                              }
 384                          }
 385  
 386                          $course->idnumber = $coursecode;
 387                          $course->format = $courseconfig->format;
 388                          $course->visible = $courseconfig->visible;
 389                          $course->newsitems = $courseconfig->newsitems;
 390                          $course->showgrades = $courseconfig->showgrades;
 391                          $course->showreports = $courseconfig->showreports;
 392                          $course->maxbytes = $courseconfig->maxbytes;
 393                          $course->groupmode = $courseconfig->groupmode;
 394                          $course->groupmodeforce = $courseconfig->groupmodeforce;
 395                          $course->enablecompletion = $courseconfig->enablecompletion;
 396                          // Insert default names for teachers/students, from the current language.
 397  
 398                          // Handle course categorisation (taken from the group.org.orgunit or group.org.id fields if present).
 399                          $course->category = $this->get_category_from_group($group->categories);
 400  
 401                          $course->startdate = time();
 402                          // Choose a sort order that puts us at the start of the list!
 403                          $course->sortorder = 0;
 404  
 405                          $course = create_course($course);
 406  
 407                          $this->log_line("Created course $coursecode in Moodle (Moodle ID is $course->id)");
 408                      }
 409                  } else if (($recstatus == self::IMSENTERPRISE_UPDATE) && $dbcourse) {
 410                      if ($updatecourses) {
 411                          // Update course. Allowed fields to be updated are:
 412                          // Short Name, and Full Name.
 413                          $hasupdates = false;
 414                          if (!empty($group->short)) {
 415                              if ($group->short != $dbcourse->shortname) {
 416                                  $dbcourse->shortname = $group->short;
 417                                  $hasupdates = true;
 418                              }
 419                          }
 420                          if (!empty($group->full)) {
 421                              if ($group->full != $dbcourse->fullname) {
 422                                  $dbcourse->fullname = $group->full;
 423                                  $hasupdates = true;
 424                              }
 425                          }
 426                          if ($hasupdates) {
 427                              update_course($dbcourse);
 428                              $courseid = $dbcourse->id;
 429                              $this->log_line("Updated course $coursecode in Moodle (Moodle ID is $courseid)");
 430                          }
 431                      } else {
 432                          // Update courses option is not enabled. Ignore.
 433                          $this->log_line("Ignoring update to course $coursecode");
 434                      }
 435                  } else if (($recstatus == self::IMSENTERPRISE_DELETE) && $dbcourse) {
 436                      // If course does exist, but recstatus==3 (delete), then set the course as hidden.
 437                      $courseid = $dbcourse->id;
 438                      $show = false;
 439                      course_change_visibility($courseid, $show);
 440                      $this->log_line("Updated (set to hidden) course $coursecode in Moodle (Moodle ID is $courseid)");
 441                  }
 442              }
 443          }
 444      }
 445  
 446      /**
 447       * Process the person tag. This defines a Moodle user.
 448       *
 449       * @param string $tagcontents The raw contents of the XML element
 450       */
 451      protected function process_person_tag($tagcontents) {
 452          global $CFG, $DB;
 453  
 454          // Get plugin configs.
 455          $imssourcedidfallback   = $this->get_config('imssourcedidfallback');
 456          $fixcaseusernames       = $this->get_config('fixcaseusernames');
 457          $fixcasepersonalnames   = $this->get_config('fixcasepersonalnames');
 458          $imsdeleteusers         = $this->get_config('imsdeleteusers');
 459          $createnewusers         = $this->get_config('createnewusers');
 460          $imsupdateusers         = $this->get_config('imsupdateusers');
 461  
 462          $person = new stdClass();
 463          if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)) {
 464              $person->idnumber = trim($matches[1]);
 465          }
 466  
 467          $matches = array();
 468          if (preg_match('{<name>.*?<n>.*?<given>(.+?)</given>.*?</n>.*?</name>}is', $tagcontents, $matches)) {
 469              $person->firstname = trim($matches[1]);
 470          }
 471  
 472          $matches = array();
 473          if (preg_match('{<name>.*?<n>.*?<family>(.+?)</family>.*?</n>.*?</name>}is', $tagcontents, $matches)) {
 474              $person->lastname = trim($matches[1]);
 475          }
 476  
 477          $matches = array();
 478          if (preg_match('{<userid.*?>(.*?)</userid>}is', $tagcontents, $matches)) {
 479              $person->username = trim($matches[1]);
 480          }
 481  
 482          $matches = array();
 483          if (preg_match('{<userid\s+authenticationtype\s*=\s*"*(.+?)"*>.*?</userid>}is', $tagcontents, $matches)) {
 484              $person->auth = trim($matches[1]);
 485          }
 486  
 487          if ($imssourcedidfallback && trim($person->username) == '') {
 488              // This is the point where we can fall back to useing the "sourcedid" if "userid" is not supplied.
 489              // NB We don't use an "elseif" because the tag may be supplied-but-empty.
 490              $person->username = $person->idnumber;
 491          }
 492  
 493          $matches = array();
 494          if (preg_match('{<email>(.*?)</email>}is', $tagcontents, $matches)) {
 495              $person->email = trim($matches[1]);
 496          }
 497  
 498          $matches = array();
 499          if (preg_match('{<url>(.*?)</url>}is', $tagcontents, $matches)) {
 500              $person->url = trim($matches[1]);
 501          }
 502  
 503          $matches = array();
 504          if (preg_match('{<adr>.*?<locality>(.+?)</locality>.*?</adr>}is', $tagcontents, $matches)) {
 505              $person->city = trim($matches[1]);
 506          }
 507  
 508          $matches = array();
 509          if (preg_match('{<adr>.*?<country>(.+?)</country>.*?</adr>}is', $tagcontents, $matches)) {
 510              $person->country = trim($matches[1]);
 511          }
 512  
 513          // Fix case of some of the fields if required.
 514          if ($fixcaseusernames && isset($person->username)) {
 515              $person->username = strtolower($person->username);
 516          }
 517          if ($fixcasepersonalnames) {
 518              if (isset($person->firstname)) {
 519                  $person->firstname = ucwords(strtolower($person->firstname));
 520              }
 521              if (isset($person->lastname)) {
 522                  $person->lastname = ucwords(strtolower($person->lastname));
 523              }
 524          }
 525  
 526          $recstatus = ($this->get_recstatus($tagcontents, 'person'));
 527  
 528          // Now if the recstatus is 3, we should delete the user if-and-only-if the setting for delete users is turned on.
 529          if ($recstatus == self::IMSENTERPRISE_DELETE) {
 530  
 531              if ($imsdeleteusers) { // If we're allowed to delete user records.
 532                  // Do not dare to hack the user.deleted field directly in database!!!
 533                  $params = array('username' => $person->username, 'mnethostid' => $CFG->mnet_localhost_id, 'deleted' => 0);
 534                  if ($user = $DB->get_record('user', $params)) {
 535                      if (delete_user($user)) {
 536                          $this->log_line("Deleted user '$person->username' (ID number $person->idnumber).");
 537                      } else {
 538                          $this->log_line("Error deleting '$person->username' (ID number $person->idnumber).");
 539                      }
 540                  } else {
 541                      $this->log_line("Can not delete user '$person->username' (ID number $person->idnumber) - user does not exist.");
 542                  }
 543              } else {
 544                  $this->log_line("Ignoring deletion request for user '$person->username' (ID number $person->idnumber).");
 545              }
 546          } else if ($recstatus == self::IMSENTERPRISE_UPDATE) { // Update user.
 547              if ($imsupdateusers) {
 548                  if ($id = $DB->get_field('user', 'id', array('idnumber' => $person->idnumber))) {
 549                      $person->id = $id;
 550                      $DB->update_record('user', $person);
 551                      $this->log_line("Updated user $person->username");
 552                  } else {
 553                      $this->log_line("Ignoring update request for non-existent user $person->username");
 554                  }
 555              } else {
 556                  $this->log_line("Ignoring update request for user $person->username");
 557              }
 558  
 559          } else { // Add or update record.
 560  
 561              // If the user exists (matching sourcedid) then we don't need to do anything.
 562              if (!$DB->get_field('user', 'id', array('idnumber' => $person->idnumber)) && $createnewusers) {
 563                  // If they don't exist and haven't a defined username, we log this as a potential problem.
 564                  if ((!isset($person->username)) || (strlen($person->username) == 0)) {
 565                      $this->log_line("Cannot create new user for ID # $person->idnumber".
 566                          "- no username listed in IMS data for this person.");
 567                  } else if ($DB->get_field('user', 'id', array('username' => $person->username))) {
 568                      // If their idnumber is not registered but their user ID is, then add their idnumber to their record.
 569                      $DB->set_field('user', 'idnumber', $person->idnumber, array('username' => $person->username));
 570                  } else {
 571  
 572                      // If they don't exist and they have a defined username, and $createnewusers == true, we create them.
 573                      $person->lang = $CFG->lang;
 574                      // TODO: MDL-15863 this needs more work due to multiauth changes, use first auth for now.
 575                      if (empty($person->auth)) {
 576                          $auth = explode(',', $CFG->auth);
 577                          $auth = reset($auth);
 578                          $person->auth = $auth;
 579                      }
 580                      $person->confirmed = 1;
 581                      $person->timemodified = time();
 582                      $person->mnethostid = $CFG->mnet_localhost_id;
 583                      $id = $DB->insert_record('user', $person);
 584                      $this->log_line("Created user record ('.$id.') for user '$person->username' (ID number $person->idnumber).");
 585                  }
 586              } else if ($createnewusers) {
 587  
 588                  $username = $person->username ?? "[unknown username]";
 589                  $personnumber = $person->idnumber ?? "[unknown ID number]";
 590  
 591                  $this->log_line("User record already exists for user '" . $username . "' (ID number " . $personnumber . ").");
 592  
 593                  // It is totally wrong to mess with deleted users flag directly in database!!!
 594                  // There is no official way to undelete user, sorry..
 595              } else {
 596                  $this->log_line("No user record found for '$person->username' (ID number $person->idnumber).");
 597              }
 598  
 599          }
 600  
 601      }
 602  
 603      /**
 604       * Process the membership tag. This defines whether the specified Moodle users
 605       * should be added/removed as teachers/students.
 606       *
 607       * @param string $tagcontents The raw contents of the XML element
 608       */
 609      protected function process_membership_tag($tagcontents) {
 610          global $DB;
 611  
 612          // Get plugin configs.
 613          $truncatecoursecodes = $this->get_config('truncatecoursecodes');
 614          $imscapitafix = $this->get_config('imscapitafix');
 615  
 616          $memberstally = 0;
 617          $membersuntally = 0;
 618  
 619          // In order to reduce the number of db queries required, group name/id associations are cached in this array.
 620          $groupids = array();
 621  
 622          $ship = new stdClass();
 623  
 624          if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)) {
 625              $ship->coursecode = ($truncatecoursecodes > 0)
 626                  ? substr(trim($matches[1]), 0, intval($truncatecoursecodes))
 627                  : trim($matches[1]);
 628              $ship->courseid = $DB->get_field('course', 'id', array('idnumber' => $ship->coursecode));
 629          }
 630          if ($ship->courseid && preg_match_all('{<member>(.*?)</member>}is', $tagcontents, $membermatches, PREG_SET_ORDER)) {
 631              $courseobj = new stdClass();
 632              $courseobj->id = $ship->courseid;
 633  
 634              foreach ($membermatches as $mmatch) {
 635                  $member = new stdClass();
 636                  $memberstoreobj = new stdClass();
 637                  $matches = array();
 638                  if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $mmatch[1], $matches)) {
 639                      $member->idnumber = trim($matches[1]);
 640                  }
 641  
 642                  $matches = array();
 643                  if (preg_match('{<role\s+roletype=["\'](.+?)["\'].*?>}is', $mmatch[1], $matches)) {
 644                      // 01 means Student, 02 means Instructor, 3 means ContentDeveloper, and there are more besides.
 645                      $member->roletype = trim($matches[1]);
 646                  } else if ($imscapitafix && preg_match('{<roletype>(.+?)</roletype>}is', $mmatch[1], $matches)) {
 647                      // The XML that comes out of Capita Student Records seems to contain a misinterpretation of
 648                      // the IMS specification! 01 means Student, 02 means Instructor, 3 means ContentDeveloper,
 649                      // and there are more besides.
 650                      $member->roletype = trim($matches[1]);
 651                  }
 652  
 653                  $matches = array();
 654                  if (preg_match('{<role\b.*?<status>(.+?)</status>.*?</role>}is', $mmatch[1], $matches)) {
 655                      // 1 means active, 0 means inactive - treat this as enrol vs unenrol.
 656                      $member->status = trim($matches[1]);
 657                  }
 658  
 659                  $recstatus = ($this->get_recstatus($mmatch[1], 'role'));
 660                  if ($recstatus == self::IMSENTERPRISE_DELETE) {
 661                      // See above - recstatus of 3 (==delete) is treated the same as status of 0.
 662                      $member->status = 0;
 663                  }
 664  
 665                  $timeframe = new stdClass();
 666                  $timeframe->begin = 0;
 667                  $timeframe->end = 0;
 668                  $matches = array();
 669                  if (preg_match('{<role\b.*?<timeframe>(.+?)</timeframe>.*?</role>}is', $mmatch[1], $matches)) {
 670                      $timeframe = $this->decode_timeframe($matches[1]);
 671                  }
 672  
 673                  $matches = array();
 674                  if (preg_match('{<role\b.*?<extension>.*?<cohort>(.+?)</cohort>.*?</extension>.*?</role>}is',
 675                          $mmatch[1], $matches)) {
 676                      $member->groupname = trim($matches[1]);
 677                      // The actual processing (ensuring a group record exists, etc) occurs below, in the enrol-a-student clause.
 678                  }
 679  
 680                  // Add or remove this student or teacher to the course...
 681                  $memberstoreobj->userid = $DB->get_field('user', 'id', array('idnumber' => $member->idnumber));
 682                  $memberstoreobj->enrol = 'imsenterprise';
 683                  $memberstoreobj->course = $ship->courseid;
 684                  $memberstoreobj->time = time();
 685                  $memberstoreobj->timemodified = time();
 686                  if ($memberstoreobj->userid) {
 687  
 688                      // Decide the "real" role (i.e. the Moodle role) that this user should be assigned to.
 689                      // Zero means this roletype is supposed to be skipped.
 690                      $moodleroleid = (isset($member->roletype) && isset($this->rolemappings[$member->roletype]))
 691                          ? $this->rolemappings[$member->roletype] : null;
 692                      if (!$moodleroleid) {
 693                          $this->log_line("SKIPPING role " .
 694                              ($member->roletype ?? "[]") . " for $memberstoreobj->userid " .
 695                              "($member->idnumber) in course $memberstoreobj->course");
 696                          continue;
 697                      }
 698  
 699                      if (intval($member->status) == 1) {
 700                          // Enrol the member.
 701  
 702                          $einstance = $DB->get_record('enrol',
 703                              array('courseid' => $courseobj->id, 'enrol' => $memberstoreobj->enrol));
 704                          if (empty($einstance)) {
 705                              // Only add an enrol instance to the course if non-existent.
 706                              $enrolid = $this->add_instance($courseobj);
 707                              $einstance = $DB->get_record('enrol', array('id' => $enrolid));
 708                          }
 709  
 710                          $this->enrol_user($einstance, $memberstoreobj->userid, $moodleroleid, $timeframe->begin, $timeframe->end);
 711  
 712                          $this->log_line("Enrolled user #$memberstoreobj->userid ($member->idnumber) "
 713                              ."to role $member->roletype in course $memberstoreobj->course");
 714                          $memberstally++;
 715  
 716                          // At this point we can also ensure the group membership is recorded if present.
 717                          if (isset($member->groupname)) {
 718                              // Create the group if it doesn't exist - either way, make sure we know the group ID.
 719                              if (isset($groupids[$member->groupname])) {
 720                                  $member->groupid = $groupids[$member->groupname]; // Recall the group ID from cache if available.
 721                              } else {
 722                                  $params = array('courseid' => $ship->courseid, 'name' => $member->groupname);
 723                                  if ($groupid = $DB->get_field('groups', 'id', $params)) {
 724                                      $member->groupid = $groupid;
 725                                      $groupids[$member->groupname] = $groupid; // Store ID in cache.
 726                                  } else {
 727                                      // Attempt to create the group.
 728                                      $group = new stdClass();
 729                                      $group->name = $member->groupname;
 730                                      $group->courseid = $ship->courseid;
 731                                      $group->timecreated = time();
 732                                      $group->timemodified = time();
 733                                      $groupid = $DB->insert_record('groups', $group);
 734                                      $this->log_line('Added a new group for this course: '.$group->name);
 735                                      $groupids[$member->groupname] = $groupid; // Store ID in cache.
 736                                      $member->groupid = $groupid;
 737                                      // Invalidate the course group data cache just in case.
 738                                      cache_helper::invalidate_by_definition('core', 'groupdata', array(), array($ship->courseid));
 739                                  }
 740                              }
 741                              // Add the user-to-group association if it doesn't already exist.
 742                              if ($member->groupid) {
 743                                  groups_add_member($member->groupid, $memberstoreobj->userid,
 744                                      'enrol_imsenterprise', $einstance->id);
 745                              }
 746                          }
 747  
 748                      } else if ($this->get_config('imsunenrol')) {
 749                          // Unenrol member.
 750                          $unenrolsetting = $this->get_config('unenrolaction');
 751  
 752                          $einstances = $DB->get_records('enrol',
 753                              array('enrol' => $memberstoreobj->enrol, 'courseid' => $courseobj->id));
 754  
 755                          switch ($unenrolsetting) {
 756                              case ENROL_EXT_REMOVED_SUSPEND:
 757                              case ENROL_EXT_REMOVED_SUSPENDNOROLES: {
 758                                  foreach ($einstances as $einstance) {
 759                                      $this->update_user_enrol($einstance, $memberstoreobj->userid,
 760                                      ENROL_USER_SUSPENDED, $timeframe->begin, $timeframe->end);
 761  
 762                                      $this->log_line("Suspending user enrolment for $member->idnumber in " .
 763                                      " course $ship->coursecode ");
 764  
 765                                      if (intval($unenrolsetting) === intval(ENROL_EXT_REMOVED_SUSPENDNOROLES)) {
 766                                          if (!$context =
 767                                              context_course::instance($courseobj->id, IGNORE_MISSING)) {
 768  
 769                                              $this->log_line("Unable to process IMS unenrolment request " .
 770                                                  " because course context not found. User: " .
 771                                                  "#$memberstoreobj->userid ($member->idnumber) , " .
 772                                                  " course: $memberstoreobj->course");
 773                                          } else {
 774  
 775                                              role_unassign_all([
 776                                                  'contextid' => $context->id,
 777                                                  'userid' => $memberstoreobj->userid,
 778                                                  'component' => 'enrol_imsenterprise',
 779                                                  'itemid' => $einstance->id
 780                                              ]);
 781  
 782                                              $this->log_line("Removing role assignments for user " .
 783                                                  "$member->idnumber from role $moodleroleid in course " .
 784                                                  "$ship->coursecode ");
 785                                          }
 786                                      }
 787                                  }
 788                              }
 789                              break;
 790  
 791                              case ENROL_EXT_REMOVED_UNENROL: {
 792                                  foreach ($einstances as $einstance) {
 793                                      $this->unenrol_user($einstance, $memberstoreobj->userid);
 794                                      $this->log_line("Removing user enrolment record for $member->idnumber " .
 795                                          " in course $ship->coursecode ");
 796                                  }
 797                              }
 798                              break;
 799  
 800                              case ENROL_EXT_REMOVED_KEEP: {
 801                                  $this->log_line("Processed KEEP IMS unenrol instruction (i.e. do nothing)");
 802                              }
 803                              break;
 804  
 805                              default:
 806                                  $this->log_line("Unable to process IMS unenrolment request because " .
 807                                      " the value set for plugin parameter, unenrol action, is not recognised. " .
 808                                      " User: #$memberstoreobj->userid ($member->idnumber) " .
 809                                      " , course: $memberstoreobj->course");
 810                                  break;
 811                          }
 812  
 813                          $membersuntally++;
 814                      }
 815  
 816                  }
 817              }
 818              $this->log_line("Added $memberstally users to course $ship->coursecode");
 819              if ($membersuntally > 0) {
 820                  $this->log_line("Processed $membersuntally unenrol instructions for course $ship->coursecode");
 821              }
 822          }
 823  
 824      } // End process_membership_tag().
 825  
 826      /**
 827       * Process the properties tag. The only data from this element
 828       * that is relevant is whether a <target> is specified.
 829       *
 830       * @param string $tagcontents The raw contents of the XML element
 831       */
 832      protected function process_properties_tag($tagcontents) {
 833          $imsrestricttarget = $this->get_config('imsrestricttarget');
 834  
 835          if ($imsrestricttarget) {
 836              if (!(preg_match('{<target>'.preg_quote($imsrestricttarget).'</target>}is', $tagcontents, $matches))) {
 837                  $this->log_line("Skipping processing: required target \"$imsrestricttarget\" not specified in this data.");
 838                  $this->continueprocessing = false;
 839              }
 840          }
 841      }
 842  
 843      /**
 844       * Store logging information. This does two things: uses the {@link mtrace()}
 845       * function to print info to screen/STDOUT, and also writes log to a text file
 846       * if a path has been specified.
 847       * @param string $string Text to write (newline will be added automatically)
 848       */
 849      protected function log_line($string) {
 850  
 851          if (!PHPUNIT_TEST) {
 852              mtrace($string);
 853          }
 854          if ($this->logfp) {
 855              fwrite($this->logfp, $string . "\n");
 856          }
 857      }
 858  
 859      /**
 860       * Process the INNER contents of a <timeframe> tag, to return beginning/ending dates.
 861       *
 862       * @param string $string tag to decode.
 863       * @return stdClass beginning and/or ending is returned, in unix time, zero indicating not specified.
 864       */
 865      protected static function decode_timeframe($string) {
 866          $ret = new stdClass();
 867          $ret->begin = $ret->end = 0;
 868          // Explanatory note: The matching will ONLY match if the attribute restrict="1"
 869          // because otherwise the time markers should be ignored (participation should be
 870          // allowed outside the period).
 871          if (preg_match('{<begin\s+restrict="1">(\d\d\d\d)-(\d\d)-(\d\d)</begin>}is', $string, $matches)) {
 872              $ret->begin = mktime(0, 0, 0, $matches[2], $matches[3], $matches[1]);
 873          }
 874  
 875          $matches = array();
 876          if (preg_match('{<end\s+restrict="1">(\d\d\d\d)-(\d\d)-(\d\d)</end>}is', $string, $matches)) {
 877              $ret->end = mktime(0, 0, 0, $matches[2], $matches[3], $matches[1]);
 878          }
 879          return $ret;
 880      }
 881  
 882      /**
 883       * Load the role mappings (from the config), so we can easily refer to
 884       * how an IMS-E role corresponds to a Moodle role
 885       */
 886      protected function load_role_mappings() {
 887          require_once ('locallib.php');
 888  
 889          $imsroles = new imsenterprise_roles();
 890          $imsroles = $imsroles->get_imsroles();
 891  
 892          $this->rolemappings = array();
 893          foreach ($imsroles as $imsrolenum => $imsrolename) {
 894              $this->rolemappings[$imsrolenum] = $this->rolemappings[$imsrolename] = $this->get_config('imsrolemap' . $imsrolenum);
 895          }
 896      }
 897  
 898      /**
 899       * Load the name mappings (from the config), so we can easily refer to
 900       * how an IMS-E course properties corresponds to a Moodle course properties
 901       */
 902      protected function load_course_mappings() {
 903          require_once ('locallib.php');
 904  
 905          $imsnames = new imsenterprise_courses();
 906          $courseattrs = $imsnames->get_courseattrs();
 907  
 908          $this->coursemappings = array();
 909          foreach ($courseattrs as $courseattr) {
 910              $this->coursemappings[$courseattr] = $this->get_config('imscoursemap' . $courseattr);
 911          }
 912      }
 913  
 914      /**
 915       * Get the default category id (often known as 'Miscellaneous'),
 916       * statically cached to avoid multiple DB lookups on big imports.
 917       *
 918       * @return int id of default category.
 919       */
 920      private function get_default_category_id() {
 921          global $CFG;
 922  
 923          if ($this->defaultcategoryid === null) {
 924              $category = core_course_category::get_default();
 925              $this->defaultcategoryid = $category->id;
 926          }
 927  
 928          return $this->defaultcategoryid;
 929      }
 930  
 931      /**
 932       * Find the category using idnumber or name.
 933       *
 934       * @param array $categories List of categories
 935       *
 936       * @return int id of category found.
 937       */
 938      private function get_category_from_group($categories) {
 939          global $DB;
 940  
 941          if (empty($categories)) {
 942              $catid = $this->get_default_category_id();
 943          } else {
 944              $createnewcategories = $this->get_config('createnewcategories');
 945              $categoryseparator = trim($this->get_config('categoryseparator'));
 946              $nestedcategories = trim($this->get_config('nestedcategories'));
 947              $searchbyidnumber = trim($this->get_config('categoryidnumber'));
 948  
 949              if (!empty($categoryseparator)) {
 950                  $sep = '{\\'.$categoryseparator.'}';
 951              }
 952  
 953              $catid = 0;
 954              $fullnestedcatname = '';
 955  
 956              foreach ($categories as $categoryinfo) {
 957                  if ($searchbyidnumber) {
 958                      $values = preg_split($sep, $categoryinfo, -1, PREG_SPLIT_NO_EMPTY);
 959                      if (count($values) < 2) {
 960                          $this->log_line('Category ' . $categoryinfo . ' missing name or idnumber. Using default category instead.');
 961                          $catid = $this->get_default_category_id();
 962                          break;
 963                      }
 964                      $categoryname = $values[0];
 965                      $categoryidnumber = $values[1];
 966                  } else {
 967                      $categoryname = $categoryinfo;
 968                      $categoryidnumber = null;
 969                      if (empty($categoryname)) {
 970                          $this->log_line('Category ' . $categoryinfo . ' missing name. Using default category instead.');
 971                          $catid = $this->get_default_category_id();
 972                          break;
 973                      }
 974                  }
 975  
 976                  if (!empty($fullnestedcatname)) {
 977                      $fullnestedcatname .= ' / ';
 978                  }
 979  
 980                  $fullnestedcatname .= $categoryname;
 981                  $parentid = $catid;
 982  
 983                  // Check if category exist.
 984                  $params = array();
 985                  if ($searchbyidnumber) {
 986                      $params['idnumber'] = $categoryidnumber;
 987                  } else {
 988                      $params['name'] = $categoryname;
 989                  }
 990                  if ($nestedcategories) {
 991                      $params['parent'] = $parentid;
 992                  }
 993  
 994                  if ($catid = $DB->get_field('course_categories', 'id', $params)) {
 995                      continue; // This category already exists.
 996                  }
 997  
 998                  // If we're allowed to create new categories, let's create this one.
 999                  if ($createnewcategories) {
1000                      $newcat = new stdClass();
1001                      $newcat->name = $categoryname;
1002                      $newcat->visible = 0;
1003                      $newcat->parent = $parentid;
1004                      $newcat->idnumber = $categoryidnumber;
1005                      $newcat = core_course_category::create($newcat);
1006                      $catid = $newcat->id;
1007                      $this->log_line("Created new (hidden) category '$fullnestedcatname'");
1008                  } else {
1009                      // If not found and not allowed to create, stick with default.
1010                      $this->log_line('Category ' . $categoryinfo . ' not found in Moodle database. Using default category instead.');
1011                      $catid = $this->get_default_category_id();
1012                      break;
1013                  }
1014              }
1015          }
1016  
1017          return $catid;
1018      }
1019  
1020      /**
1021       * Is it possible to delete enrol instance via standard UI?
1022       *
1023       * @param object $instance
1024       * @return bool
1025       */
1026      public function can_delete_instance($instance) {
1027          $context = context_course::instance($instance->courseid);
1028          return has_capability('enrol/imsenterprise:config', $context);
1029      }
1030  
1031      /**
1032       * Is it possible to hide/show enrol instance via standard UI?
1033       *
1034       * @param stdClass $instance
1035       * @return bool
1036       */
1037      public function can_hide_show_instance($instance) {
1038          $context = context_course::instance($instance->courseid);
1039          return has_capability('enrol/imsenterprise:config', $context);
1040      }
1041  }
1042  
1043  /**
1044   * Called whenever anybody tries (from the normal interface) to remove a group
1045   * member which is registered as being created by this component. (Not called
1046   * when deleting an entire group or course at once.)
1047   * @param int $itemid Item ID that was stored in the group_members entry
1048   * @param int $groupid Group ID
1049   * @param int $userid User ID being removed from group
1050   * @return bool True if the remove is permitted, false to give an error
1051   */
1052  function enrol_imsenterprise_allow_group_member_remove($itemid, $groupid, $userid) {
1053      return false;
1054  }