MDL-21782 reworked enrolment framework, the core infrastructure is in place, the...
[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 == 0) {
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 == 1) {
271                 // keep - only adding enrolments
273             } else if ($unenrolaction == 2) {
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                 }
283             }
284         }
286         // close db connection
287         $extdb->Close();
288     }
290     /**
291      * Performs a full sync with external database.
292      *
293      * First it creates new courses if necessary, then
294      * enrols and unenrols users.
295      * @return void
296      */
297     public function sync_courses() {
298         global $CFG, $DB;
300         // make sure we sync either enrolments or courses
301         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')) {
302             return;
303         }
305         // we may need a lot of memory here
306         @set_time_limit(0);
307         @raise_memory_limit("512M");
309         $extdb = $this->db_init();
311         // first create new courses
312         $table     = $this->get_config('newcoursetable');
313         $fullname  = strtolower($this->get_config('newcoursefullname'));
314         $shortname = strtolower($this->get_config('newcourseshortname'));
315         $idnumber  = strtolower($this->get_config('newcourseidnumber'));
316         $category  = strtolower($this->get_config('newcoursecategory'));
318         $fields = array($fullname, $shortname, $idnumber);
319         if ($category) {
320             $fields[] = $category;
321         }
322         if ($idnumber) {
323             $fields[] = $idnumber;
324         }
325         $sql = $this->db_get_sql($table, array(), $fields);
326         $createcourses = array();
327         if ($rs = $extdb->Execute($sql)) {
328             if (!$rs->EOF) {
329                 $courselist = array();
330                 while ($fields = $rs->FetchRow()) {
331                     $fields = array_change_key_case($fields, CASE_LOWER);
332                     if (empty($fields[$shortname]) or empty($fields[$fullname])) {
333                         //invalid record - these two are mandatory
334                         continue;
335                     }
336                     $fields = $this->db_decode($fields);
337                     if ($DB->record_exists('course', array('shortname'=>$fields[$shortname]))) {
338                         // already exists
339                         continue;
340                     }
341                     if ($idnumber and $DB->record_exists('course', array('idnumber'=>$fields[$idnumber]))) {
342                         // idnumber duplicates are not allowed
343                         continue;
344                     }
345                     if ($category and !$DB->record_exists('course_categories', array('id'=>$fields[$category]))) {
346                         // invalid category id, better to skip
347                         continue;
348                     }
349                     $course = new object();
350                     $course->fullname  = $fields[$fullname];
351                     $course->shortname = $fields[$shortname];
352                     $course->idnumber  = $idnumber ? $fields[$idnumber] : NULL;
353                     $course->category  = $category ? $fields[$category] : NULL;
354                     $createcourses[] = $course;
355                 }
356             }
357             $rs->Close();
358         } else {
359             debugging('Error while communicating with external enrolment database');
360             $extdb->Close();
361             return;
362         }
363         if ($createcourses) {
364             require_once("$CFG->dirroot/course/lib.php");
366             $template        = $this->get_config('templatecourse');
367             $defaultcategory = $this->get_config('defaultcategory');
369             if ($template) {
370                 if ($template = $DB->get_record('course', array('shortname'=>$template))) {
371                     unset($template->id);
372                     unset($template->fullname);
373                     unset($template->shortname);
374                     unset($template->idnumber);
375                 } else {
376                     $template = new object();
377                 }
378             } else {
379                 $template = new object();
380             }
381             if (!$DB->record_exists('course_categories', array('id'=>$defaultcategory))) {
382                 $categories = $DB->get_records('course_categories', array(), 'sortorder', 'id', 0, 1);
383                 $first = reset($categories);
384                 $defaultcategory = $first->id;
385             }
387             foreach ($createcourses as $fields) {
388                 $newcourse = clone($template);
389                 $newcourse->fullname  = $fields->fullname;
390                 $newcourse->shortname = $fields->shortname;
391                 $newcourse->idnumber  = $fields->idnumber;
392                 $newcourse->category  = $fields->category ? $fields->category : $defaultcategory;
394                 create_course($newcourse);
395             }
397             unset($createcourses);
398             unset($template);
399         }
401         // close db connection
402         $extdb->Close();
403     }
405     protected function db_get_sql($table, array $conditions, array $fields, $distinct = false, $sort = "") {
406         $fields = $fields ? implode(',', $fields) : "*";
407         $where = array();
408         if ($conditions) {
409             foreach ($conditions as $key=>$value) {
410                 $value = $this->db_encode($this->db_addslashes($value));
412                 $where[] = "$key = '$value'";
413             }
414         }
415         $where = $where ? "WHERE ".implode(" AND ", $where) : "";
416         $sort = $sort ? "ORDER BY $sort" : "";
417         $distinct = $distinct ? "DISTINCT" : "";
418         $sql = "SELECT $distinct $fields
419                   FROM $table
420                  $where
421                   $sort";
423         return $sql;
424     }
426     protected function db_init() {
427         global $CFG;
429         require_once($CFG->libdir.'/adodb/adodb.inc.php');
431         // Connect to the external database (forcing new connection)
432         $extdb = ADONewConnection($this->get_config('dbtype'));
433         if ($this->get_config('debugdb')) {
434             $extdb->debug = true;
435             ob_start(); //start output buffer to allow later use of the page headers
436         }
438         $extdb->Connect($this->get_config('dbhost'), $this->get_config('dbuser'), $this->get_config('dbpass'), $this->get_config('dbname'), true);
439         $extdb->SetFetchMode(ADODB_FETCH_ASSOC);
440         if ($this->get_config('dbsetupsql')) {
441             $extdb->Execute($this->get_config('dbsetupsql'));
442         }
443         return $extdb;
444     }
446     protected function db_addslashes($text) {
447         // using custom made function for now
448         if ($this->get_config('dbsybasequoting')) {
449             $text = str_replace('\\', '\\\\', $text);
450             $text = str_replace(array('\'', '"', "\0"), array('\\\'', '\\"', '\\0'), $text);
451         } else {
452             $text = str_replace("'", "''", $text);
453         }
454         return $text;
455     }
457     protected function db_encode($text) {
458         $dbenc = $this->get_config('dbencoding');
459         if (empty($dbenc) or $dbenc == 'utf-8') {
460             return $text;
461         }
462         if (is_array($text)) {
463             foreach($text as $k=>$value) {
464                 $text[$k] = $this->db_encode($value);
465             }
466             return $text;
467         } else {
468             return textlib_get_instance()->convert($text, 'utf-8', $dbenc);
469         }
470     }
472     protected function db_decode($text) {
473         $dbenc = $this->get_config('dbencoding');
474         if (empty($dbenc) or $dbenc == 'utf-8') {
475             return $text;
476         }
477         if (is_array($text)) {
478             foreach($text as $k=>$value) {
479                 $text[$k] = $this->db_decode($value);
480             }
481             return $text;
482         } else {
483             return textlib_get_instance()->convert($text, $dbenc, 'utf-8');
484         }
485     }