AutorÃa | Ultima modificación | Ver Log |
<?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\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());
$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;
}
}