Search moodle.org's
Developer Documentation

  • 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 37 and 311] [Versions 38 and 311] [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   * 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                  $this->log_line("User record already exists for user '$person->username' (ID number $person->idnumber).");
     588  
     589                  // It is totally wrong to mess with deleted users flag directly in database!!!
     590                  // There is no official way to undelete user, sorry..
     591              } else {
     592                  $this->log_line("No user record found for '$person->username' (ID number $person->idnumber).");
     593              }
     594  
     595          }
     596  
     597      }
     598  
     599      /**
     600       * Process the membership tag. This defines whether the specified Moodle users
     601       * should be added/removed as teachers/students.
     602       *
     603       * @param string $tagcontents The raw contents of the XML element
     604       */
     605      protected function process_membership_tag($tagcontents) {
     606          global $DB;
     607  
     608          // Get plugin configs.
     609          $truncatecoursecodes = $this->get_config('truncatecoursecodes');
     610          $imscapitafix = $this->get_config('imscapitafix');
     611  
     612          $memberstally = 0;
     613          $membersuntally = 0;
     614  
     615          // In order to reduce the number of db queries required, group name/id associations are cached in this array.
     616          $groupids = array();
     617  
     618          $ship = new stdClass();
     619  
     620          if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)) {
     621              $ship->coursecode = ($truncatecoursecodes > 0)
     622                  ? substr(trim($matches[1]), 0, intval($truncatecoursecodes))
     623                  : trim($matches[1]);
     624              $ship->courseid = $DB->get_field('course', 'id', array('idnumber' => $ship->coursecode));
     625          }
     626          if ($ship->courseid && preg_match_all('{<member>(.*?)</member>}is', $tagcontents, $membermatches, PREG_SET_ORDER)) {
     627              $courseobj = new stdClass();
     628              $courseobj->id = $ship->courseid;
     629  
     630              foreach ($membermatches as $mmatch) {
     631                  $member = new stdClass();
     632                  $memberstoreobj = new stdClass();
     633                  $matches = array();
     634                  if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $mmatch[1], $matches)) {
     635                      $member->idnumber = trim($matches[1]);
     636                  }
     637  
     638                  $matches = array();
     639                  if (preg_match('{<role\s+roletype=["\'](.+?)["\'].*?>}is', $mmatch[1], $matches)) {
     640                      // 01 means Student, 02 means Instructor, 3 means ContentDeveloper, and there are more besides.
     641                      $member->roletype = trim($matches[1]);
     642                  } else if ($imscapitafix && preg_match('{<roletype>(.+?)</roletype>}is', $mmatch[1], $matches)) {
     643                      // The XML that comes out of Capita Student Records seems to contain a misinterpretation of
     644                      // the IMS specification! 01 means Student, 02 means Instructor, 3 means ContentDeveloper,
     645                      // and there are more besides.
     646                      $member->roletype = trim($matches[1]);
     647                  }
     648  
     649                  $matches = array();
     650                  if (preg_match('{<role\b.*?<status>(.+?)</status>.*?</role>}is', $mmatch[1], $matches)) {
     651                      // 1 means active, 0 means inactive - treat this as enrol vs unenrol.
     652                      $member->status = trim($matches[1]);
     653                  }
     654  
     655                  $recstatus = ($this->get_recstatus($mmatch[1], 'role'));
     656                  if ($recstatus == self::IMSENTERPRISE_DELETE) {
     657                      // See above - recstatus of 3 (==delete) is treated the same as status of 0.
     658                      $member->status = 0;
     659                  }
     660  
     661                  $timeframe = new stdClass();
     662                  $timeframe->begin = 0;
     663                  $timeframe->end = 0;
     664                  $matches = array();
     665                  if (preg_match('{<role\b.*?<timeframe>(.+?)</timeframe>.*?</role>}is', $mmatch[1], $matches)) {
     666                      $timeframe = $this->decode_timeframe($matches[1]);
     667                  }
     668  
     669                  $matches = array();
     670                  if (preg_match('{<role\b.*?<extension>.*?<cohort>(.+?)</cohort>.*?</extension>.*?</role>}is',
     671                          $mmatch[1], $matches)) {
     672                      $member->groupname = trim($matches[1]);
     673                      // The actual processing (ensuring a group record exists, etc) occurs below, in the enrol-a-student clause.
     674                  }
     675  
     676                  // Add or remove this student or teacher to the course...
     677                  $memberstoreobj->userid = $DB->get_field('user', 'id', array('idnumber' => $member->idnumber));
     678                  $memberstoreobj->enrol = 'imsenterprise';
     679                  $memberstoreobj->course = $ship->courseid;
     680                  $memberstoreobj->time = time();
     681                  $memberstoreobj->timemodified = time();
     682                  if ($memberstoreobj->userid) {
     683  
     684                      // Decide the "real" role (i.e. the Moodle role) that this user should be assigned to.
     685                      // Zero means this roletype is supposed to be skipped.
     686                      $moodleroleid = $this->rolemappings[$member->roletype];
     687                      if (!$moodleroleid) {
     688                          $this->log_line("SKIPPING role $member->roletype for $memberstoreobj->userid "
     689                              ."($member->idnumber) in course $memberstoreobj->course");
     690                          continue;
     691                      }
     692  
     693                      if (intval($member->status) == 1) {
     694                          // Enrol the member.
     695  
     696                          $einstance = $DB->get_record('enrol',
     697                              array('courseid' => $courseobj->id, 'enrol' => $memberstoreobj->enrol));
     698                          if (empty($einstance)) {
     699                              // Only add an enrol instance to the course if non-existent.
     700                              $enrolid = $this->add_instance($courseobj);
     701                              $einstance = $DB->get_record('enrol', array('id' => $enrolid));
     702                          }
     703  
     704                          $this->enrol_user($einstance, $memberstoreobj->userid, $moodleroleid, $timeframe->begin, $timeframe->end);
     705  
     706                          $this->log_line("Enrolled user #$memberstoreobj->userid ($member->idnumber) "
     707                              ."to role $member->roletype in course $memberstoreobj->course");
     708                          $memberstally++;
     709  
     710                          // At this point we can also ensure the group membership is recorded if present.
     711                          if (isset($member->groupname)) {
     712                              // Create the group if it doesn't exist - either way, make sure we know the group ID.
     713                              if (isset($groupids[$member->groupname])) {
     714                                  $member->groupid = $groupids[$member->groupname]; // Recall the group ID from cache if available.
     715                              } else {
     716                                  $params = array('courseid' => $ship->courseid, 'name' => $member->groupname);
     717                                  if ($groupid = $DB->get_field('groups', 'id', $params)) {
     718                                      $member->groupid = $groupid;
     719                                      $groupids[$member->groupname] = $groupid; // Store ID in cache.
     720                                  } else {
     721                                      // Attempt to create the group.
     722                                      $group = new stdClass();
     723                                      $group->name = $member->groupname;
     724                                      $group->courseid = $ship->courseid;
     725                                      $group->timecreated = time();
     726                                      $group->timemodified = time();
     727                                      $groupid = $DB->insert_record('groups', $group);
     728                                      $this->log_line('Added a new group for this course: '.$group->name);
     729                                      $groupids[$member->groupname] = $groupid; // Store ID in cache.
     730                                      $member->groupid = $groupid;
     731                                      // Invalidate the course group data cache just in case.
     732                                      cache_helper::invalidate_by_definition('core', 'groupdata', array(), array($ship->courseid));
     733                                  }
     734                              }
     735                              // Add the user-to-group association if it doesn't already exist.
     736                              if ($member->groupid) {
     737                                  groups_add_member($member->groupid, $memberstoreobj->userid,
     738                                      'enrol_imsenterprise', $einstance->id);
     739                              }
     740                          }
     741  
     742                      } else if ($this->get_config('imsunenrol')) {
     743                          // Unenrol member.
     744  
     745                          $einstances = $DB->get_records('enrol',
     746                              array('enrol' => $memberstoreobj->enrol, 'courseid' => $courseobj->id));
     747                          foreach ($einstances as $einstance) {
     748                              // Unenrol the user from all imsenterprise enrolment instances.
     749                              $this->unenrol_user($einstance, $memberstoreobj->userid);
     750                          }
     751  
     752                          $membersuntally++;
     753                          $this->log_line("Unenrolled $member->idnumber from role $moodleroleid in course");
     754                      }
     755  
     756                  }
     757              }
     758              $this->log_line("Added $memberstally users to course $ship->coursecode");
     759              if ($membersuntally > 0) {
     760                  $this->log_line("Removed $membersuntally users from course $ship->coursecode");
     761              }
     762          }
     763      } // End process_membership_tag().
     764  
     765      /**
     766       * Process the properties tag. The only data from this element
     767       * that is relevant is whether a <target> is specified.
     768       *
     769       * @param string $tagcontents The raw contents of the XML element
     770       */
     771      protected function process_properties_tag($tagcontents) {
     772          $imsrestricttarget = $this->get_config('imsrestricttarget');
     773  
     774          if ($imsrestricttarget) {
     775              if (!(preg_match('{<target>'.preg_quote($imsrestricttarget).'</target>}is', $tagcontents, $matches))) {
     776                  $this->log_line("Skipping processing: required target \"$imsrestricttarget\" not specified in this data.");
     777                  $this->continueprocessing = false;
     778              }
     779          }
     780      }
     781  
     782      /**
     783       * Store logging information. This does two things: uses the {@link mtrace()}
     784       * function to print info to screen/STDOUT, and also writes log to a text file
     785       * if a path has been specified.
     786       * @param string $string Text to write (newline will be added automatically)
     787       */
     788      protected function log_line($string) {
     789  
     790          if (!PHPUNIT_TEST) {
     791              mtrace($string);
     792          }
     793          if ($this->logfp) {
     794              fwrite($this->logfp, $string . "\n");
     795          }
     796      }
     797  
     798      /**
     799       * Process the INNER contents of a <timeframe> tag, to return beginning/ending dates.
     800       *
     801       * @param string $string tag to decode.
     802       * @return stdClass beginning and/or ending is returned, in unix time, zero indicating not specified.
     803       */
     804      protected static function decode_timeframe($string) {
     805          $ret = new stdClass();
     806          $ret->begin = $ret->end = 0;
     807          // Explanatory note: The matching will ONLY match if the attribute restrict="1"
     808          // because otherwise the time markers should be ignored (participation should be
     809          // allowed outside the period).
     810          if (preg_match('{<begin\s+restrict="1">(\d\d\d\d)-(\d\d)-(\d\d)</begin>}is', $string, $matches)) {
     811              $ret->begin = mktime(0, 0, 0, $matches[2], $matches[3], $matches[1]);
     812          }
     813  
     814          $matches = array();
     815          if (preg_match('{<end\s+restrict="1">(\d\d\d\d)-(\d\d)-(\d\d)</end>}is', $string, $matches)) {
     816              $ret->end = mktime(0, 0, 0, $matches[2], $matches[3], $matches[1]);
     817          }
     818          return $ret;
     819      }
     820  
     821      /**
     822       * Load the role mappings (from the config), so we can easily refer to
     823       * how an IMS-E role corresponds to a Moodle role
     824       */
     825      protected function load_role_mappings() {
     826          require_once ('locallib.php');
     827  
     828          $imsroles = new imsenterprise_roles();
     829          $imsroles = $imsroles->get_imsroles();
     830  
     831          $this->rolemappings = array();
     832          foreach ($imsroles as $imsrolenum => $imsrolename) {
     833              $this->rolemappings[$imsrolenum] = $this->rolemappings[$imsrolename] = $this->get_config('imsrolemap' . $imsrolenum);
     834          }
     835      }
     836  
     837      /**
     838       * Load the name mappings (from the config), so we can easily refer to
     839       * how an IMS-E course properties corresponds to a Moodle course properties
     840       */
     841      protected function load_course_mappings() {
     842          require_once ('locallib.php');
     843  
     844          $imsnames = new imsenterprise_courses();
     845          $courseattrs = $imsnames->get_courseattrs();
     846  
     847          $this->coursemappings = array();
     848          foreach ($courseattrs as $courseattr) {
     849              $this->coursemappings[$courseattr] = $this->get_config('imscoursemap' . $courseattr);
     850          }
     851      }
     852  
     853      /**
     854       * Get the default category id (often known as 'Miscellaneous'),
     855       * statically cached to avoid multiple DB lookups on big imports.
     856       *
     857       * @return int id of default category.
     858       */
     859      private function get_default_category_id() {
     860          global $CFG;
     861  
     862          if ($this->defaultcategoryid === null) {
     863              $category = core_course_category::get_default();
     864              $this->defaultcategoryid = $category->id;
     865          }
     866  
     867          return $this->defaultcategoryid;
     868      }
     869  
     870      /**
     871       * Find the category using idnumber or name.
     872       *
     873       * @param array $categories List of categories
     874       *
     875       * @return int id of category found.
     876       */
     877      private function get_category_from_group($categories) {
     878          global $DB;
     879  
     880          if (empty($categories)) {
     881              $catid = $this->get_default_category_id();
     882          } else {
     883              $createnewcategories = $this->get_config('createnewcategories');
     884              $categoryseparator = trim($this->get_config('categoryseparator'));
     885              $nestedcategories = trim($this->get_config('nestedcategories'));
     886              $searchbyidnumber = trim($this->get_config('categoryidnumber'));
     887  
     888              if (!empty($categoryseparator)) {
     889                  $sep = '{\\'.$categoryseparator.'}';
     890              }
     891  
     892              $catid = 0;
     893              $fullnestedcatname = '';
     894  
     895              foreach ($categories as $categoryinfo) {
     896                  if ($searchbyidnumber) {
     897                      $values = preg_split($sep, $categoryinfo, -1, PREG_SPLIT_NO_EMPTY);
     898                      if (count($values) < 2) {
     899                          $this->log_line('Category ' . $categoryinfo . ' missing name or idnumber. Using default category instead.');
     900                          $catid = $this->get_default_category_id();
     901                          break;
     902                      }
     903                      $categoryname = $values[0];
     904                      $categoryidnumber = $values[1];
     905                  } else {
     906                      $categoryname = $categoryinfo;
     907                      $categoryidnumber = null;
     908                      if (empty($categoryname)) {
     909                          $this->log_line('Category ' . $categoryinfo . ' missing name. Using default category instead.');
     910                          $catid = $this->get_default_category_id();
     911                          break;
     912                      }
     913                  }
     914  
     915                  if (!empty($fullnestedcatname)) {
     916                      $fullnestedcatname .= ' / ';
     917                  }
     918  
     919                  $fullnestedcatname .= $categoryname;
     920                  $parentid = $catid;
     921  
     922                  // Check if category exist.
     923                  $params = array();
     924                  if ($searchbyidnumber) {
     925                      $params['idnumber'] = $categoryidnumber;
     926                  } else {
     927                      $params['name'] = $categoryname;
     928                  }
     929                  if ($nestedcategories) {
     930                      $params['parent'] = $parentid;
     931                  }
     932  
     933                  if ($catid = $DB->get_field('course_categories', 'id', $params)) {
     934                      continue; // This category already exists.
     935                  }
     936  
     937                  // If we're allowed to create new categories, let's create this one.
     938                  if ($createnewcategories) {
     939                      $newcat = new stdClass();
     940                      $newcat->name = $categoryname;
     941                      $newcat->visible = 0;
     942                      $newcat->parent = $parentid;
     943                      $newcat->idnumber = $categoryidnumber;
     944                      $newcat = core_course_category::create($newcat);
     945                      $catid = $newcat->id;
     946                      $this->log_line("Created new (hidden) category '$fullnestedcatname'");
     947                  } else {
     948                      // If not found and not allowed to create, stick with default.
     949                      $this->log_line('Category ' . $categoryinfo . ' not found in Moodle database. Using default category instead.');
     950                      $catid = $this->get_default_category_id();
     951                      break;
     952                  }
     953              }
     954          }
     955  
     956          return $catid;
     957      }
     958  
     959      /**
     960       * Is it possible to delete enrol instance via standard UI?
     961       *
     962       * @param object $instance
     963       * @return bool
     964       */
     965      public function can_delete_instance($instance) {
     966          $context = context_course::instance($instance->courseid);
     967          return has_capability('enrol/imsenterprise:config', $context);
     968      }
     969  
     970      /**
     971       * Is it possible to hide/show enrol instance via standard UI?
     972       *
     973       * @param stdClass $instance
     974       * @return bool
     975       */
     976      public function can_hide_show_instance($instance) {
     977          $context = context_course::instance($instance->courseid);
     978          return has_capability('enrol/imsenterprise:config', $context);
     979      }
     980  }
     981  
     982  /**
     983   * Called whenever anybody tries (from the normal interface) to remove a group
     984   * member which is registered as being created by this component. (Not called
     985   * when deleting an entire group or course at once.)
     986   * @param int $itemid Item ID that was stored in the group_members entry
     987   * @param int $groupid Group ID
     988   * @param int $userid User ID being removed from group
     989   * @return bool True if the remove is permitted, false to give an error
     990   */
     991  function enrol_imsenterprise_allow_group_member_remove($itemid, $groupid, $userid) {
     992      return false;
     993  }