Skip to content

Commit

Permalink
Add support for LTI Context Groups Service
Browse files Browse the repository at this point in the history
  • Loading branch information
csev committed Jul 31, 2024
1 parent ce77c67 commit aaebe57
Show file tree
Hide file tree
Showing 4 changed files with 225 additions and 0 deletions.
57 changes: 57 additions & 0 deletions src/Core/Context.php
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,63 @@ public function loadNamesAndRoles($with_sourcedids=false, &$debug_log=false) {
return $nrps;
}

/**
* Load all the groups if we can them from the LMS
*
* @param array $debug_log If this is an array, debug information is returned as the
* process progresses.
*
* @return mixed If this works it returns the Groups object. If it fails,
* it returns a string.
*
*/
public function loadAllGroups(&$debug_log=false) {
return self::loadGroups(null, $debug_log);
}

/**
* Load the groups from the LMS
*
* @param string $user_id If this is a string, then only the groups for the user_id are retrieved
* @param array $debug_log If this is an array, debug information is returned as the
* process progresses.
*
* @return mixed If this works it returns the Groups object. If it fails,
* it returns a string.
*
*/
public function loadGroups($user_id, &$debug_log=false) {
global $CFG;

$missing = $this->loadLTI13Data($lti13_token_url, $privkey, $kid, $lti13_token_audience, $issuer_client, $deployment_id);
$lti13_context_groups_url = $this->launch->ltiParameter('lti13_context_groups_url');
if ( empty($lti13_context_groups_url) ) $missing .= ' ' . 'context_groups_url';
$missing = trim($missing);

if ( is_string($missing) && U::strlen($missing) > 0 ) {
if ( is_array($debug_log) ) $debug_log[] = 'Missing: '.$missing;
return $missing;
}

// TODO: In the future we might cache this access token perhaps in session for a while

// TODO: Also note that in LTI13 the basicoutcome claim is suppressed to make the cert suite happy
// so these two things to the same thing for now.
$groups_access_token = LTI13::getGroupsToken($issuer_client, $lti13_token_url, $privkey, $kid, $lti13_token_audience, $deployment_id, $debug_log);

if ( $groups_access_token === false ) {
$debug_log[] = "Groups data could not be retrieved";
return;
}

if ( is_string($user_id) && strlen($user_id) > 0 ) {
$lti13_context_groups_url = add_url_parm($lti13_context_groups_url, 'user_id', $user_id);
}

$nrps = LTI13::loadGroups($lti13_context_groups_url, $groups_access_token, $debug_log);
return $nrps;
}

