Proyectos de Subversion Moodle

Rev

| Ultima modificación | Ver Log |

Rev Autor Línea Nro. Línea
1 efrain 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
}