MDL-28705 enrol_imsenterprise Unit tests for users and courses creation
[moodle.git] / enrol / imsenterprise / lib.php
CommitLineData
8c25f6f2 1<?php
fa36ca3d
AB
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/>.
8c25f6f2 16
fa36ca3d
AB
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).
97c65be5
PS
23 * @package enrol
24 * @subpackage imsenterprise
25 * @copyright 2010 Eugene Venter
26 * @author Eugene Venter - based on code by Dan Stowell
27 * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
fa36ca3d 28 */
8c25f6f2 29
97c65be5
PS
30defined('MOODLE_INTERNAL') || die();
31
8c25f6f2 32/*
33
34Note for programmers:
35
2dcccb0f 36This class uses regular expressions to mine the data file. The main reason is
8c25f6f2 37that XML handling changes from PHP 4 to PHP 5, so this should work on both.
38
2dcccb0f 39One drawback is that the pattern-matching doesn't (currently) handle XML
8c25f6f2 40namespaces - it only copes with a <group> tag if it says <group>, and not
41(for example) <ims:group>.
42
2dcccb0f 43This should also be able to handle VERY LARGE FILES - so the entire IMS file is
44NOT loaded into memory at once. It's handled line-by-line, 'forgetting' tags as
8c25f6f2 45soon as they are processed.
46
2dcccb0f 47N.B. The "sourcedid" ID code is translated to Moodle's "idnumber" field, both
8c25f6f2 48for users and for courses.
49
50*/
51
fa36ca3d 52require_once($CFG->dirroot.'/group/lib.php');
26ba0912 53
54
fa36ca3d 55class enrol_imsenterprise_plugin extends enrol_plugin {
8c25f6f2 56
fa36ca3d 57var $log;
8c25f6f2 58
8c25f6f2 59
60/**
61* Read in an IMS Enterprise file.
2dcccb0f 62* Originally designed to handle v1.1 files but should be able to handle
8c25f6f2 63* earlier types as well, I believe.
64*
65*/
66function cron() {
67 global $CFG;
68
fa36ca3d
AB
69 // Get configs
70 $imsfilelocation = $this->get_config('imsfilelocation');
71 $logtolocation = $this->get_config('logtolocation');
72 $mailadmins = $this->get_config('mailadmins');
73 $prev_time = $this->get_config('prev_time');
74 $prev_md5 = $this->get_config('prev_md5');
75 $prev_path = $this->get_config('prev_path');
76
77 if (empty($imsfilelocation)) {
8c25f6f2 78 // $filename = "$CFG->dirroot/enrol/imsenterprise/example.xml"; // Default location
79 $filename = "$CFG->dataroot/1/imsenterprise-enrol.xml"; // Default location
80 } else {
fa36ca3d 81 $filename = $imsfilelocation;
8c25f6f2 82 }
83
84 $this->logfp = false; // File pointer for writing log data to
fa36ca3d
AB
85 if(!empty($logtolocation)) {
86 $this->logfp = fopen($logtolocation, 'a');
8c25f6f2 87 }
88
8c25f6f2 89 if ( file_exists($filename) ) {
90 @set_time_limit(0);
2dcccb0f 91 $starttime = time();
92
8c25f6f2 93 $this->log_line('----------------------------------------------------------------------');
94 $this->log_line("IMS Enterprise enrol cron process launched at " . userdate(time()));
95 $this->log_line('Found file '.$filename);
96 $this->xmlcache = '';
2dcccb0f 97
26ba0912 98 // Make sure we understand how to map the IMS-E roles to Moodle roles
99 $this->load_role_mappings();
66dad719 100 // Make sure we understand how to map the IMS-E course names to Moodle course names.
a65c11b0 101 $this->load_course_mappings();
8c25f6f2 102
103 $md5 = md5_file($filename); // NB We'll write this value back to the database at the end of the cron
104 $filemtime = filemtime($filename);
2dcccb0f 105
8c25f6f2 106 // Decide if we want to process the file (based on filepath, modification time, and MD5 hash)
107 // This is so we avoid wasting the server's efforts processing a file unnecessarily
fa36ca3d 108 if(empty($prev_path) || ($filename != $prev_path)) {
8c25f6f2 109 $fileisnew = true;
fa36ca3d 110 } elseif(isset($prev_time) && ($filemtime <= $prev_time)) {
8c25f6f2 111 $fileisnew = false;
112 $this->log_line('File modification time is not more recent than last update - skipping processing.');
fa36ca3d 113 } elseif(isset($prev_md5) && ($md5 == $prev_md5)) {
8c25f6f2 114 $fileisnew = false;
115 $this->log_line('File MD5 hash is same as on last update - skipping processing.');
fa36ca3d 116 } else {
8c25f6f2 117 $fileisnew = true; // Let's process it!
118 }
119
fa36ca3d 120 if($fileisnew) {
2dcccb0f 121
8c25f6f2 122 $listoftags = array('group', 'person', 'member', 'membership', 'comments', 'properties'); // The list of tags which should trigger action (even if only cache trimming)
123 $this->continueprocessing = true; // The <properties> tag is allowed to halt processing if we're demanding a matching target
2dcccb0f 124
8c25f6f2 125 // FIRST PASS: Run through the file and process the group/person entries
126 if (($fh = fopen($filename, "r")) != false) {
2dcccb0f 127
8c25f6f2 128 $line = 0;
129 while ((!feof($fh)) && $this->continueprocessing) {
2dcccb0f 130
8c25f6f2 131 $line++;
132 $curline = fgets($fh);
133 $this->xmlcache .= $curline; // Add a line onto the XML cache
2dcccb0f 134
fa36ca3d 135 while (true) {
8c25f6f2 136 // If we've got a full tag (i.e. the most recent line has closed the tag) then process-it-and-forget-it.
137 // Must always make sure to remove tags from cache so they don't clog up our memory
fa36ca3d 138 if($tagcontents = $this->full_tag_found_in_cache('group', $curline)) {
8c25f6f2 139 $this->process_group_tag($tagcontents);
140 $this->remove_tag_from_cache('group');
fa36ca3d 141 } elseif($tagcontents = $this->full_tag_found_in_cache('person', $curline)) {
8c25f6f2 142 $this->process_person_tag($tagcontents);
143 $this->remove_tag_from_cache('person');
fa36ca3d 144 } elseif($tagcontents = $this->full_tag_found_in_cache('membership', $curline)) {
8c25f6f2 145 $this->process_membership_tag($tagcontents);
146 $this->remove_tag_from_cache('membership');
fa36ca3d 147 } elseif($tagcontents = $this->full_tag_found_in_cache('comments', $curline)) {
8c25f6f2 148 $this->remove_tag_from_cache('comments');
fa36ca3d 149 } elseif($tagcontents = $this->full_tag_found_in_cache('properties', $curline)) {
8c25f6f2 150 $this->process_properties_tag($tagcontents);
151 $this->remove_tag_from_cache('properties');
fa36ca3d
AB
152 } else {
153 break;
154 }
155 } // End of while-tags-are-detected
8c25f6f2 156 } // end of while loop
157 fclose($fh);
6f3dc679 158 fix_course_sortorder();
8c25f6f2 159 } // end of if(file_open) for first pass
2dcccb0f 160
8c25f6f2 161 /*
2dcccb0f 162
163
8c25f6f2 164 SECOND PASS REMOVED
165 Since the IMS specification v1.1 insists that "memberships" should come last,
166 and since vendors seem to have done this anyway (even with 1.0),
167 we can sensibly perform the import in one fell swoop.
2dcccb0f 168
169
8c25f6f2 170 // SECOND PASS: Now go through the file and process the membership entries
171 $this->xmlcache = '';
172 if (($fh = fopen($filename, "r")) != false) {
173 $line = 0;
174 while ((!feof($fh)) && $this->continueprocessing) {
175 $line++;
176 $curline = fgets($fh);
177 $this->xmlcache .= $curline; // Add a line onto the XML cache
2dcccb0f 178
8c25f6f2 179 while(true){
180 // Must always make sure to remove tags from cache so they don't clog up our memory
181 if($tagcontents = $this->full_tag_found_in_cache('group', $curline)){
182 $this->remove_tag_from_cache('group');
183 }elseif($tagcontents = $this->full_tag_found_in_cache('person', $curline)){
184 $this->remove_tag_from_cache('person');
185 }elseif($tagcontents = $this->full_tag_found_in_cache('membership', $curline)){
186 $this->process_membership_tag($tagcontents);
187 $this->remove_tag_from_cache('membership');
188 }elseif($tagcontents = $this->full_tag_found_in_cache('comments', $curline)){
189 $this->remove_tag_from_cache('comments');
190 }elseif($tagcontents = $this->full_tag_found_in_cache('properties', $curline)){
191 $this->remove_tag_from_cache('properties');
192 }else{
193 break;
194 }
195 }
196 } // end of while loop
197 fclose($fh);
198 } // end of if(file_open) for second pass
2dcccb0f 199
200
8c25f6f2 201 */
202
203 $timeelapsed = time() - $starttime;
204 $this->log_line('Process has completed. Time taken: '.$timeelapsed.' seconds.');
205
206
207 } // END of "if file is new"
2dcccb0f 208
209
8c25f6f2 210 // These variables are stored so we can compare them against the IMS file, next time round.
fa36ca3d
AB
211 $this->set_config('prev_time', $filemtime);
212 $this->set_config('prev_md5', $md5);
213 $this->set_config('prev_path', $filename);
8c25f6f2 214
215
216
217 }else{ // end of if(file_exists)
218 $this->log_line('File not found: '.$filename);
219 }
220
fa36ca3d 221 if (!empty($mailadmins)) {
8c25f6f2 222 $msg = "An IMS enrolment has been carried out within Moodle.\nTime taken: $timeelapsed seconds.\n\n";
fa36ca3d 223 if(!empty($logtolocation)){
8c25f6f2 224 if($this->logfp){
225 $msg .= "Log data has been written to:\n";
fa36ca3d
AB
226 $msg .= "$logtolocation\n";
227 $msg .= "(Log file size: ".ceil(filesize($logtolocation)/1024)."Kb)\n\n";
8c25f6f2 228 }else{
229 $msg .= "The log file appears not to have been successfully written.\nCheck that the file is writeable by the server:\n";
fa36ca3d 230 $msg .= "$logtolocation\n\n";
8c25f6f2 231 }
232 }else{
233 $msg .= "Logging is currently not active.";
234 }
4317f92f 235
94b9c2e8 236 $eventdata = new stdClass();
3b120e46 237 $eventdata->modulename = 'moodle';
fa36ca3d
AB
238 $eventdata->component = 'imsenterprise';
239 $eventdata->name = 'imsenterprise_enrolment';
3b120e46 240 $eventdata->userfrom = get_admin();
241 $eventdata->userto = get_admin();
242 $eventdata->subject = "Moodle IMS Enterprise enrolment notification";
243 $eventdata->fullmessage = $msg;
244 $eventdata->fullmessageformat = FORMAT_PLAIN;
245 $eventdata->fullmessagehtml = '';
4454447d 246 $eventdata->smallmessage = '';
7c7d3afa 247 message_send($eventdata);
3b120e46 248
8c25f6f2 249 $this->log_line('Notification email sent to administrator.');
250
251 }
252
253 if($this->logfp){
254 fclose($this->logfp);
255 }
2dcccb0f 256
8c25f6f2 257
258} // end of cron() function
259
260/**
261* Check if a complete tag is found in the cached data, which usually happens
262* when the end of the tag has only just been loaded into the cache.
263* Returns either false, or the contents of the tag (including start and end).
264* @param string $tagname Name of tag to look for
265* @param string $latestline The very last line in the cache (used for speeding up the match)
266*/
267function full_tag_found_in_cache($tagname, $latestline){ // Return entire element if found. Otherwise return false.
268 if(strpos(strtolower($latestline), '</'.strtolower($tagname).'>')===false){
269 return false;
270 }elseif(preg_match('{(<'.$tagname.'\b.*?>.*?</'.$tagname.'>)}is', $this->xmlcache, $matches)){
271 return $matches[1];
272 }else return false;
273}
274
275/**
276* Remove complete tag from the cached data (including all its contents) - so
277* that the cache doesn't grow to unmanageable size
278* @param string $tagname Name of tag to look for
279*/
280function remove_tag_from_cache($tagname){ // Trim the cache so we're not in danger of running out of memory.
281 ///echo "<p>remove_tag_from_cache: $tagname</p>"; flush(); ob_flush();
282 // echo "<p>remove_tag_from_cache:<br />".htmlspecialchars($this->xmlcache);
283 $this->xmlcache = trim(preg_replace('{<'.$tagname.'\b.*?>.*?</'.$tagname.'>}is', '', $this->xmlcache, 1)); // "1" so that we replace only the FIRST instance
284 // echo "<br />".htmlspecialchars($this->xmlcache)."</p>";
285}
286
287/**
288* Very simple convenience function to return the "recstatus" found in person/group/role tags.
289* 1=Add, 2=Update, 3=Delete, as specified by IMS, and we also use 0 to indicate "unspecified".
290* @param string $tagdata the tag XML data
291* @param string $tagname the name of the tag we're interested in
292*/
293function get_recstatus($tagdata, $tagname){
294 if(preg_match('{<'.$tagname.'\b[^>]*recstatus\s*=\s*["\'](\d)["\']}is', $tagdata, $matches)){
295 // echo "<p>get_recstatus($tagname) found status of $matches[1]</p>";
296 return intval($matches[1]);
297 }else{
298 // echo "<p>get_recstatus($tagname) found nothing</p>";
299 return 0; // Unspecified
300 }
301}
302
303/**
304* Process the group tag. This defines a Moodle course.
305* @param string $tagconents The raw contents of the XML element
306*/
801fdcfd 307function process_group_tag($tagcontents) {
fa36ca3d
AB
308 global $DB;
309
310 // Get configs
311 $truncatecoursecodes = $this->get_config('truncatecoursecodes');
312 $createnewcourses = $this->get_config('createnewcourses');
313 $createnewcategories = $this->get_config('createnewcategories');
2dcccb0f 314
8c25f6f2 315 // Process tag contents
94b9c2e8 316 $group = new stdClass();
801fdcfd 317 if (preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)) {
8c25f6f2 318 $group->coursecode = trim($matches[1]);
319 }
b1cabb40
AS
320
321 if (preg_match('{<description>.*?<long>(.*?)</long>.*?</description>}is', $tagcontents, $matches)) {
66dad719 322 $group->long = trim($matches[1]);
8c25f6f2 323 }
b47316cd 324 if (preg_match('{<description>.*?<short>(.*?)</short>.*?</description>}is', $tagcontents, $matches)) {
66dad719 325 $group->short = trim($matches[1]);
b47316cd 326 }
a4b1ba24 327 if (preg_match('{<description>.*?<full>(.*?)</full>.*?</description>}is', $tagcontents, $matches)) {
b1cabb40 328 $group->full = trim($matches[1]);
a4b1ba24 329 }
b1cabb40 330
801fdcfd 331 if (preg_match('{<org>.*?<orgunit>(.*?)</orgunit>.*?</org>}is', $tagcontents, $matches)) {
8c25f6f2 332 $group->category = trim($matches[1]);
333 }
2dcccb0f 334
8c25f6f2 335 $recstatus = ($this->get_recstatus($tagcontents, 'group'));
336 //echo "<p>get_recstatus for this group returned $recstatus</p>";
2dcccb0f 337
801fdcfd 338 if (!(strlen($group->coursecode)>0)) {
8c25f6f2 339 $this->log_line('Error at line '.$line.': Unable to find course code in \'group\' element.');
801fdcfd 340 } else {
8c25f6f2 341 // First, truncate the course code if desired
801fdcfd 342 if (intval($truncatecoursecodes)>0) {
fa36ca3d
AB
343 $group->coursecode = ($truncatecoursecodes > 0)
344 ? substr($group->coursecode, 0, intval($truncatecoursecodes))
8c25f6f2 345 : $group->coursecode;
346 }
2dcccb0f 347
8c25f6f2 348 /* -----------Course aliasing is DEACTIVATED until a more general method is in place---------------
2dcccb0f 349
8c25f6f2 350 // Second, look in the course alias table to see if the code should be translated to something else
a5d424df 351 if($aliases = $DB->get_field('enrol_coursealias', 'toids', array('fromid'=>$group->coursecode))){
8c25f6f2 352 $this->log_line("Found alias of course code: Translated $group->coursecode to $aliases");
353 // Alias is allowed to be a comma-separated list, so let's split it
354 $group->coursecode = explode(',', $aliases);
355 }
356 */
2dcccb0f 357
8c25f6f2 358 // For compatibility with the (currently inactive) course aliasing, we need this to be an array
359 $group->coursecode = array($group->coursecode);
2dcccb0f 360
8c25f6f2 361 // Third, check if the course(s) exist
801fdcfd 362 foreach ($group->coursecode as $coursecode) {
8c25f6f2 363 $coursecode = trim($coursecode);
801fdcfd
DM
364 if (!$DB->get_field('course', 'id', array('idnumber'=>$coursecode))) {
365 if (!$createnewcourses) {
20d694ac
DM
366 $this->log_line("Course $coursecode not found in Moodle's course idnumbers.");
367 } else {
a65c11b0 368
20d694ac
DM
369 // Create the (hidden) course(s) if not found
370 $courseconfig = get_config('moodlecourse'); // Load Moodle Course shell defaults
66dad719
DM
371
372 // New course.
20d694ac 373 $course = new stdClass();
66dad719
DM
374 foreach ($this->coursemappings as $courseattr => $imsname) {
375
376 if ($imsname == 'ignore') {
377 continue;
378 }
379
380 // Check if the IMS file contains the mapped tag, otherwise fallback on coursecode.
381 if ($imsname == 'coursecode') {
382 $course->{$courseattr} = $coursecode;
383 } else if (!empty($group->{$imsname})) {
384 $course->{$courseattr} = $group->{$imsname};
385 } else {
386 $this->log_line('No ' . $imsname . ' description tag found for ' . $coursecode . ' coursecode, using ' . $coursecode . ' instead');
387 $course->{$courseattr} = $coursecode;
388 }
389
390 if ($courseattr == 'summary') {
391 $format = FORMAT_HTML;
392 } else {
393 $format = FORMAT_PLAIN;
394 }
395 $course->{$courseattr} = format_text($course->$courseattr, $format);
a4b1ba24 396 }
66dad719 397
20d694ac
DM
398 $course->idnumber = $coursecode;
399 $course->format = $courseconfig->format;
400 $course->visible = $courseconfig->visible;
20d694ac
DM
401 $course->newsitems = $courseconfig->newsitems;
402 $course->showgrades = $courseconfig->showgrades;
403 $course->showreports = $courseconfig->showreports;
404 $course->maxbytes = $courseconfig->maxbytes;
405 $course->groupmode = $courseconfig->groupmode;
406 $course->groupmodeforce = $courseconfig->groupmodeforce;
407 $course->enablecompletion = $courseconfig->enablecompletion;
408 $course->completionstartonenrol = $courseconfig->completionstartonenrol;
409 // Insert default names for teachers/students, from the current language
20d694ac
DM
410
411 // Handle course categorisation (taken from the group.org.orgunit field if present)
801fdcfd 412 if (strlen($group->category)>0) {
20d694ac 413 // If the category is defined and exists in Moodle, we want to store it in that one
801fdcfd 414 if ($catid = $DB->get_field('course_categories', 'id', array('name'=>$group->category))) {
20d694ac 415 $course->category = $catid;
801fdcfd 416 } else if ($createnewcategories) {
20d694ac
DM
417 // Else if we're allowed to create new categories, let's create this one
418 $newcat = new stdClass();
419 $newcat->name = $group->category;
420 $newcat->visible = 0;
421 $catid = $DB->insert_record('course_categories', $newcat);
422 $course->category = $catid;
423 $this->log_line("Created new (hidden) category, #$catid: $newcat->name");
801fdcfd 424 } else {
20d694ac
DM
425 // If not found and not allowed to create, stick with default
426 $this->log_line('Category '.$group->category.' not found in Moodle database, so using default category instead.');
427 $course->category = 1;
428 }
801fdcfd 429 } else {
8c25f6f2 430 $course->category = 1;
431 }
20d694ac
DM
432 $course->timecreated = time();
433 $course->startdate = time();
434 // Choose a sort order that puts us at the start of the list!
435 $course->sortorder = 0;
20d694ac 436 $courseid = $DB->insert_record('course', $course);
2dcccb0f 437
c72c6356
DM
438 // Setup default enrolment plugins
439 $course->id = $courseid;
440 enrol_course_updated(true, $course, null);
441
20d694ac
DM
442 // Setup the blocks
443 $course = $DB->get_record('course', array('id' => $courseid));
444 blocks_add_default_course_blocks($course);
2dcccb0f 445
4ede27b2
MG
446 // Create default 0-section
447 course_create_sections_if_missing($course, 0);
2dcccb0f 448
20d694ac 449 add_to_log(SITEID, "course", "new", "view.php?id=$course->id", "$course->fullname (ID $course->id)");
2dcccb0f 450
20d694ac
DM
451 $this->log_line("Created course $coursecode in Moodle (Moodle ID is $course->id)");
452 }
801fdcfd 453 } else if ($recstatus==3 && ($courseid = $DB->get_field('course', 'id', array('idnumber'=>$coursecode)))) {
8c25f6f2 454 // If course does exist, but recstatus==3 (delete), then set the course as hidden
50c5bef4 455 $DB->set_field('course', 'visible', '0', array('id'=>$courseid));
8c25f6f2 456 }
457 } // End of foreach(coursecode)
458 }
459} // End process_group_tag()
460
461/**
462* Process the person tag. This defines a Moodle user.
463* @param string $tagconents The raw contents of the XML element
464*/
465function process_person_tag($tagcontents){
50c5bef4 466 global $CFG, $DB;
8c25f6f2 467
fa36ca3d
AB
468 // Get plugin configs
469 $imssourcedidfallback = $this->get_config('imssourcedidfallback');
470 $fixcaseusernames = $this->get_config('fixcaseusernames');
471 $fixcasepersonalnames = $this->get_config('fixcasepersonalnames');
472 $imsdeleteusers = $this->get_config('imsdeleteusers');
473 $createnewusers = $this->get_config('createnewusers');
474
94b9c2e8 475 $person = new stdClass();
8c25f6f2 476 if(preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)){
477 $person->idnumber = trim($matches[1]);
478 }
479 if(preg_match('{<name>.*?<n>.*?<given>(.+?)</given>.*?</n>.*?</name>}is', $tagcontents, $matches)){
480 $person->firstname = trim($matches[1]);
481 }
482 if(preg_match('{<name>.*?<n>.*?<family>(.+?)</family>.*?</n>.*?</name>}is', $tagcontents, $matches)){
483 $person->lastname = trim($matches[1]);
484 }
485 if(preg_match('{<userid>(.*?)</userid>}is', $tagcontents, $matches)){
486 $person->username = trim($matches[1]);
22016388 487 }
fa36ca3d 488 if($imssourcedidfallback && trim($person->username)==''){
8c25f6f2 489 // This is the point where we can fall back to useing the "sourcedid" if "userid" is not supplied
450a0a7d 490 // NB We don't use an "elseif" because the tag may be supplied-but-empty
8c25f6f2 491 $person->username = $person->idnumber;
492 }
493 if(preg_match('{<email>(.*?)</email>}is', $tagcontents, $matches)){
494 $person->email = trim($matches[1]);
495 }
496 if(preg_match('{<url>(.*?)</url>}is', $tagcontents, $matches)){
497 $person->url = trim($matches[1]);
498 }
499 if(preg_match('{<adr>.*?<locality>(.+?)</locality>.*?</adr>}is', $tagcontents, $matches)){
500 $person->city = trim($matches[1]);
501 }
502 if(preg_match('{<adr>.*?<country>(.+?)</country>.*?</adr>}is', $tagcontents, $matches)){
503 $person->country = trim($matches[1]);
504 }
505
506 // Fix case of some of the fields if required
fa36ca3d 507 if($fixcaseusernames && isset($person->username)){
8c25f6f2 508 $person->username = strtolower($person->username);
509 }
fa36ca3d 510 if($fixcasepersonalnames){
8c25f6f2 511 if(isset($person->firstname)){
512 $person->firstname = ucwords(strtolower($person->firstname));
513 }
514 if(isset($person->lastname)){
515 $person->lastname = ucwords(strtolower($person->lastname));
516 }
517 }
518
519 $recstatus = ($this->get_recstatus($tagcontents, 'person'));
520
521
522 // Now if the recstatus is 3, we should delete the user if-and-only-if the setting for delete users is turned on
523 // In the "users" table we can do this by setting deleted=1
524 if($recstatus==3){
2dcccb0f 525
fa36ca3d 526 if($imsdeleteusers){ // If we're allowed to delete user records
8c25f6f2 527 // Make sure their "deleted" field is set to one
50c5bef4 528 $DB->set_field('user', 'deleted', 1, array('username'=>$person->username));
8c25f6f2 529 $this->log_line("Marked user record for user '$person->username' (ID number $person->idnumber) as deleted.");
530 }else{
531 $this->log_line("Ignoring deletion request for user '$person->username' (ID number $person->idnumber).");
532 }
2dcccb0f 533
8c25f6f2 534 }else{ // Add or update record
2dcccb0f 535
8c25f6f2 536
537 // If the user exists (matching sourcedid) then we don't need to do anything.
fa36ca3d 538 if(!$DB->get_field('user', 'id', array('idnumber'=>$person->idnumber)) && $createnewusers){
8c25f6f2 539 // If they don't exist and haven't a defined username, we log this as a potential problem.
540 if((!isset($person->username)) || (strlen($person->username)==0)){
541 $this->log_line("Cannot create new user for ID # $person->idnumber - no username listed in IMS data for this person.");
50c5bef4 542 } else if ($DB->get_field('user', 'id', array('username'=>$person->username))){
8c25f6f2 543 // If their idnumber is not registered but their user ID is, then add their idnumber to their record
50c5bef4 544 $DB->set_field('user', 'idnumber', $person->idnumber, array('username'=>$person->username));
545 } else {
2dcccb0f 546
fa36ca3d 547 // If they don't exist and they have a defined username, and $createnewusers == true, we create them.
2dcccb0f 548 $person->lang = 'manual'; //TODO: this needs more work due tu multiauth changes
67e3f5b5 549 $person->auth = $CFG->auth;
8c25f6f2 550 $person->confirmed = 1;
551 $person->timemodified = time();
2dcccb0f 552 $person->mnethostid = $CFG->mnet_localhost_id;
9d97f08e 553 $id = $DB->insert_record('user', $person);
8c25f6f2 554 /*
555 Photo processing is deactivated until we hear from Moodle dev forum about modification to gdlib.
2dcccb0f 556
557 //Antoni Mas. 07/12/2005. If a photo URL is specified then we might want to load
8c25f6f2 558 // it into the user's profile. Beware that this may cause a heavy overhead on the server.
559 if($CFG->enrol_processphoto){
560 if(preg_match('{<photo>.*?<extref>(.*?)</extref>.*?</photo>}is', $tagcontents, $matches)){
561 $person->urlphoto = trim($matches[1]);
562 }
563 //Habilitam el flag que ens indica que el personatge t foto prpia.
2dcccb0f 564 $person->picture = 1;
8c25f6f2 565 //Llibreria creada per nosaltres mateixos.
566 require_once($CFG->dirroot.'/lib/gdlib.php');
edfd6a5e 567 if ($usernew->picture = save_profile_image($id, $person->urlphoto,'user')) { TODO: use process_new_icon() instead
50c5bef4 568 $DB->set_field('user', 'picture', $usernew->picture, array('id'=>$id)); /// Note picture in DB
8c25f6f2 569 }
570 }
571 */
9d97f08e 572 $this->log_line("Created user record for user '$person->username' (ID number $person->idnumber).");
8c25f6f2 573 }
fa36ca3d 574 } elseif ($createnewusers) {
8c25f6f2 575 $this->log_line("User record already exists for user '$person->username' (ID number $person->idnumber).");
2dcccb0f 576
8c25f6f2 577 // Make sure their "deleted" field is set to zero.
50c5bef4 578 $DB->set_field('user', 'deleted', 0, array('idnumber'=>$person->idnumber));
8c25f6f2 579 }else{
580 $this->log_line("No user record found for '$person->username' (ID number $person->idnumber).");
581 }
2dcccb0f 582
8c25f6f2 583 } // End of are-we-deleting-or-adding
584
585} // End process_person_tag()
586
587/**
2dcccb0f 588* Process the membership tag. This defines whether the specified Moodle users
8c25f6f2 589* should be added/removed as teachers/students.
590* @param string $tagconents The raw contents of the XML element
591*/
592function process_membership_tag($tagcontents){
fa36ca3d
AB
593 global $DB;
594
595 // Get plugin configs
596 $truncatecoursecodes = $this->get_config('truncatecoursecodes');
597 $imscapitafix = $this->get_config('imscapitafix');
598
26ba0912 599 $memberstally = 0;
600 $membersuntally = 0;
2dcccb0f 601
8c25f6f2 602 // In order to reduce the number of db queries required, group name/id associations are cached in this array:
603 $groupids = array();
2dcccb0f 604
92701024
PS
605 $ship = new stdClass();
606
8c25f6f2 607 if(preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $tagcontents, $matches)){
fa36ca3d
AB
608 $ship->coursecode = ($truncatecoursecodes > 0)
609 ? substr(trim($matches[1]), 0, intval($truncatecoursecodes))
8c25f6f2 610 : trim($matches[1]);
50c5bef4 611 $ship->courseid = $DB->get_field('course', 'id', array('idnumber'=>$ship->coursecode));
8c25f6f2 612 }
613 if($ship->courseid && preg_match_all('{<member>(.*?)</member>}is', $tagcontents, $membermatches, PREG_SET_ORDER)){
fa36ca3d
AB
614 $courseobj = new stdClass();
615 $courseobj->id = $ship->courseid;
616
8c25f6f2 617 foreach($membermatches as $mmatch){
92701024
PS
618 $member = new stdClass();
619 $memberstoreobj = new stdClass();
8c25f6f2 620 if(preg_match('{<sourcedid>.*?<id>(.+?)</id>.*?</sourcedid>}is', $mmatch[1], $matches)){
621 $member->idnumber = trim($matches[1]);
622 }
623 if(preg_match('{<role\s+roletype=["\'](.+?)["\'].*?>}is', $mmatch[1], $matches)){
624 $member->roletype = trim($matches[1]); // 01 means Student, 02 means Instructor, 3 means ContentDeveloper, and there are more besides
fa36ca3d 625 } elseif($imscapitafix && preg_match('{<roletype>(.+?)</roletype>}is', $mmatch[1], $matches)){
8c25f6f2 626 // The XML that comes out of Capita Student Records seems to contain a misinterpretation of the IMS specification!
627 $member->roletype = trim($matches[1]); // 01 means Student, 02 means Instructor, 3 means ContentDeveloper, and there are more besides
628 }
629 if(preg_match('{<role\b.*?<status>(.+?)</status>.*?</role>}is', $mmatch[1], $matches)){
630 $member->status = trim($matches[1]); // 1 means active, 0 means inactive - treat this as enrol vs unenrol
631 }
632
633 $recstatus = ($this->get_recstatus($mmatch[1], 'role'));
634 if($recstatus==3){
635 $member->status = 0; // See above - recstatus of 3 (==delete) is treated the same as status of 0
636 //echo "<p>process_membership_tag: unenrolling member due to recstatus of 3</p>";
637 }
638
92701024 639 $timeframe = new stdClass();
8c25f6f2 640 $timeframe->begin = 0;
641 $timeframe->end = 0;
642 if(preg_match('{<role\b.*?<timeframe>(.+?)</timeframe>.*?</role>}is', $mmatch[1], $matches)){
643 $timeframe = $this->decode_timeframe($matches[1]);
644 }
645 if(preg_match('{<role\b.*?<extension>.*?<cohort>(.+?)</cohort>.*?</extension>.*?</role>}is', $mmatch[1], $matches)){
646 $member->groupname = trim($matches[1]);
647 // The actual processing (ensuring a group record exists, etc) occurs below, in the enrol-a-student clause
648 }
2dcccb0f 649
55bcef29 650 $rolecontext = context_course::instance($ship->courseid);
26ba0912 651 $rolecontext = $rolecontext->id; // All we really want is the ID
652//$this->log_line("Context instance for course $ship->courseid is...");
653//print_r($rolecontext);
2dcccb0f 654
8c25f6f2 655 // Add or remove this student or teacher to the course...
50c5bef4 656 $memberstoreobj->userid = $DB->get_field('user', 'id', array('idnumber'=>$member->idnumber));
8c25f6f2 657 $memberstoreobj->enrol = 'imsenterprise';
658 $memberstoreobj->course = $ship->courseid;
659 $memberstoreobj->time = time();
660 $memberstoreobj->timemodified = time();
26ba0912 661 if($memberstoreobj->userid){
2dcccb0f 662
26ba0912 663 // Decide the "real" role (i.e. the Moodle role) that this user should be assigned to.
664 // Zero means this roletype is supposed to be skipped.
665 $moodleroleid = $this->rolemappings[$member->roletype];
fa36ca3d 666 if(!$moodleroleid) {
26ba0912 667 $this->log_line("SKIPPING role $member->roletype for $memberstoreobj->userid ($member->idnumber) in course $memberstoreobj->course");
668 continue;
669 }
2dcccb0f 670
fa36ca3d
AB
671 if(intval($member->status) == 1) {
672 // Enrol the member
26ba0912 673
fa36ca3d
AB
674 $einstance = $DB->get_record('enrol',
675 array('courseid' => $courseobj->id, 'enrol' => $memberstoreobj->enrol));
676 if (empty($einstance)) {
677 // Only add an enrol instance to the course if non-existent
678 $enrolid = $this->add_instance($courseobj);
679 $einstance = $DB->get_record('enrol', array('id' => $enrolid));
680 }
26ba0912 681
fa36ca3d
AB
682 $this->enrol_user($einstance, $memberstoreobj->userid, $moodleroleid, $timeframe->begin, $timeframe->end);
683
684 $this->log_line("Enrolled user #$memberstoreobj->userid ($member->idnumber) to role $member->roletype in course $memberstoreobj->course");
685 $memberstally++;
686
687 // At this point we can also ensure the group membership is recorded if present
688 if(isset($member->groupname)){
689 // Create the group if it doesn't exist - either way, make sure we know the group ID
690 if(isset($groupids[$member->groupname])) {
691 $member->groupid = $groupids[$member->groupname]; // Recall the group ID from cache if available
692 } else {
fb57247e 693 if($groupid = $DB->get_field('groups', 'id', array('courseid'=>$ship->courseid, 'name'=>$member->groupname))){
fa36ca3d
AB
694 $member->groupid = $groupid;
695 $groupids[$member->groupname] = $groupid; // Store ID in cache
696 } else {
697 // Attempt to create the group
92701024 698 $group = new stdClass();
fa36ca3d
AB
699 $group->name = $member->groupname;
700 $group->courseid = $ship->courseid;
701 $group->timecreated = time();
702 $group->timemodified = time();
703 $groupid = $DB->insert_record('groups', $group);
704 $this->log_line('Added a new group for this course: '.$group->name);
705 $groupids[$member->groupname] = $groupid; // Store ID in cache
706 $member->groupid = $groupid;
26ba0912 707 }
fa36ca3d
AB
708 }
709 // Add the user-to-group association if it doesn't already exist
710 if($member->groupid) {
1d1917ae 711 groups_add_member($member->groupid, $memberstoreobj->userid,
712 'enrol_imsenterprise', $einstance->id);
fa36ca3d
AB
713 }
714 } // End of group-enrolment (from member.role.extension.cohort tag)
715
716 } elseif ($this->get_config('imsunenrol')) {
717 // Unenrol member
718
719 $einstances = $DB->get_records('enrol',
720 array('enrol' => $memberstoreobj->enrol, 'courseid' => $courseobj->id));
721 foreach ($einstances as $einstance) {
722 // Unenrol the user from all imsenterprise enrolment instances
723 $this->unenrol_user($einstance, $memberstoreobj->userid);
8c25f6f2 724 }
26ba0912 725
df997f84
PS
726 $membersuntally++;
727 $this->log_line("Unenrolled $member->idnumber from role $moodleroleid in course");
26ba0912 728 }
2dcccb0f 729
8c25f6f2 730 }
8c25f6f2 731 }
26ba0912 732 $this->log_line("Added $memberstally users to course $ship->coursecode");
733 if($membersuntally > 0){
734 $this->log_line("Removed $membersuntally users from course $ship->coursecode");
8c25f6f2 735 }
736 }
737} // End process_membership_tag()
738
739/**
740* Process the properties tag. The only data from this element
741* that is relevant is whether a <target> is specified.
742* @param string $tagconents The raw contents of the XML element
743*/
744function process_properties_tag($tagcontents){
fa36ca3d 745 $imsrestricttarget = $this->get_config('imsrestricttarget');
2dcccb0f 746
fa36ca3d
AB
747 if ($imsrestricttarget) {
748 if(!(preg_match('{<target>'.preg_quote($imsrestricttarget).'</target>}is', $tagcontents, $matches))){
749 $this->log_line("Skipping processing: required target \"$imsrestricttarget\" not specified in this data.");
8c25f6f2 750 $this->continueprocessing = false;
751 }
752 }
753}
754
755/**
756* Store logging information. This does two things: uses the {@link mtrace()}
2dcccb0f 757* function to print info to screen/STDOUT, and also writes log to a text file
8c25f6f2 758* if a path has been specified.
759* @param string $string Text to write (newline will be added automatically)
760*/
761function log_line($string){
2b8f692f
DM
762
763 if (!PHPUNIT_TEST) {
764 mtrace($string);
765 }
8c25f6f2 766 if($this->logfp) {
767 fwrite($this->logfp, $string . "\n");
768 }
769}
770
771/**
772* Process the INNER contents of a <timeframe> tag, to return beginning/ending dates.
773*/
774function decode_timeframe($string){ // Pass me the INNER CONTENTS of a <timeframe> tag - beginning and/or ending is returned, in unix time, zero indicating not specified
92701024 775 $ret = new stdClass();
8c25f6f2 776 $ret->begin = $ret->end = 0;
777 // Explanatory note: The matching will ONLY match if the attribute restrict="1"
778 // because otherwise the time markers should be ignored (participation should be
779 // allowed outside the period)
780 if(preg_match('{<begin\s+restrict="1">(\d\d\d\d)-(\d\d)-(\d\d)</begin>}is', $string, $matches)){
781 $ret->begin = mktime(0,0,0, $matches[2], $matches[3], $matches[1]);
782 }
783 if(preg_match('{<end\s+restrict="1">(\d\d\d\d)-(\d\d)-(\d\d)</end>}is', $string, $matches)){
784 $ret->end = mktime(0,0,0, $matches[2], $matches[3], $matches[1]);
785 }
786 return $ret;
787} // End decode_timeframe
788
26ba0912 789/**
2dcccb0f 790* Load the role mappings (from the config), so we can easily refer to
26ba0912 791* how an IMS-E role corresponds to a Moodle role
792*/
793function load_role_mappings() {
fa36ca3d 794 require_once('locallib.php');
50c5bef4 795 global $DB;
796
fa36ca3d
AB
797 $imsroles = new imsenterprise_roles();
798 $imsroles = $imsroles->get_imsroles();
799
26ba0912 800 $this->rolemappings = array();
fa36ca3d 801 foreach($imsroles as $imsrolenum=>$imsrolename) {
365bf676 802 $this->rolemappings[$imsrolenum] = $this->rolemappings[$imsrolename] = $this->get_config('imsrolemap' . $imsrolenum);
26ba0912 803 }
804}
805
a65c11b0
AS
806 /**
807 * Load the name mappings (from the config), so we can easily refer to
66dad719 808 * how an IMS-E course properties corresponds to a Moodle course properties
a65c11b0
AS
809 */
810 function load_course_mappings() {
811 require_once('locallib.php');
812
66dad719
DM
813 $imsnames = new imsenterprise_courses();
814 $courseattrs = $imsnames->get_courseattrs();
a65c11b0
AS
815
816 $this->coursemappings = array();
66dad719
DM
817 foreach($courseattrs as $courseattr) {
818 $this->coursemappings[$courseattr] = $this->get_config('imscoursemap' . $courseattr);
a65c11b0
AS
819 }
820 }
821
1d1917ae 822 /**
823 * Called whenever anybody tries (from the normal interface) to remove a group
824 * member which is registered as being created by this component. (Not called
825 * when deleting an entire group or course at once.)
826 * @param int $itemid Item ID that was stored in the group_members entry
827 * @param int $groupid Group ID
828 * @param int $userid User ID being removed from group
829 * @return bool True if the remove is permitted, false to give an error
830 */
831 function enrol_imsenterprise_allow_group_member_remove($itemid, $groupid, $userid) {
832 return false;
833 }
834
a65c11b0 835
8c25f6f2 836} // end of class
837
4317f92f 838