/** Wrapper to get line items token so we can add caching
*
* @param string $missing This is a non-empty string with error detail if there
Expand Down
31 changes: 31 additions & 0 deletions src/Core/LTIX.php
Original file line number Diff line number Diff line change
Expand Up @@ -1210,6 +1210,18 @@ public static function extractJWT($needed=self::ALL, $input=false) {
$retval['lti13_membership_url'] = $body->{LTI13::NAMESANDROLES_CLAIM}->context_memberships_url;
}

// Get data from the groups claim
$retval['lti13_context_groups_url'] = null;
if ( isset($body->{LTI13::GROUPS_CLAIM}) &&
isset($body->{LTI13::GROUPS_CLAIM}->context_groups_url) &&
is_string($body->{LTI13::GROUPS_CLAIM}->context_groups_url) &&
isset($body->{LTI13::GROUPS_CLAIM}->service_versions) &&
is_array($body->{LTI13::GROUPS_CLAIM}->service_versions) &&
in_array("1.0", $body->{LTI13::GROUPS_CLAIM}->service_versions)
) {
$retval['lti13_context_groups_url'] = $body->{LTI13::GROUPS_CLAIM}->context_groups_url;
}

// Get the error url...
$retval['launch_presentation_return_url'] = null;
if ( isset($body->{LTI13::PRESENTATION_CLAIM}) &&
Expand Down Expand Up @@ -1307,6 +1319,9 @@ public static function loadAllData($p, $profile_table, $post) {
$LTI13 = $issuer_key !== false;
$for_user_subject = U::get($post, "for_user_subject", false);

// TODO: Remove this some time after 2024-07-30
$PDOX->insureColumnExists("{$CFG->dbprefix}lti_context", "lti13_context_groups_url", "TEXT NULL");

if ( $LTI13 ) {
$sql = "SELECT i.issuer_id, i.issuer_key, i.issuer_client, i.lti13_kid, i.lti13_keyset_url, i.lti13_keyset,
i.lti13_platform_pubkey, i.lti13_token_url, i.lti13_token_audience,
Expand All @@ -1326,6 +1341,7 @@ public static function loadAllData($p, $profile_table, $post) {
c.ext_memberships_id AS ext_memberships_id, c.ext_memberships_url AS ext_memberships_url,
c.lineitems_url AS lineitems_url, c.memberships_url AS memberships_url,
c.lti13_lineitems AS lti13_lineitems, c.lti13_membership_url AS lti13_membership_url,
c.lti13_context_groups_url AS lti13_context_groups_url,
c.settings AS context_settings,
l.link_id, l.path AS link_path, l.title AS link_title, l.settings AS link_settings, l.settings_url AS link_settings_url,
l.lti13_lineitem AS lti13_lineitem, l.settings AS link_settings,
Expand Down Expand Up @@ -1770,6 +1786,7 @@ public static function adjustData($p, &$row, $post, $needed) {
if ( ! isset($post['result_url']) ) $post['result_url'] = null;
if ( ! isset($post['lti13_lineitem']) ) $post['lti13_lineitem'] = null;
if ( ! isset($post['lti13_membership_url']) ) $post['lti13_membership_url'] = null;
if ( ! isset($post['lti13_context_groups_url']) ) $post['lti13_context_groups_url'] = null;
if ( ! isset($post['lti13_lineitems']) ) $post['lti13_lineitems'] = null;
if ( ! isset($row['service']) ) {
$row['service'] = null;
Expand Down Expand Up @@ -1823,6 +1840,20 @@ public static function adjustData($p, &$row, $post, $needed) {
$actions[] = "=== Updated result id=".$row['result_id']." lti13_membership_url=".$row['lti13_membership_url'];
}

// Here we handle lti13_context_groups_url
if ( isset($row['context_id']) && isset($post['lti13_context_groups_url']) &&
array_key_exists('lti13_context_groups_url',$row) && $post['lti13_context_groups_url'] != $row['lti13_context_groups_url'] ) {
$sql = "UPDATE {$p}lti_context
SET lti13_context_groups_url = :lti13_context_groups_url, updated_at = NOW()
WHERE context_id = :context_id";
$PDOX->queryDie($sql, array(
':lti13_context_groups_url' => $post['lti13_context_groups_url'],
':context_id' => $row['context_id']));
$row['lti13_context_groups_url'] = $post['lti13_context_groups_url'];
$actions[] = "=== Updated result id=".$row['result_id']." lti13_context_groups_url=".$row['lti13_context_groups_url'];
}


// Here we handle lti13_lineitems
if ( isset($row['context_id']) && isset($post['lti13_lineitems']) &&
array_key_exists('lti13_lineitems',$row) && $post['lti13_lineitems'] != $row['lti13_lineitems'] ) {
Expand Down
118 changes: 118 additions & 0 deletions src/Util/LTI13.php
Original file line number Diff line number Diff line change
Expand Up @@ -42,12 +42,14 @@ class LTI13 {
const DEEPLINK_CLAIM = 'https://purl.imsglobal.org/spec/lti-dl/claim/deep_linking_settings';

const CUSTOM_CLAIM = 'https://purl.imsglobal.org/spec/lti/claim/custom';
const GROUPS_CLAIM = 'https://purl.imsglobal.org/spec/lti-gs/claim/groupsservice';

const MEDIA_TYPE_MEMBERSHIPS = 'application/vnd.ims.lti-nrps.v2.membershipcontainer+json';
const MEDIA_TYPE_LINEITEM = 'application/vnd.ims.lis.v2.lineitem+json';
const MEDIA_TYPE_LINEITEMS = 'application/vnd.ims.lis.v2.lineitemcontainer+json';
const SCORE_TYPE = 'application/vnd.ims.lis.v1.score+json';
const RESULTS_TYPE = 'application/vnd.ims.lis.v2.resultcontainer+json';
const MEDIA_TYPE_GROUPS = 'application/vnd.ims.lti-gs.v1.contextgroupcontainer+json';

// https://www.imsglobal.org/spec/lti/v1p3/#platform-instance-claim
const TOOL_PLATFORM_CLAIM = 'https://purl.imsglobal.org/spec/lti/claim/tool_platform';
Expand Down Expand Up @@ -376,6 +378,22 @@ public static function getNRPSWithSourceDidsToken($subject, $lti13_token_url, $l
return self::extract_access_token($roster_token_data, $debug_log);
}

/** Retrieve a Course Group Service token
*
* @param array $debug_log An optional array passed by reference. Actions taken will be
* logged into this array.
*
* @return mixed Returns the token (string) or false on error.
*/
public static function getGroupsToken($subject, $lti13_token_url, $lti13_privkey, $lti13_kid, $lti13_token_audience, $deployment_id, &$debug_log=false) {

$groups_token_data = self::get_access_token([
"https://purl.imsglobal.org/spec/lti-gs/scope/contextgroup.readonly"
], $subject, $lti13_token_url, $lti13_privkey, $lti13_kid, $lti13_token_audience, $deployment_id, $debug_log);

return self::extract_access_token($groups_token_data, $debug_log);
}

