See Release Notes
Long Term Support Release
Differences Between: [Versions 310 and 401] [Versions 311 and 401] [Versions 39 and 401] [Versions 400 and 401]
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 }
title
Description
Body
title
Description
Body
title
Description
Body
title
Body