MDL-69542 enrol_lti: add LTI Advantage member sync task
[moodle.git] / enrol / lti / classes / task / sync_members.php
blobdca4becd58c09f253616687bbd4670375e4b395a
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.
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/>.
17 /**
18 * Handles synchronising members using the enrolment LTI.
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
25 namespace enrol_lti\task;
27 defined('MOODLE_INTERNAL') || die();
29 use core\task\scheduled_task;
30 use core_user;
31 use enrol_lti\data_connector;
32 use enrol_lti\helper;
33 use IMSGlobal\LTI\ToolProvider\Context;
34 use IMSGlobal\LTI\ToolProvider\ResourceLink;
35 use IMSGlobal\LTI\ToolProvider\ToolConsumer;
36 use IMSGlobal\LTI\ToolProvider\User;
37 use stdClass;
39 require_once($CFG->dirroot . '/user/lib.php');
41 /**
42 * Task for synchronising members using the enrolment LTI.
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
48 class sync_members extends scheduled_task {
50 /** @var array Array of user photos. */
51 protected $userphotos = [];
53 /** @var array Array of current LTI users. */
54 protected $currentusers = [];
56 /** @var data_connector $dataconnector A data_connector instance. */
57 protected $dataconnector;
59 /**
60 * Get a descriptive name for this task.
62 * @return string
64 public function get_name() {
65 return get_string('tasksyncmembers', 'enrol_lti');
68 /**
69 * Performs the synchronisation of members.
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;
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;
84 $this->dataconnector = new data_connector();
86 // Get all the enabled tools.
87 $tools = helper::get_lti_tools(array('status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1,
88 'ltiversion' => 'LTI-1p0/LTI-2p0'));
89 foreach ($tools as $tool) {
90 mtrace("Starting - Member sync for published tool '$tool->id' for course '$tool->courseid'.");
92 // Variables to keep track of information to display later.
93 $usercount = 0;
94 $enrolcount = 0;
95 $unenrolcount = 0;
97 // Fetch consumer records mapped to this tool.
98 $consumers = $this->dataconnector->get_consumers_mapped_to_tool($tool->id);
100 // Perform processing for each consumer.
101 foreach ($consumers as $consumer) {
102 mtrace("Requesting membership service for the tool consumer '{$consumer->getRecordId()}'");
104 // Get members through this tool consumer.
105 $members = $this->fetch_members_from_consumer($consumer);
107 // Check if we were able to fetch the members.
108 if ($members === false) {
109 mtrace("Skipping - Membership service request failed.\n");
110 continue;
113 // Fetched members count.
114 $membercount = count($members);
115 mtrace("$membercount members received.\n");
117 // Process member information.
118 list($usercount, $enrolcount) = $this->sync_member_information($tool, $consumer, $members);
121 // Now we check if we have to unenrol users who were not listed.
122 if ($this->should_sync_unenrol($tool->membersyncmode)) {
123 $unenrolcount = $this->sync_unenrol($tool);
126 mtrace("Completed - Synced members for tool '$tool->id' in the course '$tool->courseid'. " .
127 "Processed $usercount users; enrolled $enrolcount members; unenrolled $unenrolcount members.\n");
130 // Sync the user profile photos.
131 mtrace("Started - Syncing user profile images.");
132 $countsyncedimages = $this->sync_profile_images();
133 mtrace("Completed - Synced $countsyncedimages profile images.");
137 * Fetches the members that belong to a ToolConsumer.
139 * @param ToolConsumer $consumer
140 * @return bool|User[]
142 protected function fetch_members_from_consumer(ToolConsumer $consumer) {
143 $dataconnector = $this->dataconnector;
145 // Get membership URL template from consumer profile data.
146 $defaultmembershipsurl = null;
147 if (isset($consumer->profile->service_offered)) {
148 $servicesoffered = $consumer->profile->service_offered;
149 foreach ($servicesoffered as $service) {
150 if (isset($service->{'@id'}) && strpos($service->{'@id'}, 'tcp:ToolProxyBindingMemberships') !== false &&
151 isset($service->endpoint)) {
152 $defaultmembershipsurl = $service->endpoint;
153 if (isset($consumer->profile->product_instance->product_info->product_family->vendor->code)) {
154 $vendorcode = $consumer->profile->product_instance->product_info->product_family->vendor->code;
155 $defaultmembershipsurl = str_replace('{vendor_code}', $vendorcode, $defaultmembershipsurl);
157 $defaultmembershipsurl = str_replace('{product_code}', $consumer->getKey(), $defaultmembershipsurl);
158 break;
163 $members = false;
165 // Fetch the resource link linked to the consumer.
166 $resourcelink = $dataconnector->get_resourcelink_from_consumer($consumer);
167 if ($resourcelink !== null) {
168 // Try to perform a membership service request using this resource link.
169 $members = $this->do_resourcelink_membership_request($resourcelink);
172 // If membership service can't be performed through resource link, fallback through context memberships.
173 if ($members === false) {
174 // Fetch context records that are mapped to this ToolConsumer.
175 $contexts = $dataconnector->get_contexts_from_consumer($consumer);
177 // Perform membership service request for each of these contexts.
178 foreach ($contexts as $context) {
179 $contextmembership = $this->do_context_membership_request($context, $resourcelink, $defaultmembershipsurl);
180 if ($contextmembership) {
181 // Add $contextmembership contents to $members array.
182 if (is_array($members)) {
183 $members = array_merge($members, $contextmembership);
184 } else {
185 $members = $contextmembership;
191 return $members;
195 * Method to determine whether to sync unenrolments or not.
197 * @param int $syncmode The tool's membersyncmode.
198 * @return bool
200 protected function should_sync_unenrol($syncmode) {
201 return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_UNENROL_MISSING;
205 * Method to determine whether to sync enrolments or not.
207 * @param int $syncmode The tool's membersyncmode.
208 * @return bool
210 protected function should_sync_enrol($syncmode) {
211 return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_ENROL_NEW;
215 * Performs synchronisation of member information and enrolments.
217 * @param stdClass $tool
218 * @param ToolConsumer $consumer
219 * @param User[] $members
220 * @return array An array containing the number of members that were processed and the number of members that were enrolled.
222 protected function sync_member_information(stdClass $tool, ToolConsumer $consumer, $members) {
223 global $DB;
224 $usercount = 0;
225 $enrolcount = 0;
227 // Process member information.
228 foreach ($members as $member) {
229 $usercount++;
231 // Set the user data.
232 $user = new stdClass();
233 $user->username = helper::create_username($consumer->getKey(), $member->ltiUserId);
234 $user->firstname = core_user::clean_field($member->firstname, 'firstname');
235 $user->lastname = core_user::clean_field($member->lastname, 'lastname');
236 $user->email = core_user::clean_field($member->email, 'email');
238 // Get the user data from the LTI consumer.
239 $user = helper::assign_user_tool_data($tool, $user);
241 $dbuser = core_user::get_user_by_username($user->username, 'id');
242 if ($dbuser) {
243 // If email is empty remove it, so we don't update the user with an empty email.
244 if (empty($user->email)) {
245 unset($user->email);
248 $user->id = $dbuser->id;
249 user_update_user($user);
251 // Add the information to the necessary arrays.
252 if (!in_array($user->id, $this->currentusers)) {
253 $this->currentusers[] = $user->id;
255 $this->userphotos[$user->id] = $member->image;
256 } else {
257 if ($this->should_sync_enrol($tool->membersyncmode)) {
258 // If the email was stripped/not set then fill it with a default one. This
259 // stops the user from being redirected to edit their profile page.
260 if (empty($user->email)) {
261 $user->email = $user->username . "@example.com";
264 $user->auth = 'lti';
265 $user->id = user_create_user($user);
267 // Add the information to the necessary arrays.
268 $this->currentusers[] = $user->id;
269 $this->userphotos[$user->id] = $member->image;
273 // Sync enrolments.
274 if ($this->should_sync_enrol($tool->membersyncmode)) {
275 // Enrol the user in the course.
276 if (helper::enrol_user($tool, $user->id) === helper::ENROLMENT_SUCCESSFUL) {
277 // Increment enrol count.
278 $enrolcount++;
281 // Check if this user has already been registered in the enrol_lti_users table.
282 if (!$DB->record_exists('enrol_lti_users', ['toolid' => $tool->id, 'userid' => $user->id])) {
283 // Create an initial enrol_lti_user record that we can use later when syncing grades and members.
284 $userlog = new stdClass();
285 $userlog->userid = $user->id;
286 $userlog->toolid = $tool->id;
287 $userlog->consumerkey = $consumer->getKey();
289 $DB->insert_record('enrol_lti_users', $userlog);
294 return [$usercount, $enrolcount];
298 * Performs unenrolment of users that are no longer enrolled in the consumer side.
300 * @param stdClass $tool The tool record object.
301 * @return int The number of users that have been unenrolled.
303 protected function sync_unenrol(stdClass $tool) {
304 global $DB;
306 $ltiplugin = enrol_get_plugin('lti');
308 if (!$this->should_sync_unenrol($tool->membersyncmode)) {
309 return 0;
312 if (empty($this->currentusers)) {
313 return 0;
316 $unenrolcount = 0;
318 $ltiusersrs = $DB->get_recordset('enrol_lti_users', array('toolid' => $tool->id), 'lastaccess DESC', 'userid');
319 // Go through the users and check if any were never listed, if so, remove them.
320 foreach ($ltiusersrs as $ltiuser) {
321 if (!in_array($ltiuser->userid, $this->currentusers)) {
322 $instance = new stdClass();
323 $instance->id = $tool->enrolid;
324 $instance->courseid = $tool->courseid;
325 $instance->enrol = 'lti';
326 $ltiplugin->unenrol_user($instance, $ltiuser->userid);
327 // Increment unenrol count.
328 $unenrolcount++;
331 $ltiusersrs->close();
333 return $unenrolcount;
337 * Performs synchronisation of user profile images.
339 protected function sync_profile_images() {
340 $counter = 0;
341 foreach ($this->userphotos as $userid => $url) {
342 if ($url) {
343 $result = helper::update_user_profile_image($userid, $url);
344 if ($result === helper::PROFILE_IMAGE_UPDATE_SUCCESSFUL) {
345 $counter++;
346 mtrace("Profile image succesfully downloaded and created for user '$userid' from $url.");
347 } else {
348 mtrace($result);
352 return $counter;
356 * Performs membership service request using an LTI Context object.
358 * If the context has a 'custom_context_memberships_url' setting, we use this to perform the membership service request.
359 * Otherwise, if a context is associated with resource link, we try first to get the members using the
360 * ResourceLink::doMembershipsService() method.
361 * If we're still unable to fetch members from the resource link, we try to build a memberships URL from the memberships URL
362 * endpoint template that is defined in the ToolConsumer profile and substitute the parameters accordingly.
364 * @param Context $context The context object.
365 * @param ResourceLink $resourcelink The resource link object.
366 * @param string $membershipsurltemplate The memberships endpoint URL template.
367 * @return bool|User[] Array of User objects upon successful membership service request. False, otherwise.
369 protected function do_context_membership_request(Context $context, ResourceLink $resourcelink = null,
370 $membershipsurltemplate = '') {
371 $dataconnector = $this->dataconnector;
373 // Flag to indicate whether to save the context later.
374 $contextupdated = false;
376 // If membership URL is not set, try to generate using the default membership URL from the consumer profile.
377 if (!$context->hasMembershipService()) {
378 if (empty($membershipsurltemplate)) {
379 mtrace("Skipping - No membership service available.\n");
380 return false;
383 if ($resourcelink === null) {
384 $resourcelink = $dataconnector->get_resourcelink_from_context($context);
387 if ($resourcelink !== null) {
388 // Try to perform a membership service request using this resource link.
389 $resourcelinkmembers = $this->do_resourcelink_membership_request($resourcelink);
390 if ($resourcelinkmembers) {
391 // If we're able to fetch members using this resource link, return these.
392 return $resourcelinkmembers;
396 // If fetching memberships through resource link failed and we don't have a memberships URL, build one from template.
397 mtrace("'custom_context_memberships_url' not set. Fetching default template: $membershipsurltemplate");
398 $membershipsurl = $membershipsurltemplate;
400 // Check if we need to fetch tool code.
401 $needstoolcode = strpos($membershipsurl, '{tool_code}') !== false;
402 if ($needstoolcode) {
403 $toolcode = false;
405 // Fetch tool code from the resource link data.
406 $lisresultsourcedidjson = $resourcelink->getSetting('lis_result_sourcedid');
407 if ($lisresultsourcedidjson) {
408 $lisresultsourcedid = json_decode($lisresultsourcedidjson);
409 if (isset($lisresultsourcedid->data->typeid)) {
410 $toolcode = $lisresultsourcedid->data->typeid;
414 if ($toolcode) {
415 // Substitute fetched tool code value.
416 $membershipsurl = str_replace('{tool_code}', $toolcode, $membershipsurl);
417 } else {
418 // We're unable to determine the tool code. End this processing.
419 return false;
423 // Get context_id parameter and substitute, if applicable.
424 $membershipsurl = str_replace('{context_id}', $context->getId(), $membershipsurl);
426 // Get context_type and substitute, if applicable.
427 if (strpos($membershipsurl, '{context_type}') !== false) {
428 $contexttype = $context->type !== null ? $context->type : 'CourseSection';
429 $membershipsurl = str_replace('{context_type}', $contexttype, $membershipsurl);
432 // Save this URL for the context's custom_context_memberships_url setting.
433 $context->setSetting('custom_context_memberships_url', $membershipsurl);
434 $contextupdated = true;
437 // Perform membership service request.
438 $url = $context->getSetting('custom_context_memberships_url');
439 mtrace("Performing membership service request from context with URL {$url}.");
440 $members = $context->getMembership();
442 // Save the context if membership request succeeded and if it has been updated.
443 if ($members && $contextupdated) {
444 $context->save();
447 return $members;
451 * Performs membership service request using ResourceLink::doMembershipsService() method.
453 * @param ResourceLink $resourcelink
454 * @return bool|User[] Array of User objects upon successful membership service request. False, otherwise.
456 protected function do_resourcelink_membership_request(ResourceLink $resourcelink) {
457 $members = false;
458 $membershipsurl = $resourcelink->getSetting('ext_ims_lis_memberships_url');
459 $membershipsid = $resourcelink->getSetting('ext_ims_lis_memberships_id');
460 if ($membershipsurl && $membershipsid) {
461 mtrace("Performing membership service request from resource link with membership URL: " . $membershipsurl);
462 $members = $resourcelink->doMembershipsService(true);
464 return $members;