Merge branch '400_mdl-73498' of https://github.com/daniil-berg/moodle into MOODLE_400...
[moodle.git] / enrol / imsenterprise / lib.php
blob3b35a3b74c3d6e635faa3a96bcabe6de9348bd71
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.
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/>.
17 /**
18 * IMS Enterprise file enrolment plugin.
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
29 defined('MOODLE_INTERNAL') || die();
31 require_once($CFG->dirroot.'/group/lib.php');
33 /**
34 * IMS Enterprise file enrolment plugin.
36 * @copyright 2010 Eugene Venter
37 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
39 class enrol_imsenterprise_plugin extends enrol_plugin {
41 /**
42 * @var IMSENTERPRISE_ADD imsenterprise add action.
44 const IMSENTERPRISE_ADD = 1;
46 /**
47 * @var IMSENTERPRISE_UPDATE imsenterprise update action.
49 const IMSENTERPRISE_UPDATE = 2;
51 /**
52 * @var IMSENTERPRISE_DELETE imsenterprise delete action.
54 const IMSENTERPRISE_DELETE = 3;
56 /**
57 * @var $logfp resource file pointer for writing log data to.
59 protected $logfp;
61 /**
62 * @var $continueprocessing bool flag to determine if processing should continue.
64 protected $continueprocessing;
66 /**
67 * @var $xmlcache string cache of xml lines.
69 protected $xmlcache;
71 /**
72 * @var $coursemappings array of mappings between IMS data fields and moodle course fields.
74 protected $coursemappings;
76 /**
77 * @var $rolemappings array of mappings between IMS roles and moodle roles.
79 protected $rolemappings;
81 /**
82 * @var $defaultcategoryid id of default category.
84 protected $defaultcategoryid;
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.
93 public function cron() {
94 global $CFG;
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');
104 if (empty($imsfilelocation)) {
105 $filename = "$CFG->dataroot/1/imsenterprise-enrol.xml"; // Default location.
106 } else {
107 $filename = $imsfilelocation;
110 $this->logfp = false;
111 if (!empty($logtolocation)) {
112 $this->logfp = fopen($logtolocation, 'a');
115 $this->defaultcategoryid = null;
117 $fileisnew = false;
118 if ( file_exists($filename) ) {
119 core_php_time_limit::raise();
120 $starttime = time();
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 = '';
127 $categoryseparator = trim($this->get_config('categoryseparator'));
128 $categoryidnumber = $this->get_config('categoryidnumber');
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();
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);
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!
152 if ($fileisnew) {
154 // The <properties> tag is allowed to halt processing if we're demanding a matching target.
155 $this->continueprocessing = true;
157 // Run through the file and process the group/person entries.
158 if (($fh = fopen($filename, "r")) != false) {
160 $line = 0;
161 while ((!feof($fh)) && $this->continueprocessing) {
163 $line++;
164 $curline = fgets($fh);
165 $this->xmlcache .= $curline; // Add a line onto the XML cache.
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;
189 fclose($fh);
190 fix_course_sortorder();
193 $timeelapsed = time() - $starttime;
194 $this->log_line('Process has completed. Time taken: '.$timeelapsed.' seconds.');
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);
203 } else {
204 $this->log_line('File not found: '.$filename);
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";
220 } else {
221 $msg .= "Logging is currently not active.";
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);
238 $this->log_line('Notification email sent to administrator.');
242 if ($this->logfp) {
243 fclose($this->logfp);
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.
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).
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;
268 * Remove complete tag from the cached data (including all its contents) - so
269 * that the cache doesn't grow to unmanageable size
271 * @param string $tagname Name of tag to look for
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));
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".
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
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.
296 * Process the group tag. This defines a Moodle course.
298 * @param string $tagcontents The raw contents of the XML element
300 protected function process_group_tag($tagcontents) {
301 global $DB, $CFG;
303 // Get configs.
304 $truncatecoursecodes = $this->get_config('truncatecoursecodes');
305 $createnewcourses = $this->get_config('createnewcourses');
306 $updatecourses = $this->get_config('updatecourses');
308 if ($createnewcourses) {
309 require_once("$CFG->dirroot/course/lib.php");
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]);
318 $matches = array();
319 if (preg_match('{<description>.*?<long>(.*?)</long>.*?</description>}is', $tagcontents, $matches)) {
320 $group->long = trim($matches[1]);
323 $matches = array();
324 if (preg_match('{<description>.*?<short>(.*?)</short>.*?</description>}is', $tagcontents, $matches)) {
325 $group->short = trim($matches[1]);
328 $matches = array();
329 if (preg_match('{<description>.*?<full>(.*?)</full>.*?</description>}is', $tagcontents, $matches)) {
330 $group->full = trim($matches[1]);
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]);
339 $recstatus = ($this->get_recstatus($tagcontents, 'group'));
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;
351 // For compatibility with the (currently inactive) course aliasing, we need this to be an array.
352 $group->coursecode = array($group->coursecode);
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 {
363 // Create the (hidden) course(s) if not found.
364 $courseconfig = get_config('moodlecourse'); // Load Moodle Course shell defaults.
366 // New course.
367 $course = new stdClass();
368 foreach ($this->coursemappings as $courseattr => $imsname) {
370 if ($imsname == 'ignore') {
371 continue;
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;
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.
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);
401 $course->startdate = time();
402 // Choose a sort order that puts us at the start of the list!
403 $course->sortorder = 0;
405 $course = create_course($course);
407 $this->log_line("Created course $coursecode in Moodle (Moodle ID is $course->id)");
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;
420 if (!empty($group->full)) {
421 if ($group->full != $dbcourse->fullname) {
422 $dbcourse->fullname = $group->full;
423 $hasupdates = true;
426 if ($hasupdates) {
427 update_course($dbcourse);
428 $courseid = $dbcourse->id;
429 $this->log_line("Updated course $coursecode in Moodle (Moodle ID is $courseid)");
431 } else {
432 // Update courses option is not enabled. Ignore.
433 $this->log_line("Ignoring update to course $coursecode");
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)");
447 * Process the person tag. This defines a Moodle user.
449 * @param string $tagcontents The raw contents of the XML element
451 protected function process_person_tag($tagcontents) {
452 global $CFG, $DB;
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');
462 $person = new stdClass();
463 if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)) {
464 $person->idnumber = trim($matches[1]);
467 $matches = array();
468 if (preg_match('{<name>.*?<n>.*?<given>(.+?)</given>.*?</n>.*?</name>}is', $tagcontents, $matches)) {
469 $person->firstname = trim($matches[1]);
472 $matches = array();
473 if (preg_match('{<name>.*?<n>.*?<family>(.+?)</family>.*?</n>.*?</name>}is', $tagcontents, $matches)) {
474 $person->lastname = trim($matches[1]);
477 $matches = array();
478 if (preg_match('{<userid.*?>(.*?)</userid>}is', $tagcontents, $matches)) {
479 $person->username = trim($matches[1]);
482 $matches = array();
483 if (preg_match('{<userid\s+authenticationtype\s*=\s*"*(.+?)"*>.*?</userid>}is', $tagcontents, $matches)) {
484 $person->auth = trim($matches[1]);
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;
493 $matches = array();
494 if (preg_match('{<email>(.*?)</email>}is', $tagcontents, $matches)) {
495 $person->email = trim($matches[1]);
498 $matches = array();
499 if (preg_match('{<url>(.*?)</url>}is', $tagcontents, $matches)) {
500 $person->url = trim($matches[1]);
503 $matches = array();
504 if (preg_match('{<adr>.*?<locality>(.+?)</locality>.*?</adr>}is', $tagcontents, $matches)) {
505 $person->city = trim($matches[1]);
508 $matches = array();
509 if (preg_match('{<adr>.*?<country>(.+?)</country>.*?</adr>}is', $tagcontents, $matches)) {
510 $person->country = trim($matches[1]);
513 // Fix case of some of the fields if required.
514 if ($fixcaseusernames && isset($person->username)) {
515 $person->username = strtolower($person->username);
517 if ($fixcasepersonalnames) {
518 if (isset($person->firstname)) {
519 $person->firstname = ucwords(strtolower($person->firstname));
521 if (isset($person->lastname)) {
522 $person->lastname = ucwords(strtolower($person->lastname));
526 $recstatus = ($this->get_recstatus($tagcontents, 'person'));
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) {
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).");
540 } else {
541 $this->log_line("Can not delete user '$person->username' (ID number $person->idnumber) - user does not exist.");
543 } else {
544 $this->log_line("Ignoring deletion request for user '$person->username' (ID number $person->idnumber).");
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");
555 } else {
556 $this->log_line("Ignoring update request for user $person->username");
559 } else { // Add or update record.
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 {
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;
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).");
586 } else if ($createnewusers) {
587 $this->log_line("User record already exists for user '$person->username' (ID number $person->idnumber).");
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).");
600 * Process the membership tag. This defines whether the specified Moodle users
601 * should be added/removed as teachers/students.
603 * @param string $tagcontents The raw contents of the XML element
605 protected function process_membership_tag($tagcontents) {
606 global $DB;
608 // Get plugin configs.
609 $truncatecoursecodes = $this->get_config('truncatecoursecodes');
610 $imscapitafix = $this->get_config('imscapitafix');
612 $memberstally = 0;
613 $membersuntally = 0;
615 // In order to reduce the number of db queries required, group name/id associations are cached in this array.
616 $groupids = array();
618 $ship = new stdClass();
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));
626 if ($ship->courseid && preg_match_all('{<member>(.*?)</member>}is', $tagcontents, $membermatches, PREG_SET_ORDER)) {
627 $courseobj = new stdClass();
628 $courseobj->id = $ship->courseid;
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]);
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]);
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]);
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;
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]);
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.
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) {
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;
693 if (intval($member->status) == 1) {
694 // Enrol the member.
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));
704 $this->enrol_user($einstance, $memberstoreobj->userid, $moodleroleid, $timeframe->begin, $timeframe->end);
706 $this->log_line("Enrolled user #$memberstoreobj->userid ($member->idnumber) "
707 ."to role $member->roletype in course $memberstoreobj->course");
708 $memberstally++;
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));
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);
742 } else if ($this->get_config('imsunenrol')) {
743 // Unenrol member.
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);
752 $membersuntally++;
753 $this->log_line("Unenrolled $member->idnumber from role $moodleroleid in course");
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");
763 } // End process_membership_tag().
766 * Process the properties tag. The only data from this element
767 * that is relevant is whether a <target> is specified.
769 * @param string $tagcontents The raw contents of the XML element
771 protected function process_properties_tag($tagcontents) {
772 $imsrestricttarget = $this->get_config('imsrestricttarget');
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;
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)
788 protected function log_line($string) {
790 if (!PHPUNIT_TEST) {
791 mtrace($string);
793 if ($this->logfp) {
794 fwrite($this->logfp, $string . "\n");
799 * Process the INNER contents of a <timeframe> tag, to return beginning/ending dates.
801 * @param string $string tag to decode.
802 * @return stdClass beginning and/or ending is returned, in unix time, zero indicating not specified.
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]);
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]);
818 return $ret;
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
825 protected function load_role_mappings() {
826 require_once('locallib.php');
828 $imsroles = new imsenterprise_roles();
829 $imsroles = $imsroles->get_imsroles();
831 $this->rolemappings = array();
832 foreach ($imsroles as $imsrolenum => $imsrolename) {
833 $this->rolemappings[$imsrolenum] = $this->rolemappings[$imsrolename] = $this->get_config('imsrolemap' . $imsrolenum);
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
841 protected function load_course_mappings() {
842 require_once('locallib.php');
844 $imsnames = new imsenterprise_courses();
845 $courseattrs = $imsnames->get_courseattrs();
847 $this->coursemappings = array();
848 foreach ($courseattrs as $courseattr) {
849 $this->coursemappings[$courseattr] = $this->get_config('imscoursemap' . $courseattr);
854 * Get the default category id (often known as 'Miscellaneous'),
855 * statically cached to avoid multiple DB lookups on big imports.
857 * @return int id of default category.
859 private function get_default_category_id() {
860 global $CFG;
862 if ($this->defaultcategoryid === null) {
863 $category = core_course_category::get_default();
864 $this->defaultcategoryid = $category->id;
867 return $this->defaultcategoryid;
871 * Find the category using idnumber or name.
873 * @param array $categories List of categories
875 * @return int id of category found.
877 private function get_category_from_group($categories) {
878 global $DB;
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'));
888 if (!empty($categoryseparator)) {
889 $sep = '{\\'.$categoryseparator.'}';
892 $catid = 0;
893 $fullnestedcatname = '';
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;
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;
915 if (!empty($fullnestedcatname)) {
916 $fullnestedcatname .= ' / ';
919 $fullnestedcatname .= $categoryname;
920 $parentid = $catid;
922 // Check if category exist.
923 $params = array();
924 if ($searchbyidnumber) {
925 $params['idnumber'] = $categoryidnumber;
926 } else {
927 $params['name'] = $categoryname;
929 if ($nestedcategories) {
930 $params['parent'] = $parentid;
933 if ($catid = $DB->get_field('course_categories', 'id', $params)) {
934 continue; // This category already exists.
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;
956 return $catid;
960 * Is it possible to delete enrol instance via standard UI?
962 * @param object $instance
963 * @return bool
965 public function can_delete_instance($instance) {
966 $context = context_course::instance($instance->courseid);
967 return has_capability('enrol/imsenterprise:config', $context);
971 * Is it possible to hide/show enrol instance via standard UI?
973 * @param stdClass $instance
974 * @return bool
976 public function can_hide_show_instance($instance) {
977 $context = context_course::instance($instance->courseid);
978 return has_capability('enrol/imsenterprise:config', $context);
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
991 function enrol_imsenterprise_allow_group_member_remove($itemid, $groupid, $userid) {
992 return false;