Moodle release 4.5rc1
[moodle.git] / enrol / lti / classes / task / sync_members.php
blob5aa77ed72b61cca8bcfb8de57f85d93026762d35
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 data_connector $dataconnector A data_connector instance. */
54 protected $dataconnector;
56 /**
57 * Get a descriptive name for this task.
59 * @return string
61 public function get_name() {
62 return get_string('tasksyncmembers', 'enrol_lti');
65 /**
66 * Performs the synchronisation of members.
68 public function execute() {
69 if (!is_enabled_auth('lti')) {
70 mtrace('Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti')));
71 return;
74 // Check if the enrolment plugin is disabled - isn't really necessary as the task should not run if
75 // the plugin is disabled, but there is no harm in making sure core hasn't done something wrong.
76 if (!enrol_is_enabled('lti')) {
77 mtrace('Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti'));
78 return;
81 $this->dataconnector = new data_connector();
83 // Get all the enabled tools.
84 $tools = helper::get_lti_tools(array('status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1,
85 'ltiversion' => 'LTI-1p0/LTI-2p0'));
86 foreach ($tools as $tool) {
87 mtrace("Starting - Member sync for published tool '$tool->id' for course '$tool->courseid'.");
89 // Variables to keep track of information to display later.
90 $usercount = 0;
91 $enrolcount = 0;
92 $unenrolcount = 0;
94 // Fetch consumer records mapped to this tool.
95 $consumers = $this->dataconnector->get_consumers_mapped_to_tool($tool->id);
97 // Perform processing for each consumer.
98 foreach ($consumers as $consumer) {
99 mtrace("Requesting membership service for the tool consumer '{$consumer->getRecordId()}'");
101 // Get members through this tool consumer.
102 $members = $this->fetch_members_from_consumer($consumer);
104 // Check if we were able to fetch the members.
105 if ($members === false) {
106 mtrace("Skipping - Membership service request failed.\n");
107 continue;
110 // Fetched members count.
111 $membercount = count($members);
112 $usercount += $membercount;
113 mtrace("$membercount members received.\n");
115 // Process member information.
116 list($users, $enrolledcount) = $this->sync_member_information($tool, $consumer, $members);
117 $enrolcount += $enrolledcount;
119 // Now sync unenrolments for the consumer.
120 $unenrolcount += $this->sync_unenrol($tool, $consumer->getKey(), $users);
123 mtrace("Completed - Synced members for tool '$tool->id' in the course '$tool->courseid'. " .
124 "Processed $usercount users; enrolled $enrolcount members; unenrolled $unenrolcount members.\n");
127 // Sync the user profile photos.
128 mtrace("Started - Syncing user profile images.");
129 $countsyncedimages = $this->sync_profile_images();
130 mtrace("Completed - Synced $countsyncedimages profile images.");
134 * Fetches the members that belong to a ToolConsumer.
136 * @param ToolConsumer $consumer
137 * @return bool|User[]
139 protected function fetch_members_from_consumer(ToolConsumer $consumer) {
140 $dataconnector = $this->dataconnector;
142 // Get membership URL template from consumer profile data.
143 $defaultmembershipsurl = null;
144 if (isset($consumer->profile->service_offered)) {
145 $servicesoffered = $consumer->profile->service_offered;
146 foreach ($servicesoffered as $service) {
147 if (isset($service->{'@id'}) && strpos($service->{'@id'}, 'tcp:ToolProxyBindingMemberships') !== false &&
148 isset($service->endpoint)) {
149 $defaultmembershipsurl = $service->endpoint;
150 if (isset($consumer->profile->product_instance->product_info->product_family->vendor->code)) {
151 $vendorcode = $consumer->profile->product_instance->product_info->product_family->vendor->code;
152 $defaultmembershipsurl = str_replace('{vendor_code}', $vendorcode, $defaultmembershipsurl);
154 $defaultmembershipsurl = str_replace('{product_code}', $consumer->getKey(), $defaultmembershipsurl);
155 break;
160 $members = false;
162 // Fetch the resource link linked to the consumer.
163 $resourcelink = $dataconnector->get_resourcelink_from_consumer($consumer);
164 if ($resourcelink !== null) {
165 // Try to perform a membership service request using this resource link.
166 $members = $this->do_resourcelink_membership_request($resourcelink);
169 // If membership service can't be performed through resource link, fallback through context memberships.
170 if ($members === false) {
171 // Fetch context records that are mapped to this ToolConsumer.
172 $contexts = $dataconnector->get_contexts_from_consumer($consumer);
174 // Perform membership service request for each of these contexts.
175 foreach ($contexts as $context) {
176 $contextmembership = $this->do_context_membership_request($context, $resourcelink, $defaultmembershipsurl);
177 if ($contextmembership) {
178 // Add $contextmembership contents to $members array.
179 if (is_array($members)) {
180 $members = array_merge($members, $contextmembership);
181 } else {
182 $members = $contextmembership;
188 return $members;
192 * Method to determine whether to sync unenrolments or not.
194 * @param int $syncmode The tool's membersyncmode.
195 * @return bool
197 protected function should_sync_unenrol($syncmode) {
198 return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_UNENROL_MISSING;
202 * Method to determine whether to sync enrolments or not.
204 * @param int $syncmode The tool's membersyncmode.
205 * @return bool
207 protected function should_sync_enrol($syncmode) {
208 return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_ENROL_NEW;
212 * Performs synchronisation of member information and enrolments.
214 * @param stdClass $tool
215 * @param ToolConsumer $consumer
216 * @param User[] $members
217 * @return array An array of users from processed members and the number that were enrolled.
219 protected function sync_member_information(stdClass $tool, ToolConsumer $consumer, $members) {
220 global $DB;
221 $users = [];
222 $enrolcount = 0;
224 // Process member information.
225 foreach ($members as $member) {
226 // Set the user data.
227 $user = new stdClass();
228 $user->username = helper::create_username($consumer->getKey(), $member->ltiUserId);
229 $user->firstname = core_user::clean_field($member->firstname, 'firstname');
230 $user->lastname = core_user::clean_field($member->lastname, 'lastname');
231 $user->email = core_user::clean_field($member->email, 'email');
233 // Get the user data from the LTI consumer.
234 $user = helper::assign_user_tool_data($tool, $user);
236 $dbuser = core_user::get_user_by_username($user->username, 'id');
237 if ($dbuser) {
238 // If email is empty remove it, so we don't update the user with an empty email.
239 if (empty($user->email)) {
240 unset($user->email);
243 $user->id = $dbuser->id;
244 user_update_user($user);
246 // Add the information to the necessary arrays.
247 $users[$user->id] = $user;
248 $this->userphotos[$user->id] = $member->image;
249 } else {
250 if ($this->should_sync_enrol($tool->membersyncmode)) {
251 // If the email was stripped/not set then fill it with a default one. This
252 // stops the user from being redirected to edit their profile page.
253 if (empty($user->email)) {
254 $user->email = $user->username . "@example.com";
257 $user->auth = 'lti';
258 $user->id = user_create_user($user);
260 // Add the information to the necessary arrays.
261 $users[$user->id] = $user;
262 $this->userphotos[$user->id] = $member->image;
266 // Sync enrolments.
267 if ($this->should_sync_enrol($tool->membersyncmode)) {
268 // Enrol the user in the course.
269 if (helper::enrol_user($tool, $user->id) === helper::ENROLMENT_SUCCESSFUL) {
270 // Increment enrol count.
271 $enrolcount++;
274 // Check if this user has already been registered in the enrol_lti_users table.
275 if (!$DB->record_exists('enrol_lti_users', ['toolid' => $tool->id, 'userid' => $user->id])) {
276 // Create an initial enrol_lti_user record that we can use later when syncing grades and members.
277 $userlog = new stdClass();
278 $userlog->userid = $user->id;
279 $userlog->toolid = $tool->id;
280 $userlog->consumerkey = $consumer->getKey();
282 $DB->insert_record('enrol_lti_users', $userlog);
287 return [$users, $enrolcount];
291 * Performs unenrolment of users that are no longer enrolled in the consumer side.
293 * @param stdClass $tool The tool record object.
294 * @param string $consumerkey ensure we only unenrol users from this tool consumer.
295 * @param array $currentusers The list of current users.
296 * @return int The number of users that have been unenrolled.
298 protected function sync_unenrol(stdClass $tool, string $consumerkey, array $currentusers) {
299 global $DB;
301 $ltiplugin = enrol_get_plugin('lti');
303 if (!$this->should_sync_unenrol($tool->membersyncmode)) {
304 return 0;
307 if (empty($currentusers)) {
308 return 0;
311 $unenrolcount = 0;
313 $select = "toolid = :toolid AND " . $DB->sql_compare_text('consumerkey', 255) . " = :consumerkey";
314 $ltiusersrs = $DB->get_recordset_select('enrol_lti_users', $select, ['toolid' => $tool->id, 'consumerkey' => $consumerkey],
315 'lastaccess DESC', 'userid');
316 // Go through the users and check if any were never listed, if so, remove them.
317 foreach ($ltiusersrs as $ltiuser) {
318 if (!array_key_exists($ltiuser->userid, $currentusers)) {
319 $instance = new stdClass();
320 $instance->id = $tool->enrolid;
321 $instance->courseid = $tool->courseid;
322 $instance->enrol = 'lti';
323 $ltiplugin->unenrol_user($instance, $ltiuser->userid);
324 // Increment unenrol count.
325 $unenrolcount++;
328 $ltiusersrs->close();
330 return $unenrolcount;
334 * Performs synchronisation of user profile images.
336 protected function sync_profile_images() {
337 $counter = 0;
338 foreach ($this->userphotos as $userid => $url) {
339 if ($url) {
340 $result = helper::update_user_profile_image($userid, $url);
341 if ($result === helper::PROFILE_IMAGE_UPDATE_SUCCESSFUL) {
342 $counter++;
343 mtrace("Profile image succesfully downloaded and created for user '$userid' from $url.");
344 } else {
345 mtrace($result);
349 return $counter;
353 * Performs membership service request using an LTI Context object.
355 * If the context has a 'custom_context_memberships_url' setting, we use this to perform the membership service request.
356 * Otherwise, if a context is associated with resource link, we try first to get the members using the
357 * ResourceLink::doMembershipsService() method.
358 * If we're still unable to fetch members from the resource link, we try to build a memberships URL from the memberships URL
359 * endpoint template that is defined in the ToolConsumer profile and substitute the parameters accordingly.
361 * @param Context $context The context object.
362 * @param ResourceLink $resourcelink The resource link object.
363 * @param string $membershipsurltemplate The memberships endpoint URL template.
364 * @return bool|User[] Array of User objects upon successful membership service request. False, otherwise.
366 protected function do_context_membership_request(Context $context, ?ResourceLink $resourcelink = null,
367 $membershipsurltemplate = '') {
368 $dataconnector = $this->dataconnector;
370 // Flag to indicate whether to save the context later.
371 $contextupdated = false;
373 // If membership URL is not set, try to generate using the default membership URL from the consumer profile.
374 if (!$context->hasMembershipService()) {
375 if (empty($membershipsurltemplate)) {
376 mtrace("Skipping - No membership service available.\n");
377 return false;
380 if ($resourcelink === null) {
381 $resourcelink = $dataconnector->get_resourcelink_from_context($context);
384 if ($resourcelink !== null) {
385 // Try to perform a membership service request using this resource link.
386 $resourcelinkmembers = $this->do_resourcelink_membership_request($resourcelink);
387 if ($resourcelinkmembers) {
388 // If we're able to fetch members using this resource link, return these.
389 return $resourcelinkmembers;
393 // If fetching memberships through resource link failed and we don't have a memberships URL, build one from template.
394 mtrace("'custom_context_memberships_url' not set. Fetching default template: $membershipsurltemplate");
395 $membershipsurl = $membershipsurltemplate;
397 // Check if we need to fetch tool code.
398 $needstoolcode = strpos($membershipsurl, '{tool_code}') !== false;
399 if ($needstoolcode) {
400 $toolcode = false;
402 // Fetch tool code from the resource link data.
403 $lisresultsourcedidjson = $resourcelink->getSetting('lis_result_sourcedid');
404 if ($lisresultsourcedidjson) {
405 $lisresultsourcedid = json_decode($lisresultsourcedidjson);
406 if (isset($lisresultsourcedid->data->typeid)) {
407 $toolcode = $lisresultsourcedid->data->typeid;
411 if ($toolcode) {
412 // Substitute fetched tool code value.
413 $membershipsurl = str_replace('{tool_code}', $toolcode, $membershipsurl);
414 } else {
415 // We're unable to determine the tool code. End this processing.
416 return false;
420 // Get context_id parameter and substitute, if applicable.
421 $membershipsurl = str_replace('{context_id}', $context->getId(), $membershipsurl);
423 // Get context_type and substitute, if applicable.
424 if (strpos($membershipsurl, '{context_type}') !== false) {
425 $contexttype = $context->type !== null ? $context->type : 'CourseSection';
426 $membershipsurl = str_replace('{context_type}', $contexttype, $membershipsurl);
429 // Save this URL for the context's custom_context_memberships_url setting.
430 $context->setSetting('custom_context_memberships_url', $membershipsurl);
431 $contextupdated = true;
434 // Perform membership service request.
435 $url = $context->getSetting('custom_context_memberships_url');
436 mtrace("Performing membership service request from context with URL {$url}.");
437 $members = $context->getMembership();
439 // Save the context if membership request succeeded and if it has been updated.
440 if ($members && $contextupdated) {
441 $context->save();
444 return $members;
448 * Performs membership service request using ResourceLink::doMembershipsService() method.
450 * @param ResourceLink $resourcelink
451 * @return bool|User[] Array of User objects upon successful membership service request. False, otherwise.
453 protected function do_resourcelink_membership_request(ResourceLink $resourcelink) {
454 $members = false;
455 $membershipsurl = $resourcelink->getSetting('ext_ims_lis_memberships_url');
456 $membershipsid = $resourcelink->getSetting('ext_ims_lis_memberships_id');
457 if ($membershipsurl && $membershipsid) {
458 mtrace("Performing membership service request from resource link with membership URL: " . $membershipsurl);
459 $members = $resourcelink->doMembershipsService(true);
461 return $members;