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 * Handles synchronising members using the enrolment LTI.
19 *
20 * @package    enrol_lti
21 * @copyright  2016 Mark Nelson <markn@moodle.com>
22 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
23 */
24
25namespace enrol_lti\task;
26
27defined('MOODLE_INTERNAL') || die();
28
29use core\task\scheduled_task;
30use core_user;
31use enrol_lti\data_connector;
32use enrol_lti\helper;
33use IMSGlobal\LTI\ToolProvider\Context;
34use IMSGlobal\LTI\ToolProvider\ResourceLink;
35use IMSGlobal\LTI\ToolProvider\ToolConsumer;
36use IMSGlobal\LTI\ToolProvider\User;
37use stdClass;
38
39require_once($CFG->dirroot . '/user/lib.php');
40
41/**
42 * Task for synchronising members using the enrolment LTI.
43 *
44 * @package    enrol_lti
45 * @copyright  2016 Mark Nelson <markn@moodle.com>
46 * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
47 */
48class sync_members extends scheduled_task {
49
50    /** @var array Array of user photos. */
51    protected $userphotos = [];
52
53    /** @var array Array of current LTI users. */
54    protected $currentusers = [];
55
56    /** @var data_connector $dataconnector A data_connector instance. */
57    protected $dataconnector;
58
59    /**
60     * Get a descriptive name for this task.
61     *
62     * @return string
63     */
64    public function get_name() {
65        return get_string('tasksyncmembers', 'enrol_lti');
66    }
67
68    /**
69     * Performs the synchronisation of members.
70     */
71    public function execute() {
72        if (!is_enabled_auth('lti')) {
73            mtrace('Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti')));
74            return;
75        }
76
77        // Check if the enrolment plugin is disabled - isn't really necessary as the task should not run if
78        // the plugin is disabled, but there is no harm in making sure core hasn't done something wrong.
79        if (!enrol_is_enabled('lti')) {
80            mtrace('Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti'));
81            return;
82        }
83
84        $this->dataconnector = new data_connector();
85
86        // Get all the enabled tools.
87        $tools = helper::get_lti_tools(array('status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1));
88        foreach ($tools as $tool) {
89            mtrace("Starting - Member sync for published tool '$tool->id' for course '$tool->courseid'.");
90
91            // Variables to keep track of information to display later.
92            $usercount = 0;
93            $enrolcount = 0;
94            $unenrolcount = 0;
95
96            // Fetch consumer records mapped to this tool.
97            $consumers = $this->dataconnector->get_consumers_mapped_to_tool($tool->id);
98
99            // Perform processing for each consumer.
100            foreach ($consumers as $consumer) {
101                mtrace("Requesting membership service for the tool consumer '{$consumer->getRecordId()}'");
102
103                // Get members through this tool consumer.
104                $members = $this->fetch_members_from_consumer($consumer);
105
106                // Check if we were able to fetch the members.
107                if ($members === false) {
108                    mtrace("Skipping - Membership service request failed.\n");
109                    continue;
110                }
111
112                // Fetched members count.
113                $membercount = count($members);
114                mtrace("$membercount members received.\n");
115
116                // Process member information.
117                list($usercount, $enrolcount) = $this->sync_member_information($tool, $consumer, $members);
118            }
119
120            // Now we check if we have to unenrol users who were not listed.
121            if ($this->should_sync_unenrol($tool->membersyncmode)) {
122                $unenrolcount = $this->sync_unenrol($tool);
123            }
124
125            mtrace("Completed - Synced members for tool '$tool->id' in the course '$tool->courseid'. " .
126                 "Processed $usercount users; enrolled $enrolcount members; unenrolled $unenrolcount members.\n");
127        }
128
129        // Sync the user profile photos.
130        mtrace("Started - Syncing user profile images.");
131        $countsyncedimages = $this->sync_profile_images();
132        mtrace("Completed - Synced $countsyncedimages profile images.");
133    }
134
135    /**
136     * Fetches the members that belong to a ToolConsumer.
137     *
138     * @param ToolConsumer $consumer
139     * @return bool|User[]
140     */
141    protected function fetch_members_from_consumer(ToolConsumer $consumer) {
142        $dataconnector = $this->dataconnector;
143
144        // Get membership URL template from consumer profile data.
145        $defaultmembershipsurl = null;
146        if (isset($consumer->profile->service_offered)) {
147            $servicesoffered = $consumer->profile->service_offered;
148            foreach ($servicesoffered as $service) {
149                if (isset($service->{'@id'}) && strpos($service->{'@id'}, 'tcp:ToolProxyBindingMemberships') !== false &&
150                    isset($service->endpoint)) {
151                    $defaultmembershipsurl = $service->endpoint;
152                    if (isset($consumer->profile->product_instance->product_info->product_family->vendor->code)) {
153                        $vendorcode = $consumer->profile->product_instance->product_info->product_family->vendor->code;
154                        $defaultmembershipsurl = str_replace('{vendor_code}', $vendorcode, $defaultmembershipsurl);
155                    }
156                    $defaultmembershipsurl = str_replace('{product_code}', $consumer->getKey(), $defaultmembershipsurl);
157                    break;
158                }
159            }
160        }
161
162        $members = false;
163
164        // Fetch the resource link linked to the consumer.
165        $resourcelink = $dataconnector->get_resourcelink_from_consumer($consumer);
166        if ($resourcelink !== null) {
167            // Try to perform a membership service request using this resource link.
168            $members = $this->do_resourcelink_membership_request($resourcelink);
169        }
170
171        // If membership service can't be performed through resource link, fallback through context memberships.
172        if ($members === false) {
173            // Fetch context records that are mapped to this ToolConsumer.
174            $contexts = $dataconnector->get_contexts_from_consumer($consumer);
175
176            // Perform membership service request for each of these contexts.
177            foreach ($contexts as $context) {
178                $contextmembership = $this->do_context_membership_request($context, $resourcelink, $defaultmembershipsurl);
179                if ($contextmembership) {
180                    // Add $contextmembership contents to $members array.
181                    if (is_array($members)) {
182                        $members = array_merge($members, $contextmembership);
183                    } else {
184                        $members = $contextmembership;
185                    }
186                }
187            }
188        }
189
190        return $members;
191    }
192
193    /**
194     * Method to determine whether to sync unenrolments or not.
195     *
196     * @param int $syncmode The tool's membersyncmode.
197     * @return bool
198     */
199    protected function should_sync_unenrol($syncmode) {
200        return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_UNENROL_MISSING;
201    }
202
203    /**
204     * Method to determine whether to sync enrolments or not.
205     *
206     * @param int $syncmode The tool's membersyncmode.
207     * @return bool
208     */
209    protected function should_sync_enrol($syncmode) {
210        return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_ENROL_NEW;
211    }
212
213    /**
214     * Performs synchronisation of member information and enrolments.
215     *
216     * @param stdClass $tool
217     * @param ToolConsumer $consumer
218     * @param User[] $members
219     * @return array An array containing the number of members that were processed and the number of members that were enrolled.
220     */
221    protected function sync_member_information(stdClass $tool, ToolConsumer $consumer, $members) {
222        global $DB;
223        $usercount = 0;
224        $enrolcount = 0;
225
226        // Process member information.
227        foreach ($members as $member) {
228            $usercount++;
229
230            // Set the user data.
231            $user = new stdClass();
232            $user->username = helper::create_username($consumer->getKey(), $member->ltiUserId);
233            $user->firstname = core_user::clean_field($member->firstname, 'firstname');
234            $user->lastname = core_user::clean_field($member->lastname, 'lastname');
235            $user->email = core_user::clean_field($member->email, 'email');
236
237            // Get the user data from the LTI consumer.
238            $user = helper::assign_user_tool_data($tool, $user);
239
240            $dbuser = core_user::get_user_by_username($user->username, 'id');
241            if ($dbuser) {
242                // If email is empty remove it, so we don't update the user with an empty email.
243                if (empty($user->email)) {
244                    unset($user->email);
245                }
246
247                $user->id = $dbuser->id;
248                user_update_user($user);
249
250                // Add the information to the necessary arrays.
251                if (!in_array($user->id, $this->currentusers)) {
252                    $this->currentusers[] = $user->id;
253                }
254                $this->userphotos[$user->id] = $member->image;
255            } else {
256                if ($this->should_sync_enrol($tool->membersyncmode)) {
257                    // If the email was stripped/not set then fill it with a default one. This
258                    // stops the user from being redirected to edit their profile page.
259                    if (empty($user->email)) {
260                        $user->email = $user->username .  "@example.com";
261                    }
262
263                    $user->auth = 'lti';
264                    $user->id = user_create_user($user);
265
266                    // Add the information to the necessary arrays.
267                    $this->currentusers[] = $user->id;
268                    $this->userphotos[$user->id] = $member->image;
269                }
270            }
271
272            // Sync enrolments.
273            if ($this->should_sync_enrol($tool->membersyncmode)) {
274                // Enrol the user in the course.
275                if (helper::enrol_user($tool, $user->id) === helper::ENROLMENT_SUCCESSFUL) {
276                    // Increment enrol count.
277                    $enrolcount++;
278                }
279
280                // Check if this user has already been registered in the enrol_lti_users table.
281                if (!$DB->record_exists('enrol_lti_users', ['toolid' => $tool->id, 'userid' => $user->id])) {
282                    // Create an initial enrol_lti_user record that we can use later when syncing grades and members.
283                    $userlog = new stdClass();
284                    $userlog->userid = $user->id;
285                    $userlog->toolid = $tool->id;
286                    $userlog->consumerkey = $consumer->getKey();
287
288                    $DB->insert_record('enrol_lti_users', $userlog);
289                }
290            }
291        }
292
293        return [$usercount, $enrolcount];
294    }
295
296    /**
297     * Performs unenrolment of users that are no longer enrolled in the consumer side.
298     *
299     * @param stdClass $tool The tool record object.
300     * @return int The number of users that have been unenrolled.
301     */
302    protected function sync_unenrol(stdClass $tool) {
303        global $DB;
304
305        $ltiplugin = enrol_get_plugin('lti');
306
307        if (!$this->should_sync_unenrol($tool->membersyncmode)) {
308            return 0;
309        }
310
311        if (empty($this->currentusers)) {
312            return 0;
313        }
314
315        $unenrolcount = 0;
316
317        $ltiusersrs = $DB->get_recordset('enrol_lti_users', array('toolid' => $tool->id), 'lastaccess DESC', 'userid');
318        // Go through the users and check if any were never listed, if so, remove them.
319        foreach ($ltiusersrs as $ltiuser) {
320            if (!in_array($ltiuser->userid, $this->currentusers)) {
321                $instance = new stdClass();
322                $instance->id = $tool->enrolid;
323                $instance->courseid = $tool->courseid;
324                $instance->enrol = 'lti';
325                $ltiplugin->unenrol_user($instance, $ltiuser->userid);
326                // Increment unenrol count.
327                $unenrolcount++;
328            }
329        }
330        $ltiusersrs->close();
331
332        return $unenrolcount;
333    }
334
335    /**
336     * Performs synchronisation of user profile images.
337     */
338    protected function sync_profile_images() {
339        $counter = 0;
340        foreach ($this->userphotos as $userid => $url) {
341            if ($url) {
342                $result = helper::update_user_profile_image($userid, $url);
343                if ($result === helper::PROFILE_IMAGE_UPDATE_SUCCESSFUL) {
344                    $counter++;
345                    mtrace("Profile image succesfully downloaded and created for user '$userid' from $url.");
346                } else {
347                    mtrace($result);
348                }
349            }
350        }
351        return $counter;
352    }
353
354    /**
355     * Performs membership service request using an LTI Context object.
356     *
357     * If the context has a 'custom_context_memberships_url' setting, we use this to perform the membership service request.
358     * Otherwise, if a context is associated with resource link, we try first to get the members using the
359     * ResourceLink::doMembershipsService() method.
360     * If we're still unable to fetch members from the resource link, we try to build a memberships URL from the memberships URL
361     * endpoint template that is defined in the ToolConsumer profile and substitute the parameters accordingly.
362     *
363     * @param Context $context The context object.
364     * @param ResourceLink $resourcelink The resource link object.
365     * @param string $membershipsurltemplate The memberships endpoint URL template.
366     * @return bool|User[] Array of User objects upon successful membership service request. False, otherwise.
367     */
368    protected function do_context_membership_request(Context $context, ResourceLink $resourcelink = null,
369                                                     $membershipsurltemplate = '') {
370        $dataconnector = $this->dataconnector;
371
372        // Flag to indicate whether to save the context later.
373        $contextupdated = false;
374
375        // If membership URL is not set, try to generate using the default membership URL from the consumer profile.
376        if (!$context->hasMembershipService()) {
377            if (empty($membershipsurltemplate)) {
378                mtrace("Skipping - No membership service available.\n");
379                return false;
380            }
381
382            if ($resourcelink === null) {
383                $resourcelink = $dataconnector->get_resourcelink_from_context($context);
384            }
385
386            if ($resourcelink !== null) {
387                // Try to perform a membership service request using this resource link.
388                $resourcelinkmembers = $this->do_resourcelink_membership_request($resourcelink);
389                if ($resourcelinkmembers) {
390                    // If we're able to fetch members using this resource link, return these.
391                    return $resourcelinkmembers;
392                }
393            }
394
395            // If fetching memberships through resource link failed and we don't have a memberships URL, build one from template.
396            mtrace("'custom_context_memberships_url' not set. Fetching default template: $membershipsurltemplate");
397            $membershipsurl = $membershipsurltemplate;
398
399            // Check if we need to fetch tool code.
400            $needstoolcode = strpos($membershipsurl, '{tool_code}') !== false;
401            if ($needstoolcode) {
402                $toolcode = false;
403
404                // Fetch tool code from the resource link data.
405                $lisresultsourcedidjson = $resourcelink->getSetting('lis_result_sourcedid');
406                if ($lisresultsourcedidjson) {
407                    $lisresultsourcedid = json_decode($lisresultsourcedidjson);
408                    if (isset($lisresultsourcedid->data->typeid)) {
409                        $toolcode = $lisresultsourcedid->data->typeid;
410                    }
411                }
412
413                if ($toolcode) {
414                    // Substitute fetched tool code value.
415                    $membershipsurl = str_replace('{tool_code}', $toolcode, $membershipsurl);
416                } else {
417                    // We're unable to determine the tool code. End this processing.
418                    return false;
419                }
420            }
421
422            // Get context_id parameter and substitute, if applicable.
423            $membershipsurl = str_replace('{context_id}', $context->getId(), $membershipsurl);
424
425            // Get context_type and substitute, if applicable.
426            if (strpos($membershipsurl, '{context_type}') !== false) {
427                $contexttype = $context->type !== null ? $context->type : 'CourseSection';
428                $membershipsurl = str_replace('{context_type}', $contexttype, $membershipsurl);
429            }
430
431            // Save this URL for the context's custom_context_memberships_url setting.
432            $context->setSetting('custom_context_memberships_url', $membershipsurl);
433            $contextupdated = true;
434        }
435
436        // Perform membership service request.
437        $url = $context->getSetting('custom_context_memberships_url');
438        mtrace("Performing membership service request from context with URL {$url}.");
439        $members = $context->getMembership();
440
441        // Save the context if membership request succeeded and if it has been updated.
442        if ($members && $contextupdated) {
443            $context->save();
444        }
445
446        return $members;
447    }
448
449    /**
450     * Performs membership service request using ResourceLink::doMembershipsService() method.
451     *
452     * @param ResourceLink $resourcelink
453     * @return bool|User[] Array of User objects upon successful membership service request. False, otherwise.
454     */
455    protected function do_resourcelink_membership_request(ResourceLink $resourcelink) {
456        $members = false;
457        $membershipsurl = $resourcelink->getSetting('ext_ims_lis_memberships_url');
458        $membershipsid = $resourcelink->getSetting('ext_ims_lis_memberships_id');
459        if ($membershipsurl && $membershipsid) {
460            mtrace("Performing membership service request from resource link with membership URL: " . $membershipsurl);
461            $members = $resourcelink->doMembershipsService(true);
462        }
463        return $members;
464    }
465}
466