Search moodle.org's
Developer Documentation

See Release Notes

  • Bug fixes for general core bugs in 4.2.x will end 22 April 2024 (12 months).
  • Bug fixes for security issues in 4.2.x will end 7 October 2024 (18 months).
  • PHP version: minimum PHP 8.0.0 Note: minimum PHP version has increased since Moodle 4.1. PHP 8.1.x is supported too.
<?php
// This file is part of Moodle - http://moodle.org/
//
// Moodle is free software: you can redistribute it and/or modify
// it under the terms of the GNU General Public License as published by
// the Free Software Foundation, either version 3 of the License, or
// (at your option) any later version.
//
// Moodle is distributed in the hope that it will be useful,
// but WITHOUT ANY WARRANTY; without even the implied warranty of
// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
// GNU General Public License for more details.
//
// You should have received a copy of the GNU General Public License
// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.

namespace enrol_lti\local\ltiadvantage\task;

> use core\http_client;
use core\task\scheduled_task; use enrol_lti\helper; use enrol_lti\local\ltiadvantage\entity\application_registration; use enrol_lti\local\ltiadvantage\entity\nrps_info; use enrol_lti\local\ltiadvantage\entity\resource_link; use enrol_lti\local\ltiadvantage\entity\user;
< use enrol_lti\local\ltiadvantage\lib\http_client;
use enrol_lti\local\ltiadvantage\lib\issuer_database; use enrol_lti\local\ltiadvantage\lib\launch_cache_session; use enrol_lti\local\ltiadvantage\repository\application_registration_repository; use enrol_lti\local\ltiadvantage\repository\deployment_repository; use enrol_lti\local\ltiadvantage\repository\resource_link_repository; use enrol_lti\local\ltiadvantage\repository\user_repository; use Packback\Lti1p3\LtiNamesRolesProvisioningService; use Packback\Lti1p3\LtiRegistration; use Packback\Lti1p3\LtiServiceConnector; use stdClass; /** * LTI Advantage-specific task responsible for syncing memberships from tool platforms with the tool. * * This task may gather members from a context-level service call, depending on whether a resource-level service call * (which is made first) was successful. Because of the context-wide memberships, and because each published resource * has per-resource access control (role assignments), this task only enrols user into the course, and does not assign * roles to resource/course contexts. Role assignment only takes place during a launch, via the tool_launch_service. * * @package enrol_lti * @copyright 2021 Jake Dallimore <jrhdallimore@gmail.com> * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class sync_members extends scheduled_task { /** @var array Array of user photos. */ protected $userphotos = []; /** @var resource_link_repository $resourcelinkrepo for fetching resource_link instances.*/ protected $resourcelinkrepo; /** @var application_registration_repository $appregistrationrepo for fetching application_registration instances.*/ protected $appregistrationrepo; /** @var deployment_repository $deploymentrepo for fetching deployment instances. */ protected $deploymentrepo; /** @var user_repository $userrepo for fetching and saving lti user information.*/ protected $userrepo; /** @var issuer_database $issuerdb library specific registration DB required to create service connectors.*/ protected $issuerdb; /** * Get the name for this task. * * @return string the name of the task. */ public function get_name(): string { return get_string('tasksyncmembers', 'enrol_lti'); } /** * Make a resource-link-level memberships call. * * @param nrps_info $nrps information about names and roles service endpoints and scopes. * @param LtiServiceConnector $sc a service connector object. * @param LtiRegistration $registration the registration * @param resource_link $resourcelink the resource link * @return array an array of members if found. */ protected function get_resource_link_level_members(nrps_info $nrps, LtiServiceConnector $sc, LtiRegistration $registration, resource_link $resourcelink) { // Try a resource-link-level memberships call first, falling back to context-level if no members are found. $reslinkmembershipsurl = $nrps->get_context_memberships_url(); $reslinkmembershipsurl->param('rlid', $resourcelink->get_resourcelinkid()); $servicedata = [ 'context_memberships_url' => $reslinkmembershipsurl->out(false) ]; $reslinklevelnrps = new LtiNamesRolesProvisioningService($sc, $registration, $servicedata); mtrace('Making resource-link-level memberships request'); return $reslinklevelnrps->getMembers(); } /** * Make a context-level memberships call. * * @param nrps_info $nrps information about names and roles service endpoints and scopes. * @param LtiServiceConnector $sc a service connector object. * @param LtiRegistration $registration the registration * @return array an array of members. */ protected function get_context_level_members(nrps_info $nrps, LtiServiceConnector $sc, LtiRegistration $registration) { $clservicedata = [ 'context_memberships_url' => $nrps->get_context_memberships_url()->out(false) ]; $contextlevelnrps = new LtiNamesRolesProvisioningService($sc, $registration, $clservicedata); return $contextlevelnrps->getMembers(); } /** * Make the NRPS service call and fetch members based on the given resource link. * * Memberships will be retrieved by first trying the link-level memberships service first, falling back to calling * the context-level memberships service only if the link-level call fails. * * @param application_registration $appregistration an application registration instance. * @param resource_link $resourcelink a resourcelink instance. * @return array an array of members. */ protected function get_members_from_resource_link(application_registration $appregistration, resource_link $resourcelink) { // Get a service worker for the corresponding application registration. $registration = $this->issuerdb->findRegistrationByIssuer( $appregistration->get_platformid()->out(false), $appregistration->get_clientid() ); global $CFG; require_once($CFG->libdir . '/filelib.php');
< $sc = new LtiServiceConnector(new launch_cache_session(), new http_client(new \curl()));
> $sc = new LtiServiceConnector(new launch_cache_session(), new http_client());
$nrps = $resourcelink->get_names_and_roles_service(); try { $members = $this->get_resource_link_level_members($nrps, $sc, $registration, $resourcelink); } catch (\Exception $e) { mtrace('Link-level memberships request failed. Making context-level memberships request'); $members = $this->get_context_level_members($nrps, $sc, $registration); } return $members; } /** * Performs the synchronisation of members. */ public function execute() { if (!is_enabled_auth('lti')) { mtrace('Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti'))); return; } if (!enrol_is_enabled('lti')) { mtrace('Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti')); return; } $this->resourcelinkrepo = new resource_link_repository(); $this->appregistrationrepo = new application_registration_repository(); $this->deploymentrepo = new deployment_repository(); $this->userrepo = new user_repository(); $this->issuerdb = new issuer_database($this->appregistrationrepo, $this->deploymentrepo); $resources = helper::get_lti_tools(['status' => ENROL_INSTANCE_ENABLED, 'membersync' => 1, 'ltiversion' => 'LTI-1p3']); foreach ($resources as $resource) { mtrace("Starting - Member sync for published resource '$resource->id' for course '$resource->courseid'."); $usercount = 0; $enrolcount = 0; $unenrolcount = 0; $syncedusers = []; // Get all resource_links for this shared resource. // This is how context/resource_link memberships calls will be made. $resourcelinks = $this->resourcelinkrepo->find_by_resource((int)$resource->id); foreach ($resourcelinks as $resourcelink) { mtrace("Requesting names and roles for the resource link '{$resourcelink->get_id()}' for the resource" . " '{$resource->id}'"); if (!$resourcelink->get_names_and_roles_service()) { mtrace("Skipping - No names and roles service found."); continue; } $appregistration = $this->appregistrationrepo->find_by_deployment( $resourcelink->get_deploymentid() ); if (!$appregistration) { mtrace("Skipping - no corresponding application registration found."); continue; } try { $members = $this->get_members_from_resource_link($appregistration, $resourcelink); } catch (\Exception $e) { mtrace("Skipping - Names and Roles service request failed: {$e->getMessage()}."); continue; } // Fetched members count. $membercount = count($members); $usercount += $membercount; mtrace("$membercount members received."); // Process member information. [$rlenrolcount, $userids] = $this->sync_member_information($appregistration, $resource, $resourcelink, $members); $enrolcount += $rlenrolcount; // Update the list of users synced for this shared resource or its context. $syncedusers = array_unique(array_merge($syncedusers, $userids)); mtrace("Completed - Synced $membercount members for the resource link '{$resourcelink->get_id()}' ". "for the resource '{$resource->id}'.\n"); // Sync unenrolments on a per-resource-link basis so we have fine grained control over unenrolments. // If a resource link doesn't support NRPS, it will already have been skipped. $unenrolcount += $this->sync_unenrol_resourcelink($resourcelink, $resource, $syncedusers); } mtrace("Completed - Synced members for tool '$resource->id' in the course '$resource->courseid'. " . "Processed $usercount users; enrolled $enrolcount members; unenrolled $unenrolcount members.\n"); } if (!empty($resources) && !empty($this->userphotos)) { // Sync the user profile photos. mtrace("Started - Syncing user profile images."); $countsyncedimages = $this->sync_profile_images(); mtrace("Completed - Synced $countsyncedimages profile images."); } } /** * Process unenrolment of users for a given resource link and based on the list of recently synced users. * * @param resource_link $resourcelink the resource_link instance to which the $synced users pertains * @param stdClass $resource the resource object instance * @param array $syncedusers the array of recently synced users, who are not to be unenrolled. * @return int the number of unenrolled users. */ protected function sync_unenrol_resourcelink(resource_link $resourcelink, stdClass $resource, array $syncedusers): int { if (!$this->should_sync_unenrol($resource->membersyncmode)) { return 0; } $ltiplugin = enrol_get_plugin('lti'); $unenrolcount = 0; // Get all users for the resource_link instance. $linkusers = $this->userrepo->find_by_resource_link($resourcelink->get_id()); foreach ($linkusers as $ltiuser) { if (!in_array($ltiuser->get_localid(), $syncedusers)) { $instance = new stdClass(); $instance->id = $resource->enrolid; $instance->courseid = $resource->courseid; $instance->enrol = 'lti'; $ltiplugin->unenrol_user($instance, $ltiuser->get_localid()); $unenrolcount++; } } return $unenrolcount; } /** * Check whether the member has an instructor role or not. * * @param array $member * @return bool */ protected function member_is_instructor(array $member): bool { // See: http://www.imsglobal.org/spec/lti/v1p3/#role-vocabularies. $memberroles = $member['roles']; if ($memberroles) { $adminroles = [ 'http://purl.imsglobal.org/vocab/lis/v2/institution/person#Administrator', 'http://purl.imsglobal.org/vocab/lis/v2/system/person#Administrator' ]; $staffroles = [ 'http://purl.imsglobal.org/vocab/lis/v2/membership#ContentDeveloper', 'http://purl.imsglobal.org/vocab/lis/v2/membership#Instructor', 'http://purl.imsglobal.org/vocab/lis/v2/membership/Instructor#TeachingAssistant', 'ContentDeveloper', 'Instructor', 'Instructor#TeachingAssistant' ]; $instructorroles = array_merge($adminroles, $staffroles); foreach ($instructorroles as $validrole) { if (in_array($validrole, $memberroles)) { return true; } } } return false; } /** * Method to determine whether to sync unenrolments or not. * * @param int $syncmode The shared resource's membersyncmode. * @return bool true if unenrolment should be synced, false if not. */ protected function should_sync_unenrol($syncmode): bool { return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_UNENROL_MISSING; } /** * Method to determine whether to sync enrolments or not. * * @param int $syncmode The shared resource's membersyncmode. * @return bool true if enrolment should be synced, false if not. */ protected function should_sync_enrol($syncmode): bool { return $syncmode == helper::MEMBER_SYNC_ENROL_AND_UNENROL || $syncmode == helper::MEMBER_SYNC_ENROL_NEW; } /** * Creates an lti user object from a member entry. * * @param stdClass $user the Moodle user record representing this member. * @param stdClass $resource the locally published resource record, used for setting user defaults. * @param resource_link $resourcelink the resource_link instance. * @param array $member the member information from the NRPS service call. * @return user the lti user instance. */ protected function ltiuser_from_member(stdClass $user, stdClass $resource, resource_link $resourcelink, array $member): user { if (!$ltiuser = $this->userrepo->find_single_user_by_resource($user->id, $resource->id)) { // New user, so create them. $ltiuser = user::create( $resourcelink->get_resourceid(), $user->id, $resourcelink->get_deploymentid(), $member['user_id'], $resource->lang, $resource->timezone, $resource->city ?? '', $resource->country ?? '', $resource->institution ?? '', $resource->maildisplay ); } $ltiuser->set_lastaccess(time()); return $ltiuser; } /** * Performs synchronisation of member information and enrolments. * * @param application_registration $appregistration the application_registration instance. * @param stdClass $resource the enrol_lti_tools resource information. * @param resource_link $resourcelink the resource_link instance. * @param user[] $members an array of members to sync. * @return array An array containing the counts of enrolled users and a list of userids. */ protected function sync_member_information(application_registration $appregistration, stdClass $resource, resource_link $resourcelink, array $members): array { $enrolcount = 0; $userids = []; // Get the verified legacy consumer key, if mapped, from the resource link's tool deployment. // This will be used to locate legacy user accounts and link them to LTI 1.3 users. // A launch must have been made in order to get the legacy consumer key from the lti1p1 migration claim. $deployment = $this->deploymentrepo->find($resourcelink->get_deploymentid()); $legacyconsumerkey = $deployment->get_legacy_consumer_key() ?? ''; foreach ($members as $member) { $auth = get_auth_plugin('lti'); if ($auth->get_user_binding($appregistration->get_platformid()->out(false), $member['user_id'])) { // Use is bound already, so we can update them. $user = $auth->find_or_create_user_from_membership($member, $appregistration->get_platformid()->out(false)); if ($user->auth != 'lti') { mtrace("Skipped profile sync for user '$user->id'. The user does not belong to the LTI auth method."); } } else { // Not bound, so defer to the role-based provisioning mode for the resource. $provisioningmode = $this->member_is_instructor($member) ? $resource->provisioningmodeinstructor : $resource->provisioningmodelearner; switch ($provisioningmode) { case \auth_plugin_lti::PROVISIONING_MODE_AUTO_ONLY: // Automatic provisioning - this will create a user account and log the user in. $user = $auth->find_or_create_user_from_membership($member, $appregistration->get_platformid()->out(false), $legacyconsumerkey); break; case \auth_plugin_lti::PROVISIONING_MODE_PROMPT_NEW_EXISTING: case \auth_plugin_lti::PROVISIONING_MODE_PROMPT_EXISTING_ONLY: default: mtrace("Skipping account creation for member '{$member['user_id']}'. This member is not eligible for ". "automatic creation due to the current account provisioning mode."); continue 2; } } $ltiuser = $this->ltiuser_from_member($user, $resource, $resourcelink, $member); if ($this->should_sync_enrol($resource->membersyncmode)) { $ltiuser->set_resourcelinkid($resourcelink->get_id()); $ltiuser = $this->userrepo->save($ltiuser); if ($user->auth != 'lti') { mtrace("Skipped picture sync for user '$user->id'. The user does not belong to the LTI auth method."); } else { if (isset($member['picture'])) { $this->userphotos[$ltiuser->get_localid()] = $member['picture']; } } // Enrol the user in the course. if (helper::enrol_user($resource, $ltiuser->get_localid()) === helper::ENROLMENT_SUCCESSFUL) { $enrolcount++; } } // If the member has been created, or exists locally already, mark them as valid so as to not unenrol them // when syncing memberships for shared resources configured as either MEMBER_SYNC_ENROL_AND_UNENROL or // MEMBER_SYNC_UNENROL_MISSING. $userids[] = $user->id; } return [$enrolcount, $userids]; } /** * Performs synchronisation of user profile images. * * @return int the count of synced photos. */ protected function sync_profile_images(): int { $counter = 0; foreach ($this->userphotos as $userid => $url) { if ($url) { $result = helper::update_user_profile_image($userid, $url); if ($result === helper::PROFILE_IMAGE_UPDATE_SUCCESSFUL) { $counter++; mtrace("Profile image successfully downloaded and created for user '$userid' from $url."); } else { mtrace($result); } } } return $counter; } }