MDL-23151 new ext sync option and minor refactoring
[moodle.git] / enrol / database / lib.php
1 <?php
3 // This file is part of Moodle - http://moodle.org/
4 //
5 // Moodle is free software: you can redistribute it and/or modify
6 // it under the terms of the GNU General Public License as published by
7 // the Free Software Foundation, either version 3 of the License, or
8 // (at your option) any later version.
9 //
10 // Moodle is distributed in the hope that it will be useful,
11 // but WITHOUT ANY WARRANTY; without even the implied warranty of
12 // MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13 // GNU General Public License for more details.
14 //
15 // You should have received a copy of the GNU General Public License
16 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
18 /**
19  * Database enrolment plugin.
20  *
21  * This plugin synchronises enrolment and roles with external database table.
22  *
23  * @package   enrol_database
24  * @copyright 2010 Petr Skoda {@link http://skodak.org}
25  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
26  */
28 /**
29  * Database enrolment plugin implementation.
30  * @author  Petr Skoda - based on code by Martin Dougiamas, Martin Langhoff and others
31  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
32  */
33 class enrol_database_plugin extends enrol_plugin {
34     /**
35      * Is it possible to delete enrol instance via standard UI?
36      *
37      * @param object $instance
38      * @return bool
39      */
40     public function instance_deleteable($instance) {
41         if (!enrol_is_enabled('database')) {
42             return true;
43         }
44         if (!$this->get_config('dbtype') or !$this->get_config('dbhost') or !$this->get_config('remoteenroltable') or !$this->get_config('remotecoursefield') or !$this->get_config('remoteuserfield')) {
45             return true;
46         }
48         //TODO: connect to external system and make sure no users are to be enrolled in this course
49         return false;
50     }
52     /**
53      * Forces synchronisation of user enrolments with external database.
54      *
55      * @param object $user user record
56      * @return void
57      */
58     public function sync_user_enrolments($user = NULL) {
60         //TODO: full sync with external system is very expensive, it could cause big perf problems if we did that during each log-in,
61         //      so do the sync only once in a while or rely on cron
62     }
64     /**
65      * Forces synchronisation of all enrolments with external database.
66      *
67      * @return void
68      */
69     public function sync_enrolments() {
70         global $CFG, $DB;
72         // we do not create courses here intentionally because it requires full sync and is slow
73         if (!$this->get_config('dbtype') or !$this->get_config('dbhost') or !$this->get_config('remoteenroltable') or !$this->get_config('remotecoursefield') or !$this->get_config('remoteuserfield')) {
74             return;
75         }
77         // we may need a lot of memory here
78         @set_time_limit(0);
79         @raise_memory_limit("512M");
81         $extdb = $this->db_init();
83         // second step is to sync instances and users
84         $table          = $this->get_config('remoteenroltable');
85         $coursefield    = strtolower($this->get_config('remotecoursefield'));
86         $userfield      = strtolower($this->get_config('remoteuserfield'));
87         $rolefield      = strtolower($this->get_config('remoterolefield'));
88         $localrolefield = $this->get_config('localrolefield');
89         $localuserfield = $this->get_config('localuserfield');
90         $unenrolaction  = $this->get_config('unenrolaction');
92         // create roles mapping
93         $allroles = get_all_roles();
94         $defaultrole = $this->get_config('defaultrole');
95         if (!isset($allroles[$defaultrole])) {
96             $defaultrole = 0;
97         }
98         $roles = array();
99         foreach ($allroles as $role) {
100             $roles[$role->$localrolefield] = $role->id;
101         }
103         // first find all existing courses with enrol instance
104         $localcoursefiled = $this->get_config('localcoursefield');
105         $sql = "SELECT c.id, c.visible, c.$localcoursefiled AS mapping, e.id AS enrolid
106                   FROM {course} c
107                   JOIN {enrol} e ON (e.courseid = c.id AND e.enrol = 'database')";
108         $existing = array();
109         $rs = $DB->get_recordset_sql($sql); // watch out for idnumber duplicates
110         foreach ($rs as $course) {
111             if (empty($course->mapping)) {
112                 continue;
113             }
114             $existing[$course->mapping] = $course;
115         }
116         $rs->close();
118         // add necessary enrol instances that are not present yet;
119         $sql = $this->db_get_sql($table, array(), array($coursefield), true);
120         if ($rs = $extdb->Execute($sql)) {
121             if (!$rs->EOF) {
122                 $sql = "SELECT c.id, c.visible
123                           FROM {course} c
124                           JOIN {enrol} e ON (e.courseid = c.id AND e.enrol = 'database')
125                          WHERE c.$localcoursefiled = :mapping";
126                 $params = array();
127                 while ($mapping = $rs->FetchRow()) {
128                     $mapping = reset($mapping);
129                     $mapping = $this->db_decode($mapping);
130                     if (!empty($mapping) and !isset($existing[$mapping])) {
131                         $params['mapping'] = $mapping;
132                         if ($course = $DB->get_record_sql($sql, $params, IGNORE_MULTIPLE)) {
133                             $new = new object();
134                             $new->id      = $course->id;
135                             $new->visible = $course->visible;
136                             $new->mapping = $mapping;
137                             $new->enrolid = $this->add_instance($course);
138                             $existing[$mapping] = $new;
139                         }
140                     }
141                 }
142             }
143             $rs->Close();
144         } else {
145             debugging('Error while communicating with external enrolment database');
146             $extdb->Close();
147             return;
148         }
150         // sync enrolments
151         $ignorehidden = $this->get_config('ignorehiddencourses');
152         $fields = array($userfield);
153         if ($rolefield) {
154             $fields[] = $rolefield;
155         }
156         foreach ($existing as $course) {
157             if ($ignorehidden and !$course->visible) {
158                 continue;
159             }
160             if (!$instance = $DB->get_record('enrol', array('id'=>$course->enrolid))) {
161                 continue; //weird
162             }
163             $context = get_context_instance(CONTEXT_COURSE, $course->id);
165             // get current list of enrolled users with their roles
166             $current_roles  = array();
167             $current_status = array();
168             $user_mapping   = array();
169             $sql = "SELECT u.$localuserfield AS mapping, u.id, ue.status, ue.userid, ra.roleid
170                       FROM {user} u
171                       JOIN {user_enrolments} ue ON (ue.userid = u.id AND ue.enrolid = :enrolid)
172                       JOIN {role_assignments} ra ON (ra.userid = u.id AND ra.itemid = ue.enrolid AND ra.component = 'enrol_database')
173                      WHERE u.deleted = 0";
174             $params = array('enrolid'=>$instance->id);
175             if ($localuserfield === 'username') {
176                 $sql .= " AND u.mnethostid = :mnethostid";
177                 $params['mnethostid'] = $CFG->mnet_localhost_id;
178             }
179             $rs = $DB->get_recordset_sql($sql, $params);
180             foreach ($rs as $ue) {
181                 $current_roles[$ue->userid][$ue->roleid] = $ue->roleid;
182                 $current_status[$ue->userid] = $ue->status;
183                 $user_mapping[$ue->mapping] = $ue->userid;
184             }
185             $rs->close();
187             // get list of users that need to be enrolled and their roles
188             $requested_roles = array();
189             $sql = $this->db_get_sql($table, array($coursefield=>$course->mapping), $fields);
190             if ($rs = $extdb->Execute($sql)) {
191                 if (!$rs->EOF) {
192                     if ($localuserfield === 'username') {
193                         $usersearch = array('mnethostid'=>$CFG->mnet_localhost_id, 'deleted' =>0);
194                     }
195                     while ($fields = $rs->FetchRow()) {
196                         $fields = array_change_key_case($fields, CASE_LOWER);
197                         if (empty($fields[$userfield])) {
198                             //user identification is mandatory!
199                         }
200                         $mapping = $fields[$userfield];
201                         if (!isset($user_mapping[$mapping])) {
202                             $usersearch[$localuserfield] = $mapping;
203                             if (!$user = $DB->get_record('user', $usersearch, 'id', IGNORE_MULTIPLE)) {
204                                 // user does not exist or was deleted
205                                 continue;
206                             }
207                             $user_mapping[$mapping] = $user->id;
208                             $userid = $user->id;
209                         } else {
210                             $userid = $user_mapping[$mapping];
211                         }
212                         if (empty($fields[$rolefield]) or !isset($roles[$fields[$rolefield]])) {
213                             if (!$defaultrole) {
214                                 // role is mandatory
215                                 continue;
216                             }
217                             $roleid = $defaultrole;
218                         } else {
219                             $roleid = $roles[$fields[$rolefield]];
220                         }
222                         $requested_roles[$userid][$roleid] = $roleid;
223                     }
224                 }
225                 $rs->Close();
226             } else {
227                 debugging('Error while communicating with external enrolment database');
228                 $extdb->Close();
229                 return;
230             }
231             unset($user_mapping);
233             // enrol all users and sync roles
234             foreach ($requested_roles as $userid=>$roles) {
235                 foreach ($roles as $roleid) {
236                     if (empty($current_roles[$userid])) {
237                         $this->enrol_user($instance, $userid, $roleid);
238                         $current_roles[$userid][$roleid] = $roleid;
239                         $current_status[$userid] = ENROL_USER_ACTIVE;
240                     }
241                 }
243                 // unassign removed roles
244                 foreach($current_roles[$userid] as $cr) {
245                     if (empty($roles[$cr])) {
246                         role_unassign($cr, $userid, $context->id, 'enrol_database', $instance->id);
247                         unset($current_roles[$userid][$cr]);
248                     }
249                 }
251                 // reenable enrolment when previously disable enrolment refreshed
252                 if ($current_status[$userid] == ENROL_USER_SUSPENDED) {
253                     $DB->set_field('user_enrolments', 'status', ENROL_USER_ACTIVE, array('enrolid'=>$instance->id, 'userid'=>$userid));
254                 }
255             }
257             // deal with enrolments removed from external table
258             if ($unenrolaction == ENROL_EXT_REMOVED_UNENROL) {
259                 // unenrol
260                 if (!empty($requested_roles)) {
261                     // we might get some error or connection problem, better not unenrol everybody
262                     foreach ($current_status as $userid=>$status) {
263                         if (isset($requested_roles[$userid])) {
264                             continue;
265                         }
266                         $this->unenrol_user($instance, $userid);
267                     }
268                 }
270             } else if ($unenrolaction == ENROL_EXT_REMOVED_KEEP) {
271                 // keep - only adding enrolments
273             } else if ($unenrolaction == ENROL_EXT_REMOVED_SUSPEND or $unenrolaction == ENROL_EXT_REMOVED_SUSPENDNOROLES) {
274                 // disable
275                 foreach ($current_status as $userid=>$status) {
276                     if (isset($requested_roles[$userid])) {
277                         continue;
278                     }
279                     if ($status != ENROL_USER_SUSPENDED) {
280                         $DB->set_field('user_enrolments', 'status', ENROL_USER_SUSPENDED, array('enrolid'=>$instance->id, 'userid'=>$userid));
281                     }
282                     if ($unenrolaction == ENROL_EXT_REMOVED_SUSPENDNOROLES) {
283                         role_unassign_all(array('contextid'=>$context->id, 'userid'=>$userid, 'component'=>'enrol_database', 'itemid'=>$instance->id));
284                     }
285                 }
286             }
287         }
289         // close db connection
290         $extdb->Close();
291     }
293     /**
294      * Performs a full sync with external database.
295      *
296      * First it creates new courses if necessary, then
297      * enrols and unenrols users.
298      * @return void
299      */
300     public function sync_courses() {
301         global $CFG, $DB;
303         // make sure we sync either enrolments or courses
304         if (!$this->get_config('dbtype') or !$this->get_config('dbhost') or $this->get_config('newcoursetable') or $this->get_config('newcoursefullname') or $this->get_config('newcourseshortname')) {
305             return;
306         }
308         // we may need a lot of memory here
309         @set_time_limit(0);
310         @raise_memory_limit("512M");
312         $extdb = $this->db_init();
314         // first create new courses
315         $table     = $this->get_config('newcoursetable');
316         $fullname  = strtolower($this->get_config('newcoursefullname'));
317         $shortname = strtolower($this->get_config('newcourseshortname'));
318         $idnumber  = strtolower($this->get_config('newcourseidnumber'));
319         $category  = strtolower($this->get_config('newcoursecategory'));
321         $fields = array($fullname, $shortname, $idnumber);
322         if ($category) {
323             $fields[] = $category;
324         }
325         if ($idnumber) {
326             $fields[] = $idnumber;
327         }
328         $sql = $this->db_get_sql($table, array(), $fields);
329         $createcourses = array();
330         if ($rs = $extdb->Execute($sql)) {
331             if (!$rs->EOF) {
332                 $courselist = array();
333                 while ($fields = $rs->FetchRow()) {
334                     $fields = array_change_key_case($fields, CASE_LOWER);
335                     if (empty($fields[$shortname]) or empty($fields[$fullname])) {
336                         //invalid record - these two are mandatory
337                         continue;
338                     }
339                     $fields = $this->db_decode($fields);
340                     if ($DB->record_exists('course', array('shortname'=>$fields[$shortname]))) {
341                         // already exists
342                         continue;
343                     }
344                     if ($idnumber and $DB->record_exists('course', array('idnumber'=>$fields[$idnumber]))) {
345                         // idnumber duplicates are not allowed
346                         continue;
347                     }
348                     if ($category and !$DB->record_exists('course_categories', array('id'=>$fields[$category]))) {
349                         // invalid category id, better to skip
350                         continue;
351                     }
352                     $course = new object();
353                     $course->fullname  = $fields[$fullname];
354                     $course->shortname = $fields[$shortname];
355                     $course->idnumber  = $idnumber ? $fields[$idnumber] : NULL;
356                     $course->category  = $category ? $fields[$category] : NULL;
357                     $createcourses[] = $course;
358                 }
359             }
360             $rs->Close();
361         } else {
362             debugging('Error while communicating with external enrolment database');
363             $extdb->Close();
364             return;
365         }
366         if ($createcourses) {
367             require_once("$CFG->dirroot/course/lib.php");
369             $template        = $this->get_config('templatecourse');
370             $defaultcategory = $this->get_config('defaultcategory');
372             if ($template) {
373                 if ($template = $DB->get_record('course', array('shortname'=>$template))) {
374                     unset($template->id);
375                     unset($template->fullname);
376                     unset($template->shortname);
377                     unset($template->idnumber);
378                 } else {
379                     $template = new object();
380                 }
381             } else {
382                 $template = new object();
383             }
384             if (!$DB->record_exists('course_categories', array('id'=>$defaultcategory))) {
385                 $categories = $DB->get_records('course_categories', array(), 'sortorder', 'id', 0, 1);
386                 $first = reset($categories);
387                 $defaultcategory = $first->id;
388             }
390             foreach ($createcourses as $fields) {
391                 $newcourse = clone($template);
392                 $newcourse->fullname  = $fields->fullname;
393                 $newcourse->shortname = $fields->shortname;
394                 $newcourse->idnumber  = $fields->idnumber;
395                 $newcourse->category  = $fields->category ? $fields->category : $defaultcategory;
397                 create_course($newcourse);
398             }
400             unset($createcourses);
401             unset($template);
402         }
404         // close db connection
405         $extdb->Close();
406     }
408     protected function db_get_sql($table, array $conditions, array $fields, $distinct = false, $sort = "") {
409         $fields = $fields ? implode(',', $fields) : "*";
410         $where = array();
411         if ($conditions) {
412             foreach ($conditions as $key=>$value) {
413                 $value = $this->db_encode($this->db_addslashes($value));
415                 $where[] = "$key = '$value'";
416             }
417         }
418         $where = $where ? "WHERE ".implode(" AND ", $where) : "";
419         $sort = $sort ? "ORDER BY $sort" : "";
420         $distinct = $distinct ? "DISTINCT" : "";
421         $sql = "SELECT $distinct $fields
422                   FROM $table
423                  $where
424                   $sort";
426         return $sql;
427     }
429     protected function db_init() {
430         global $CFG;
432         require_once($CFG->libdir.'/adodb/adodb.inc.php');
434         // Connect to the external database (forcing new connection)
435         $extdb = ADONewConnection($this->get_config('dbtype'));
436         if ($this->get_config('debugdb')) {
437             $extdb->debug = true;
438             ob_start(); //start output buffer to allow later use of the page headers
439         }
441         $extdb->Connect($this->get_config('dbhost'), $this->get_config('dbuser'), $this->get_config('dbpass'), $this->get_config('dbname'), true);
442         $extdb->SetFetchMode(ADODB_FETCH_ASSOC);
443         if ($this->get_config('dbsetupsql')) {
444             $extdb->Execute($this->get_config('dbsetupsql'));
445         }
446         return $extdb;
447     }
449     protected function db_addslashes($text) {
450         // using custom made function for now
451         if ($this->get_config('dbsybasequoting')) {
452             $text = str_replace('\\', '\\\\', $text);
453             $text = str_replace(array('\'', '"', "\0"), array('\\\'', '\\"', '\\0'), $text);
454         } else {
455             $text = str_replace("'", "''", $text);
456         }
457         return $text;
458     }
460     protected function db_encode($text) {
461         $dbenc = $this->get_config('dbencoding');
462         if (empty($dbenc) or $dbenc == 'utf-8') {
463             return $text;
464         }
465         if (is_array($text)) {
466             foreach($text as $k=>$value) {
467                 $text[$k] = $this->db_encode($value);
468             }
469             return $text;
470         } else {
471             return textlib_get_instance()->convert($text, 'utf-8', $dbenc);
472         }
473     }
475     protected function db_decode($text) {
476         $dbenc = $this->get_config('dbencoding');
477         if (empty($dbenc) or $dbenc == 'utf-8') {
478             return $text;
479         }
480         if (is_array($text)) {
481             foreach($text as $k=>$value) {
482                 $text[$k] = $this->db_decode($value);
483             }
484             return $text;
485         } else {
486             return textlib_get_instance()->convert($text, $dbenc, 'utf-8');
487         }
488     }