/** Retrieve a LineItems token
*
* @param array $debug_log An optional array passed by reference. Actions taken will be
Expand Down Expand Up @@ -581,6 +599,106 @@ public static function loadNRPS($membership_url, $access_token, &$debug_log=fals
}
}


/**
* Load the groups if we can get them from the LMS
*
* @param string $context_groups_url The REST endpoint for memberships
* @param $access_token The access token for this request
* @param array $debug_log If this is an array, debug information is returned as the
* process progresses.
*
* @return mixed If this works it returns the NRPS object. If it fails,
* it returns a string.
*/
public static function loadGroups($context_groups_url, $access_token, &$debug_log=false) {

$return_array = null;

$context_groups_url = trim($context_groups_url);

// Handle paging
while(1) {
if ( is_array($debug_log) ) $debug_log[] = 'Loading: ' . $context_groups_url;

$ch = curl_init();

$headers = [
'Authorization: Bearer '. $access_token,
'Accept: '.self::MEDIA_TYPE_GROUPS,
'Content-Type: '.self::MEDIA_TYPE_GROUPS
];

curl_setopt($ch, CURLOPT_URL, $context_groups_url);
curl_setopt($ch, CURLOPT_HTTPHEADER, $headers);
curl_setopt($ch, CURLOPT_HEADER, true); // Ask for headers in the return data
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);

if ( is_array($debug_log) ) $debug_log[] = $context_groups_url;
if ( is_array($debug_log) ) $debug_log[] = $headers;

$lti_groups = curl_exec($ch);
if ( $lti_groups === false ) return self::handle_curl_error($ch, $debug_log);

$httpcode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
$headerSize = curl_getinfo($ch , CURLINFO_HEADER_SIZE );
curl_close ($ch);
if ( is_array($debug_log) ) $debug_log[] = "Sent groups request, received status=$httpcode (".U::strlen($lti_groups)." characters)";

if ( empty($lti_groups) ) {
return "No data retrieved status=" . $httpcode;
}

$headerStr = substr( $lti_groups , 0 , $headerSize );
$lti_groups = substr( $lti_groups , $headerSize );
$response_headers = Net::parseHeaders($headerStr);

if (is_array($debug_log) ) $debug_log[] = $response_headers;

$nextUrl = null;
$link_header = U::get($response_headers, 'Link', null);
if ( is_string($link_header) ) {
if ( is_array($debug_log) ) $debug_log[] = 'Link header: ' . $link_header;
$linkHeader = LinkHeader::fromString($link_header);
$nextRel = is_object($linkHeader) ? $linkHeader->getRel('next') : null;
$nextUrl = is_object($nextRel) ? $nextRel->getUri() : null;
}

$json = json_decode($lti_groups, false); // Top level object
if ( $json === null ) {
$retval = "Unable to parse returned groups JSON:". json_last_error_msg();
if ( is_array($debug_log) ) {
if (is_array($debug_log) ) $debug_log[] = $retval;
if (is_array($debug_log) ) $debug_log[] = substr($lti_groups, 0, 3000);
}
return $retval;
}

if ( Net::httpSuccess($httpcode) && isset($json->groups) ) {
if ( is_array($debug_log) ) $debug_log[] = "Loaded ".count($json->groups)." groups entries";
if ( $return_array == null ) {
$return_array = $json;
} else {
$return_array->groups = array_merge($return_array->groups, $json->groups);
}
if ( $nextUrl == null ) {
if ( is_array($debug_log) ) $debug_log[] = "Returning ".count($return_array->groups)." groups entries";
return $return_array;
}
if ( is_array($debug_log) ) $debug_log[] = 'Retrieving Next URL: ' . $nextUrl;
$context_groups_url = trim($nextUrl);
continue;
}

$status = isset($json->error) ? $json->error : "Unable to load results";
if ( is_array($debug_log) ) {
$debug_log[] = "Error status: $status";
if (is_array($debug_log) ) $debug_log[] = substr($lti_groups, 0, 3000);
}
return $status;
}
}

/**
* Load our lineitems from the LMS
*
Expand Down
19 changes: 19 additions & 0 deletions src/Util/PDOX.php
Original file line number Diff line number Diff line change
Expand Up @@ -377,6 +377,25 @@ function columnExists($fieldname, $source)
return is_array($column);
}

/**
* Insure that a column exists
*
* @param $table The name of the table like '{$CFG->dbprefix}lti_result'
* @param $column The name of the column like 'grading_progress'
* @param $type Desired type for the column like 'TINYINT(1) NOT NULL DEFAULT 0'
*
* @return mixed - Either true or a string with a message
*/
function insureColumnExists($table, $column, $type)
{
if ( self::columnExists($column, $table ) ) return true;
$sql= "ALTER TABLE {$CFG->dbprefix}$table ADD $column $type";
$retval = "Adding column: ".$sql;
$q = self::queryReturnError($sql);
error_log($retval);
return $retval;
}

/**
* Get the column type
*
Expand Down

0 comments on commit aaebe57

Please sign in to comment.