2 // This file is part of Moodle - http://moodle.org/
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.
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/>.
18 * Handles synchronising members using the enrolment 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
;
31 use enrol_lti\data_connector
;
33 use IMSGlobal\LTI\ToolProvider\Context
;
34 use IMSGlobal\LTI\ToolProvider\ResourceLink
;
35 use IMSGlobal\LTI\ToolProvider\ToolConsumer
;
36 use IMSGlobal\LTI\ToolProvider\User
;
39 require_once($CFG->dirroot
. '/user/lib.php');
42 * Task for synchronising members using the enrolment 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;
60 * Get a descriptive name for this task.
64 public function get_name() {
65 return get_string('tasksyncmembers', 'enrol_lti');
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')));
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'));
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.
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");
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);
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);
185 $members = $contextmembership;
195 * Method to determine whether to sync unenrolments or not.
197 * @param int $syncmode The tool's membersyncmode.
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.
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) {
227 // Process member information.
228 foreach ($members as $member) {
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');
243 // If email is empty remove it, so we don't update the user with an empty email.
244 if (empty($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
;
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";
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
;
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.
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) {
306 $ltiplugin = enrol_get_plugin('lti');
308 if (!$this->should_sync_unenrol($tool->membersyncmode
)) {
312 if (empty($this->currentusers
)) {
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.
331 $ltiusersrs->close();
333 return $unenrolcount;
337 * Performs synchronisation of user profile images.
339 protected function sync_profile_images() {
341 foreach ($this->userphotos
as $userid => $url) {
343 $result = helper
::update_user_profile_image($userid, $url);
344 if ($result === helper
::PROFILE_IMAGE_UPDATE_SUCCESSFUL
) {
346 mtrace("Profile image succesfully downloaded and created for user '$userid' from $url.");
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");
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) {
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
;
415 // Substitute fetched tool code value.
416 $membershipsurl = str_replace('{tool_code}', $toolcode, $membershipsurl);
418 // We're unable to determine the tool code. End this processing.
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) {
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) {
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);