From e13bc13ae70a9f786a0737bd25f930f5cb71e72f Mon Sep 17 00:00:00 2001 From: Stefan Topfstedt Date: Wed, 18 Sep 2024 11:06:13 -0700 Subject: [PATCH] wip: replace ilios client in enrollment plugin --- ajax.php | 63 +-- classes/ilios.php | 364 ++++++++++++++++-- edit.php | 12 +- edit_form.php | 84 ++-- lib.php | 209 ++-------- tests/ilios_test.php | 894 ++++++++++++++++++++++++++++++++++++++++++- 6 files changed, 1309 insertions(+), 317 deletions(-) diff --git a/ajax.php b/ajax.php index 8f874a5..6976244 100644 --- a/ajax.php +++ b/ajax.php @@ -26,6 +26,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\di; +use enrol_ilios\ilios; + define('AJAX_SCRIPT', true); require('../../config.php'); @@ -63,15 +66,17 @@ $outcome->response = new stdClass(); $outcome->error = ''; -/** @var enrol_ilios_plugin $enrol */ -$enrol = enrol_get_plugin('ilios'); -$apiclient = $enrol->get_api_client(); -$accesstoken = $enrol->get_api_access_token(); +try { + $ilios = di::get(ilios::class); +} catch (Exception $e) { + // Re-throw exception. + throw new Exception('ERROR: Failed to instantiate Ilios client.', $e); +} switch ($action) { case 'getselectschooloptions': require_capability('moodle/course:enrolconfig', $context); - $schools = $apiclient->get($accesstoken, 'schools', '', ['title' => "ASC"]); + $schools = $ilios->get_schools(['title' => "ASC"]); $schoolarray = []; foreach ($schools as $school) { $schoolarray["$school->id:$school->title"] = $school->title; @@ -83,7 +88,7 @@ require_capability('moodle/course:enrolconfig', $context); $sid = required_param('filterid', PARAM_INT); // School ID. $programs = []; - $programs = $apiclient->get($accesstoken, 'programs', ['school' => $sid], ['title' => "ASC"]); + $programs = $ilios->get_programs(['school' => $sid], ['title' => "ASC"]); $programarray = []; foreach ($programs as $program) { $key = $program->id; @@ -101,12 +106,7 @@ case 'getselectcohortoptions': require_capability('moodle/course:enrolconfig', $context); $pid = required_param('filterid', PARAM_INT); - $programyears = $apiclient->get( - $accesstoken, - 'programYears', - ["program" => $pid], - ["startYear" => "ASC"] - ); + $programyears = $ilios->get_program_years(["program" => $pid], ["startYear" => "ASC"]); $programyeararray = []; $cohortoptions = []; foreach ($programyears as $progyear) { @@ -114,12 +114,7 @@ } if (!empty($programyeararray)) { - $cohorts = $apiclient->get( - $accesstoken, - 'cohorts', - ["programYear" => $programyeararray], - ["title" => "ASC"] - ); + $cohorts = $ilios->get_cohorts(["programYear" => $programyeararray], ["title" => "ASC"]); foreach ($cohorts as $cohort) { $cohortoptions["$cohort->id:$cohort->title"] = $cohort->title .' ('.count($cohort->learnerGroups).')' @@ -133,12 +128,7 @@ require_capability('moodle/course:enrolconfig', $context); $cid = required_param('filterid', PARAM_INT); // Cohort ID. $usertype = optional_param('usertype', 0, PARAM_INT); // Learner or instructor. - $learnergroups = $apiclient->get( - $accesstoken, - 'learnerGroups', - ['cohort' => $cid, 'parent' => 'null'], - ['title' => "ASC"] - ); + $learnergroups = $ilios->get_learner_groups(['cohort' => $cid, 'parent' => 'null'], ['title' => "ASC"]); $grouparray = []; foreach ($learnergroups as $group) { $grouparray["$group->id:$group->title"] = $group->title. @@ -153,25 +143,15 @@ $gid = required_param('filterid', PARAM_INT); // Group ID. $usertype = optional_param('usertype', 0, PARAM_INT); // Learner or instructor. $subgroupoptions = []; - $subgroups = $apiclient->get( - $accesstoken, - 'learnerGroups', - ["parent" => $gid], - ["title" => "ASC"] - ); + $subgroups = $ilios->get_learner_groups(["parent" => $gid], ["title" => "ASC"]); foreach ($subgroups as $subgroup) { $subgroupoptions["$subgroup->id:$subgroup->title"] = $subgroup->title. ' ('. count($subgroup->children) .')'; $subgroupoptions["$subgroup->id:$subgroup->title"] .= ' ('. count($subgroup->users) .')'; if (!empty($subgroup->children)) { - $processchildren = function ($parent) use (&$processchildren, &$subgroupoptions, $apiclient, $accesstoken) { - $subgrps = $apiclient->get( - $accesstoken, - 'learnerGroups', - [ 'parent' => $parent->id], - [ 'title' => "ASC"] - ); + $processchildren = function ($parent) use (&$processchildren, &$subgroupoptions, $ilios) { + $subgrps = $ilios->get_learner_groups([ 'parent' => $parent->id], [ 'title' => "ASC"]); foreach ($subgrps as $subgrp) { $subgroupoptions["$subgrp->id:$parent->title / $subgrp->title"] = $parent->title.' / '.$subgrp->title. ' ('. count($subgrp->children) .')'; @@ -192,14 +172,9 @@ require_capability('moodle/course:enrolconfig', $context); $gid = required_param('filterid', PARAM_INT); // Group ID. $instructorgroupoptions = []; - $learnergroup = $apiclient->get_by_id($accesstoken, 'learnerGroups', $gid); + $learnergroup = $ilios->get_learner_group('learnerGroups', $gid); if (!empty($learnergroup->instructorGroups)) { - $instructorgroups = $apiclient->get( - $accesstoken, - 'instructorGroups', - '', - ["title" => "ASC"] - ); + $instructorgroups = $ilios->get_instructor_groups(sortby: ["title" => "ASC"]); foreach ($instructorgroups as $instructorgroup) { $instructorgroupoptions["$instructorgroup->id:$instructorgroup->title"] = $instructorgroup->title. ' ('. count($instructorgroup->users) .')'; diff --git a/classes/ilios.php b/classes/ilios.php index bb4b8b7..29aafc5 100644 --- a/classes/ilios.php +++ b/classes/ilios.php @@ -27,6 +27,7 @@ use core\http_client; use dml_exception; use Firebase\JWT\JWT; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; use moodle_exception; @@ -69,41 +70,296 @@ public function __construct( } /** - * Retrieves all schools from Ilios. + * Retrieves a list of schools from Ilios. * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. * @return array A list of school objects. * @throws GuzzleException * @throws moodle_exception */ - public function get_schools(): array { - $response = $this->get('schools'); + public function get_schools(array $filterby = [], array $sortby = []): array { + $response = $this->get('schools', $filterby, $sortby); return $response->schools; } /** - * Retrieves all enabled users with a given primary school affiliation. + * Retrieves a list of cohorts from Ilios. * - * @param int $schoolid The school ID. + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of cohort objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_cohorts(array $filterby = [], array $sortby = []): array { + $response = $this->get('cohorts', $filterby, $sortby); + return $response->cohorts; + } + + /** + * Retrieves a list of programs from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of program objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_programs(array $filterby = [], array $sortby = []): array { + $response = $this->get('programs', $filterby, $sortby); + return $response->programs; + } + + /** + * Retrieves a list of program-years from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of program-year objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_program_years(array $filterby = [], array $sortby = []): array { + $response = $this->get('programyears', $filterby, $sortby); + return $response->programYears; + } + + /** + * Retrieves a list of learner-groups from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of learner-group objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_learner_groups(array $filterby = [], array $sortby = []): array { + $response = $this->get('learnergroups', $filterby, $sortby); + return $response->learnerGroups; + } + + /** + * Retrieves a list of instructor-groups from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of instructor-group objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_instructor_groups(array $filterby = [], array $sortby = []): array { + $response = $this->get('instructorgroups', $filterby, $sortby); + return $response->instructorGroups; + } + + /** + * Retrieves a list of offerings from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of offering objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_offerings(array $filterby = [], array $sortby = []): array { + $response = $this->get('offerings', $filterby, $sortby); + return $response->offerings; + } + + /** + * Retrieves a list of ILMs from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @return array A list of ILM objects. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_ilms(array $filterby = [], array $sortby = []): array { + $response = $this->get('ilmsessions', $filterby, $sortby); + return $response->ilmSessions; + } + + /** + * Retrieves a list of users from Ilios. + * + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. * @return array A list of user objects. * @throws GuzzleException * @throws moodle_exception */ - public function get_enabled_users_in_school(int $schoolid): array { - $response = $this->get('users?filters[enabled]=true&filters[school]=' . $schoolid); + public function get_users(array $filterby = [], array $sortby = []): array { + $response = $this->get('users', $filterby, $sortby); return $response->users; } + /** + * Retrieves a school by its ID from Ilios. + * + * @param int $id + * @return object|null The school object, or NULL if not found. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_school(int $id): ?object { + $response = $this->get_by_id('schools', $id); + if ($response) { + return $response->schools[0]; + } + return null; + } + + /** + * Retrieves a cohort by its ID from Ilios. + * + * @param int $id + * @return object|null The cohort object, or NULL if not found. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_cohort(int $id): ?object { + $response = $this->get_by_id('cohorts', $id); + if ($response) { + return $response->cohorts[0]; + } + return null; + } + + /** + * Retrieves a program by its ID from Ilios. + * + * @param int $id + * @return object|null The program object, or NULL if not found. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_program(int $id): ?object { + $response = $this->get_by_id('programs', $id); + if ($response) { + return $response->programs[0]; + } + return null; + } + + /** + * Retrieves a learner-group by its ID from Ilios. + * + * @param int $id + * @return object|null The learner-group object, or NULL if not found. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_learner_group(int $id): ?object { + $response = $this->get_by_id('learnergroups', $id); + if ($response) { + return $response->learnerGroups[0]; + } + return null; + } + + /** + * Retrieves a list instructors for a given learner-group and its subgroups. + * + * @param int $groupid The group ID. + * @return array A list of user IDs. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_instructor_ids_from_learner_group(int $groupid): array { + $group = $this->get_learner_group($groupid); + // No group, no instructors. + if (empty($group)) { + return []; + } + + $instructorgroupids = []; + $instructorids = []; + + // Get instructors/instructor-groups from the offerings that this learner group is being taught in. + if (!empty($group->offerings)) { + $offerings = $this->get_offerings(['id' => $group->offerings]); + + foreach ($offerings as $offering) { + if (empty($offering->instructors) && empty($offering->instructorGroups)) { + // No instructor AND no instructor groups have been set for this offering. + // Fall back to the default instructors/instructor-groups defined for the learner group. + $instructorids = array_merge($instructorids, $group->instructors); + $instructorgroupids = array_merge($instructorgroupids, $group->instructorGroups); + } else { + // If there are instructors and/or instructor-groups set on the offering, then use these. + $instructorids = array_merge($instructorids, $offering->instructors); + $instructorgroupids = array_merge($instructorgroupids, $offering->instructorGroups); + } + } + } + + // Get instructors/instructor-groups from the ilm sessions that this learner group is being taught in. + // This is a rinse/repeat from offerings-related code above. + if (!empty($group->ilmSessions)) { + $ilms = $this->get_ilms(['id' => $group->ilmSessions]); + + foreach ($ilms as $ilm) { + if (empty($ilm->instructors) && empty($ilm->instructorGroups)) { + // No instructor AND no instructor groups have been set for this offering. + // Fall back to the default instructors/instructor-groups defined for the learner group. + $instructorids = array_merge($instructorids, $group->instructors); + $instructorgroupids = array_merge($instructorgroupids, $group->instructorGroups); + } else { + // If there are instructors and/or instructor-groups set on the offering, then use these. + $instructorids = array_merge($instructorids, $ilm->instructors); + $instructorgroupids = array_merge($instructorgroupids, $ilm->instructorGroups); + } + } + } + + // Get instructors from sub-learner-groups. + if (!empty($group->children)) { + foreach ($group->children as $subgroupid) { + $instructorids = array_merge( + $instructorids, + $this->get_instructor_ids_from_learner_group($subgroupid) + ); + // We don't care about instructor groups here, + // we will merge instructor groups into the $instructorIds array later. + } + } + + // Next, get the ids of all instructors from the instructor-groups that we determined as relevant earlier. + // But first let's de-dupe them. + $instructorgroupids = array_unique($instructorgroupids); + if (!empty($instructorgroupids)) { + $instructorgroups = $this->get_instructor_groups(['id' => $instructorgroupids]); + foreach ($instructorgroups as $instructorgroup) { + $instructorids = array_merge($instructorids, $instructorgroup->users); + } + } + + // Finally, we retrieve all the users that were identified as relevant instructors earlier. + $instructorids = array_unique($instructorids); + asort($instructorids); + return array_values($instructorids); + } + /** * Sends a GET request to a given API endpoint with given options. * * @param string $path The target path fragment of the API request URL. May include query parameters. + * @param array $filterby An associative array of filter options. + * @param array $sortby An associative array of sort options. * @param array $options Additional options. * @return object The decoded response body. * @throws GuzzleException * @throws moodle_exception */ - public function get(string $path, array $options = []): object { + public function get( + string $path, + array $filterby = [], + array $sortby = [], + array $options = [] + ): object { $this->validate_access_token($this->accesstoken); if (!array_key_exists('headers', $options) || empty($options['headers'])) { @@ -111,10 +367,81 @@ public function get(string $path, array $options = []): object { 'X-JWT-Authorization' => 'Token ' . $this->accesstoken, ]]); } - $response = $this->httpclient->get($this->apibaseurl . $path, $options); + + // Construct query params from given filters and sort orders. + // Unfortunately, http_build_query() doesn't cut it here, so we have to hand-roll this. + $queryparams = []; + if (!empty($filterby)) { + foreach ($filterby as $param => $value) { + if (is_array($value)) { + foreach ($value as $val) { + $queryparams[] = "filters[$param][]=$val"; + } + } else { + $queryparams[] = "filters[$param]=$value"; + } + } + } + + if (!empty($sortby)) { + foreach ($sortby as $param => $value) { + $queryparams[] = "order_by[$param]=$value"; + } + } + + $url = $this->apibaseurl . $path; + + if (!empty($queryparams)) { + $url .= '?' . implode('&', $queryparams); + } + + $response = $this->httpclient->get($url, $options); return $this->parse_result($response->getBody()); } + /** + * Decodes and retrieves the payload of the given access token. + * + * @param string $accesstoken the Ilios API access token + * @return array the token payload as key/value pairs. + * @throws moodle_exception + */ + public static function get_access_token_payload(string $accesstoken): array { + $parts = explode('.', $accesstoken); + if (count($parts) !== 3) { + throw new moodle_exception('errorinvalidnumbertokensegments', 'enrol_ilios'); + } + $payload = json_decode(JWT::urlsafeB64Decode($parts[1]), true); + if (!$payload) { + throw new moodle_exception('errordecodingtoken', 'enrol_ilios'); + } + return $payload; + } + + /** + * Retrieves a given resource from Ilios by its given ID. + * + * @param string $path The URL path fragment that names the resource. + * @param int $id The ID. + * @param bool $returnnullonnotfound If TRUE then NULL is returned if the resource cannot be found. + * On FALSE, an exception is raised on 404/Not-Found. + * Defaults to TRUE. + * @return object|null The resource object, or NULL. + * @throws GuzzleException + * @throws moodle_exception + */ + public function get_by_id(string $path, int $id, bool $returnnullonnotfound = true): ?object { + try { + return $this->get($path . '/' . $id); + } catch (ClientException $e) { + if ($returnnullonnotfound && (404 === $e->getResponse()->getStatusCode())) { + return null; + } + // Re-throw the exception otherwise. + throw $e; + } + } + /** * Decodes and returns the given JSON-encoded input. * @@ -166,23 +493,4 @@ protected function validate_access_token(string $accesstoken): void { throw new moodle_exception('errortokenexpired', 'enrol_ilios'); } } - - /** - * Decodes and retrieves the payload of the given access token. - * - * @param string $accesstoken the Ilios API access token - * @return array the token payload as key/value pairs. - * @throws moodle_exception - */ - public static function get_access_token_payload(string $accesstoken): array { - $parts = explode('.', $accesstoken); - if (count($parts) !== 3) { - throw new moodle_exception('errorinvalidnumbertokensegments', 'enrol_ilios'); - } - $payload = json_decode(JWT::urlsafeB64Decode($parts[1]), true); - if (!$payload) { - throw new moodle_exception('errordecodingtoken', 'enrol_ilios'); - } - return $payload; - } } diff --git a/edit.php b/edit.php index 25b262f..6a670fc 100644 --- a/edit.php +++ b/edit.php @@ -23,6 +23,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\di; +use enrol_ilios\ilios; + require('../../config.php'); require_once("$CFG->dirroot/enrol/ilios/edit_form.php"); require_once("$CFG->dirroot/group/lib.php"); @@ -45,6 +48,13 @@ redirect($returnurl); } +try { + $ilios = di::get(ilios::class); +} catch (Exception $e) { + // Re-throw exception. + throw new Exception('ERROR: Failed to instantiate Ilios client.', $e); +} + /** @var enrol_ilios_plugin $enrol */ $enrol = enrol_get_plugin('ilios'); @@ -74,7 +84,7 @@ $courseadmin->get('users')->get('manageinstances')->make_active(); } -$mform = new enrol_ilios_edit_form(null, [$instance, $enrol, $course]); +$mform = new enrol_ilios_edit_form(null, [$instance, $enrol, $course, $ilios]); if ($mform->is_cancelled()) { redirect($returnurl); diff --git a/edit_form.php b/edit_form.php index 7aede27..72b853f 100644 --- a/edit_form.php +++ b/edit_form.php @@ -23,6 +23,9 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ +use core\di; +use enrol_ilios\ilios; + defined('MOODLE_INTERNAL') || die(); require_once("$CFG->libdir/formslib.php"); @@ -37,6 +40,7 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ class enrol_ilios_edit_form extends moodleform { + /** * Form definition. * @@ -46,11 +50,11 @@ class enrol_ilios_edit_form extends moodleform { * @throws moodle_exception */ protected function definition(): void { - global $CFG, $DB, $PAGE; + global $DB, $PAGE; $mform = $this->_form; /* @var enrol_ilios_plugin $plugin This enrolment plugin. */ - list($instance, $plugin, $course) = $this->_customdata; + list($instance, $plugin, $course, $ilios) = $this->_customdata; $coursecontext = context_course::instance($course->id); $PAGE->requires->yui_module( @@ -65,8 +69,6 @@ protected function definition(): void { ); $enrol = $plugin; - $apiclient = $plugin->get_api_client(); - $accesstoken = $plugin->get_api_access_token(); $mform->addElement('header', 'general', get_string('pluginname', 'enrol_ilios')); @@ -96,12 +98,12 @@ protected function definition(): void { $syncinfo = json_decode($instance->customtext1); $instance->schoolid = $syncinfo->school->id; - $school = $apiclient->get_by_id($accesstoken, 'schools', $instance->schoolid); + $school = $ilios->get_school($instance->schoolid); $instance->selectschoolindex = "$instance->schoolid:$school->title"; $schooloptions = [ $instance->selectschoolindex => $school->title ]; $instance->programid = $syncinfo->program->id; - $program = $apiclient->get_by_id($accesstoken, 'programs', $instance->programid); + $program = $ilios->get_program($instance->programid); $instance->selectprogramindex = $instance->programid; foreach (['shortTitle', 'title'] as $attr) { @@ -113,7 +115,7 @@ protected function definition(): void { $programoptions = [ $instance->selectprogramindex => $program->title ]; $instance->cohortid = $syncinfo->cohort->id; - $cohort = $apiclient->get_by_id($accesstoken, 'cohorts', $instance->cohortid); + $cohort = $ilios->get_cohort($instance->cohortid); $instance->selectcohortindex = "$instance->cohortid:$cohort->title"; $cohortoptions = [ $instance->selectcohortindex => $cohort->title @@ -127,11 +129,7 @@ protected function definition(): void { if ($synctype == 'learnerGroup') { $instance->learnergroupid = $syncid; - if (!empty($instance->customint2)) { - $group = $enrol->get_group_data('learnerGroup', $instance->learnergroupid); - } else { - $group = $apiclient->get_by_id($accesstoken, 'learnerGroups', $instance->learnergroupid); - } + $group = $ilios->get_learner_group($instance->learnergroupid); if ($group) { $instance->selectlearnergroupindex = "$instance->learnergroupid:$group->title"; @@ -141,14 +139,13 @@ protected function definition(): void { $learnergroupoptions = [$instance->selectlearnergroupindex => $grouptitle]; if (!empty($group->parent)) { - $processparents = function ($child) use (&$processparents, + $processparents = function ($child) use ( + &$processparents, &$learnergroupoptions, &$grouptitle, - &$instance, - $apiclient, - $accesstoken + &$instance ) { - $parentgroup = $apiclient->get_by_id($accesstoken, 'learnerGroups', $child->parent); + $parentgroup = $ilios->get_learner_group($child->parent); $instance->learnergroupid = $parentgroup->id; $instance->selectlearnergroupindex = "$instance->learnergroupid:$parentgroup->title"; $learnergroupoptions = [ @@ -306,7 +303,6 @@ protected function definition(): void { * @throws moodle_exception */ public function definition_after_data(): void { - global $DB; $mform = $this->_form; $progel = $mform->getElement('selectschool'); @@ -314,10 +310,7 @@ public function definition_after_data(): void { return; } - /* @var enrol_ilios_plugin $enrol This enrolment plugin. */ - $enrol = enrol_get_plugin('ilios'); - $apiclient = $enrol->get_api_client(); - $accesstoken = $enrol->get_api_access_token(); + list($instance, $enrol, $course, $ilios) = $this->_customdata; $selectvalues = $mform->getElementValue('selectschool'); if (is_array($selectvalues)) { @@ -367,7 +360,7 @@ public function definition_after_data(): void { $learnergrouptitle = ''; } - $schools = $apiclient->get($accesstoken, 'schools', '', ['title' => "ASC"]); + $schools = $ilios->get_schools(sortby: ['title' => 'ASC']); $progel =& $mform->getElement('selectschool'); if ($schools === null) { // No connection to the server. @@ -384,13 +377,7 @@ public function definition_after_data(): void { $sid = $schoolid; $progel =& $mform->getElement('selectprogram'); $programoptions = []; - $programs = []; - $programs = $apiclient->get( - $accesstoken, - 'programs', - ['school' => $sid], - ['title' => "ASC"] - ); + $programs = $ilios->get_programs(['school' => $sid], ['title' => "ASC"]); if (!empty($programs)) { foreach ($programs as $program) { @@ -412,24 +399,14 @@ public function definition_after_data(): void { $progel =& $mform->getElement('selectcohort'); $cohortoptions = []; - $programyears = $apiclient->get( - $accesstoken, - 'programYears', - ["program" => $pid], - ["startYear" => "ASC"] - ); + $programyears = $ilios->get_program_years(["program" => $pid], ["startYear" => "ASC"]); $programyeararray = []; foreach ($programyears as $progyear) { $programyeararray[] = $progyear->id; } if (!empty($programyeararray)) { - $cohorts = $apiclient->get( - $accesstoken, - 'cohorts', - ["programYear" => $programyeararray], - ["title" => "ASC"] - ); + $cohorts = $ilios->get_cohorts(["programYear" => $programyeararray], ["title" => "ASC"]); foreach ($cohorts as $cohort) { $cohortoptions["$cohort->id:$cohort->title"] = $cohort->title @@ -445,12 +422,7 @@ public function definition_after_data(): void { $progel =& $mform->getElement('selectlearnergroup'); $learnergroupoptions = []; - $learnergroups = $apiclient->get( - $accesstoken, - 'learnerGroups', - ['cohort' => $cid, 'parent' => 'null'], - ['title' => "ASC"] - ); + $learnergroups = $ilios->get_learner_groups(['cohort' => $cid, 'parent' => 'null'], ['title' => "ASC"]); if (!empty($learnergroups)) { foreach ($learnergroups as $group) { $learnergroupoptions["$group->id:$group->title"] = $group->title. @@ -466,24 +438,14 @@ public function definition_after_data(): void { $progel =& $mform->getElement('selectsubgroup'); $subgroupoptions = []; - $subgroups = $apiclient->get( - $accesstoken, - 'learnerGroups', - ["parent" => $gid], - ["title" => "ASC"] - ); + $subgroups = $ilios->get_learner_groups(["parent" => $gid], ["title" => "ASC"]); foreach ($subgroups as $subgroup) { $subgroupoptions["$subgroup->id:$subgroup->title"] = $subgroup->title. ' ('. count($subgroup->children) .')'. ' ('. count($subgroup->users) .')'; if (!empty($subgroup->children)) { - $processchildren = function ($parent) use (&$processchildren, &$subgroupoptions, $apiclient, $accesstoken) { - $subgrps = $apiclient->get( - $accesstoken, - 'learnerGroups', - [ 'parent' => $parent->id], - [ 'title' => "ASC"] - ); + $processchildren = function ($parent) use (&$processchildren, &$subgroupoptions) { + $subgrps = $ilios->get_learner_groups([ 'parent' => $parent->id], [ 'title' => "ASC"]); foreach ($subgrps as $subgrp) { $subgroupoptions["$subgrp->id:$parent->title / $subgrp->title"] = $parent->title.' / '.$subgrp->title. ' ('. count($subgrp->children) .')'. diff --git a/lib.php b/lib.php index 23f35c6..597678f 100644 --- a/lib.php +++ b/lib.php @@ -23,7 +23,8 @@ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later */ -use local_iliosapiclient\ilios_client; +use core\di; +use enrol_ilios\ilios; defined('MOODLE_INTERNAL') || die(); @@ -36,20 +37,20 @@ */ class enrol_ilios_plugin extends enrol_plugin { /** - * @var ilios_client The Ilios API client. + * @var ilios The Ilios API client. */ - protected ilios_client $apiclient; - - /** - * @var string the plugin settings key for the API access token. - */ - public const SETTINGS_API_ACCESS_TOKEN = 'apikey'; + protected ilios $ilios; /** * Constructor. */ public function __construct() { - $this->apiclient = new ilios_client($this->get_config('host_url', ''), new curl()); + try { + $this->ilios = di::get(ilios::class); + } catch (Exception $e) { + // Re-throw exception. + throw new Exception('ERROR: Failed to instantiate Ilios client.', $e); + } } /** @@ -76,24 +77,6 @@ public function can_hide_show_instance($instance): bool { return has_capability('enrol/ilios:config', $context); } - /** - * Returns the Ilios Client for API access. - * - * @return ilios_client The Ilios API client. - */ - public function get_api_client(): ilios_client { - return $this->apiclient; - } - - /** - * Retrieves the Ilios API access token from the plugin configuration. - * - * @return string The API access token. - */ - public function get_api_access_token(): string { - return $this->get_config(self::SETTINGS_API_ACCESS_TOKEN, ''); - } - /** * Returns localised name of enrol instance. * @@ -228,9 +211,6 @@ public function sync($trace, $courseid = null): int { global $CFG, $DB; require_once($CFG->dirroot . '/group/lib.php'); - $apiclient = $this->get_api_client(); - $accesstoken = $this->get_api_access_token(); - if (!enrol_is_enabled('ilios')) { // Purge all roles if ilios sync disabled, those can be recreated later here by cron or CLI. $trace->output('Ilios enrolment sync plugin is disabled, unassigning all plugin roles and stopping.'); @@ -264,37 +244,39 @@ public function sync($trace, $courseid = null): int { $synctype = $instance->customchar1; $syncid = $instance->customint1; - if (!empty($instance->customint2)) { - // Need to get instructor ids. This function takes longer to run. - $group = $this->get_group_data($synctype, $syncid); + if ('learnerGroup' === $synctype) { + $entity = $this->ilios->get_learner_group($syncid); } else { - // No need to get instructor ids. - $group = $apiclient->get_by_id($accesstoken, $synctype.'s', $syncid); + $entity = $this->ilios->get_cohort($syncid); } - if (empty($group)) { + if (empty($entity)) { $trace->output("skipping: Unable to fetch data for Ilios $synctype ID $syncid.", 1); continue; } $enrolleduserids = []; // Keep a list of enrolled user's Moodle userid (both learners and instructors). - $users = []; // Ilios users in that group. $suspendenrolments = []; // List of user enrollments to suspend. - $users = []; - - if (!empty($instance->customint2) && !empty($group->instructors)) { - $trace->output( - "Enrolling instructors to Course ID " - . $instance->courseid - . " with Role ID " - . $instance->roleid - . " through Ilios Sync ID " - . $instance->id - . "." - ); - $users = $apiclient->get_by_ids($accesstoken, 'users', $group->instructors); - } else if (!empty($group->users)) { + $users = []; // Ilios users in that group. + if (!empty($instance->customint2)) { + $instructors = []; + if ('learnerGroup' === $synctype && !empty($instance->customint2)) { + $instructors = $this->ilios->get_instructor_ids_from_learner_group($entity->id); + } + if (!empty($instructors)) { + $trace->output( + "Enrolling instructors to Course ID " + . $instance->courseid + . " with Role ID " + . $instance->roleid + . " through Ilios Sync ID " + . $instance->id + . "." + ); + $users = $this->ilios->get_users(['id' => $instructors]); + } + } else if (!empty($entity->users)) { $trace->output( "Enrolling students to Course ID " . $instance->courseid @@ -304,7 +286,7 @@ public function sync($trace, $courseid = null): int { $instance->id . "." ); - $users = $apiclient->get_by_ids($accesstoken, 'users', $group->users); + $users = $this->ilios->get_users(['id' => $entity->users]); } $trace->output(count($users) . " Ilios users found."); @@ -400,16 +382,6 @@ public function sync($trace, $courseid = null): int { $this->update_user_enrol($instance, $ue->userid, ENROL_USER_SUSPENDED); } - // Unenrol as necessary. - $trace->output( - "Unenrolling users from Course ID " - . $instance->courseid." with Role ID " - . $instance->roleid - . " that no longer associate with Ilios Sync ID " - . $instance->id - . "." - ); - $sql = "SELECT ue.* FROM {user_enrolments} ue WHERE ue.enrolid = $instance->id"; @@ -441,7 +413,7 @@ public function sync($trace, $courseid = null): int { 'itemid' => $instance->id, ]); $trace->output( - "suspending and unsassigning all roles: userid " + "suspending and unassigning all roles: userid " . $ue->userid . " ==> courseid " . $instance->courseid @@ -711,117 +683,6 @@ public function restore_user_enrolment( $this->enrol_user($instance, $userid, null, $data->timestart, $data->timeend, ENROL_USER_SUSPENDED); } } - - /** - * Recursive get for learner group data with instructors info, to compensate for - * something that the ILIOS API fails to do! - * - * @param string $grouptype Singular noun of the group type, e.g. cohort, learnerGroup. - * @param string $groupid The ID for the corresponding group type, e.g. cohort id, learner group id. - * - * @return mixed Returned by the ILIOS api in addition of populating - * the instructor array with correct ids, which is to - * iterate into offerings and ilmSessions and fetch the - * associated instructors and instructor groups. Should - * also iterate into subgroups. - * @throws Exception - */ - public function get_group_data($grouptype, $groupid) { - $apiclient = $this->get_api_client(); - $accesstoken = $this->get_api_access_token(); - // Ilios API uses a plural noun, append an 's'. - $group = $apiclient->get_by_id($accesstoken, $grouptype.'s', $groupid ); - - if ($group && $grouptype === 'learnerGroup') { - $group->instructors = $this->get_instructor_ids_from_group($grouptype, $groupid); - asort($group->instructors); - } - - return $group; - } - - /** - * Retrieves a list instructors for a given type of group (learner group or instructor group) and given group ID. - - * @param string $grouptype The group type (either 'instructorgroup' or 'learnergroup'). - * @param string $groupid The group ID. - * @return array A list of user IDs. - * @throws moodle_exception - */ - private function get_instructor_ids_from_group($grouptype, $groupid): array { - $apiclient = $this->get_api_client(); - $accesstoken = $this->get_api_access_token(); - - // Ilios API uses a plural noun, append an 's'. - $group = $apiclient->get_by_id($accesstoken, $grouptype.'s', $groupid); - - $instructorgroupids = []; - $instructorids = []; - - // Get instructors/instructor-groups from the offerings that this learner group is being taught in. - if (!empty($group->offerings)) { - $offerings = $apiclient->get_by_ids($accesstoken, 'offerings', $group->offerings); - - foreach ($offerings as $offering) { - if (empty($offering->instructors)) { - // No instructor AND no instructor groups have been set for this offering. - // Fall back to the default instructors/instructor-groups defined for the learner group. - $instructorids = array_merge($instructorids, $group->instructors); - $instructorgroupids = array_merge($instructorgroupids, $group->instructorGroups); - } else { - // If there are instructors and/or instructor-groups set on the offering, then use these. - $instructorids = array_merge($instructorids, $offering->instructors); - $instructorgroupids = array_merge($instructorgroupids, $offering->instructorGroups); - } - } - } - - // Get instructors/instructor-groups from the ilm sessions that this learner group is being taught in. - // This is a rinse/repeat from offerings-related code above. - if (!empty($group->ilmSessions)) { - $ilms = $apiclient->get_by_ids($accesstoken, 'ilmSessions', $group->ilmSessions); - - foreach ($ilms as $ilm) { - if (empty($ilm->instructors) && empty($ilm->instructorGroups)) { - // No instructor AND no instructor groups have been set for this offering. - // Fall back to the default instructors/instructor-groups defined for the learner group. - $instructorids = array_merge($instructorids, $group->instructors); - $instructorgroupids = array_merge($instructorgroupids, $group->instructorGroups); - } else { - // If there are instructors and/or instructor-groups set on the offering, then use these. - $instructorids = array_merge($instructorids, $ilm->instructors); - $instructorgroupids = array_merge($instructorgroupids, $ilm->instructorGroups); - } - } - } - - // Get instructors from sub-learner-groups. - if (!empty($group->children)) { - foreach ($group->children as $subgroupid) { - $instructorids = array_merge( - $instructorids, - $this->get_instructor_ids_from_group('learnerGroup', $subgroupid) - ); - // We don't care about instructor groups here, - // we will merge instructor groups into the $instructorIds array later. - } - } - - // Next, get the ids of all instructors from the instructor-groups that we determined as relevant earlier. - // But first let's de-dupe them. - $instructorgroupids = array_unique($instructorgroupids); - if (!empty($instructorgroupids)) { - $instructorgroups = $apiclient->get_by_ids($accesstoken, 'instructorGroups', $instructorgroupids); - foreach ($instructorgroups as $instructorgroup) { - $instructorids = array_merge($instructorids, $instructorgroup->users); - } - } - - // Finally, we retrieve all the users that were identified as relevant instructors earlier. - $instructorids = array_unique($instructorids); - - return $instructorids; - } } /** diff --git a/tests/ilios_test.php b/tests/ilios_test.php index de509a4..29a1320 100644 --- a/tests/ilios_test.php +++ b/tests/ilios_test.php @@ -28,6 +28,7 @@ use advanced_testcase; use core\di; use core\http_client; +use GuzzleHttp\Exception\ClientException; use GuzzleHttp\Exception\GuzzleException; use GuzzleHttp\Handler\MockHandler; use GuzzleHttp\HandlerStack; @@ -60,8 +61,8 @@ public function test_get_schools(): void { $handlerstack = HandlerStack::create(new MockHandler([ new Response(200, [], json_encode([ 'schools' => [ - ['id' => 1, 'title' => 'Medicine'], - ['id' => 2, 'title' => 'Pharmacy'], + ['id' => 1, 'title' => 'Medicine', 'programs' => ['2', '4']], + ['id' => 2, 'title' => 'Pharmacy', 'programs' => ['3', '5']], ], ])), ])); @@ -71,37 +72,802 @@ public function test_get_schools(): void { $this->assertCount(2, $schools); $this->assertEquals(1, $schools[0]->id); $this->assertEquals('Medicine', $schools[0]->title); + $this->assertEquals(['2', '4'], $schools[0]->programs); $this->assertEquals(2, $schools[1]->id); $this->assertEquals('Pharmacy', $schools[1]->title); + $this->assertEquals(['3', '5'], $schools[1]->programs); + } + + /** + * Tests the happy path on get_cohorts(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_cohorts(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'title' => 'Class of 2023', + 'programYear' => 1, + 'courses' => ['3'], + 'users' => ['1', '2'], + 'learnerGroups' => ['5', '8'], + ], + [ + 'id' => 2, + 'title' => 'Class of 2024', + 'programYear' => 3, + 'courses' => [], + 'users' => [], + 'learnerGroups' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $cohorts = $ilios->get_cohorts(); + $this->assertCount(2, $cohorts); + $this->assertEquals(1, $cohorts[0]->id); + $this->assertEquals('Class of 2023', $cohorts[0]->title); + $this->assertEquals(1, $cohorts[0]->programYear); + $this->assertEquals(['3'], $cohorts[0]->courses); + $this->assertEquals(['1', '2'], $cohorts[0]->users); + $this->assertEquals(['5', '8'], $cohorts[0]->learnerGroups); + $this->assertEquals(2, $cohorts[1]->id); + $this->assertEquals('Class of 2024', $cohorts[1]->title); + $this->assertEquals(3, $cohorts[1]->programYear); + $this->assertEquals([], $cohorts[1]->courses); + $this->assertEquals([], $cohorts[1]->users); + $this->assertEquals([], $cohorts[1]->learnerGroups); + } + + /** + * Tests the happy path on get_programs(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_programs(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'programs' => [ + [ + 'id' => 1, + 'title' => 'Doctor of Medicine - MD', + 'shortTitle' => 'MD', + 'school' => 1, + 'programYears' => ['1', '2'], + ], + [ + 'id' => 2, + 'title' => 'Doctor of Medicine - Bridges', + 'shortTitle' => 'Bridges', + 'school' => 2, + 'programYears' => ['3'], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $programs = $ilios->get_programs(); + $this->assertCount(2, $programs); + $this->assertEquals(1, $programs[0]->id); + $this->assertEquals('Doctor of Medicine - MD', $programs[0]->title); + $this->assertEquals('MD', $programs[0]->shortTitle); + $this->assertEquals(1, $programs[0]->school); + $this->assertEquals(['1', '2'], $programs[0]->programYears); + $this->assertEquals(2, $programs[1]->id); + $this->assertEquals('Doctor of Medicine - Bridges', $programs[1]->title); + $this->assertEquals('Bridges', $programs[1]->shortTitle); + $this->assertEquals(2, $programs[1]->school); + $this->assertEquals(['3'], $programs[1]->programYears); + } + + /** + * Tests the happy path on get_program_years(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_program_years(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'programYears' => [ + [ + 'id' => 1, + 'startYear' => 2023, + 'program' => 1, + 'cohort' => 2, + ], + [ + 'id' => 2, + 'startYear' => 2024, + 'program' => 2, + 'cohort' => 3, + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $programs = $ilios->get_program_years(); + $this->assertCount(2, $programs); + $this->assertEquals(1, $programs[0]->id); + $this->assertEquals(2023, $programs[0]->startYear); + $this->assertEquals(1, $programs[0]->program); + $this->assertEquals(2, $programs[0]->cohort); + $this->assertEquals(2, $programs[1]->id); + $this->assertEquals(2024, $programs[1]->startYear); + $this->assertEquals(2, $programs[1]->program); + $this->assertEquals(3, $programs[1]->cohort); + } + + /** + * Tests the happy path on get_learner_groups(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_learner_groups(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'instructorGroups' => ['3', '4', '5'], + 'instructors' => ['7'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 2, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'instructorGroups' => [], + 'instructors' => [], + 'users' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $learnergroups = $ilios->get_learner_groups(); + $this->assertCount(2, $learnergroups); + $this->assertEquals(1, $learnergroups[0]->id); + $this->assertEquals('Alpha', $learnergroups[0]->title); + $this->assertEquals(1, $learnergroups[0]->cohort); + $this->assertNull($learnergroups[0]->parent); + $this->assertEquals(['2'], $learnergroups[0]->children); + $this->assertEquals(['1', '2'], $learnergroups[0]->ilmSessions); + $this->assertEquals(['5', '6'], $learnergroups[0]->offerings); + $this->assertEquals(['3', '4', '5'], $learnergroups[0]->instructorGroups); + $this->assertEquals(['7'], $learnergroups[0]->instructors); + $this->assertEquals(['4', '12'], $learnergroups[0]->users); + $this->assertEquals(2, $learnergroups[1]->id); + $this->assertEquals('Beta', $learnergroups[1]->title); + $this->assertEquals(2, $learnergroups[1]->cohort); + $this->assertEquals(1, $learnergroups[1]->parent); + $this->assertEquals([], $learnergroups[1]->children); + $this->assertEquals([], $learnergroups[1]->ilmSessions); + $this->assertEquals([], $learnergroups[1]->offerings); + $this->assertEquals([], $learnergroups[1]->instructorGroups); + $this->assertEquals([], $learnergroups[1]->instructors); + $this->assertEquals([], $learnergroups[1]->users); + } + + /** + * Tests the happy path on get_instructor_groups(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_instructor_groups(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 1, + 'title' => 'Anatomy Lab Instructors', + 'school' => 1, + 'learnerGroups' => ['8', '9'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Clinical Pharmacy Instructors', + 'school' => 2, + 'learnerGroups' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'users' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $instructorgroups = $ilios->get_instructor_groups(); + $this->assertCount(2, $instructorgroups); + $this->assertEquals(1, $instructorgroups[0]->id); + $this->assertEquals('Anatomy Lab Instructors', $instructorgroups[0]->title); + $this->assertEquals(1, $instructorgroups[0]->school); + $this->assertEquals(['8', '9'], $instructorgroups[0]->learnerGroups); + $this->assertEquals(['1', '2'], $instructorgroups[0]->ilmSessions); + $this->assertEquals(['5', '6'], $instructorgroups[0]->offerings); + $this->assertEquals(['4', '12'], $instructorgroups[0]->users); + $this->assertEquals(2, $instructorgroups[1]->id); + $this->assertEquals('Clinical Pharmacy Instructors', $instructorgroups[1]->title); + $this->assertEquals(2, $instructorgroups[1]->school); + $this->assertEquals([], $instructorgroups[1]->learnerGroups); + $this->assertEquals([], $instructorgroups[1]->ilmSessions); + $this->assertEquals([], $instructorgroups[1]->offerings); + $this->assertEquals([], $instructorgroups[1]->users); + } + + /** + * Tests the happy path on get_users(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_offerings(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1', '2'], + 'instructorGroups' => ['2', '4'], + 'learners' => ['8', '9'], + 'instructors' => ['5', '6'], + ], + [ + 'id' => 2, + 'learnerGroups' => [], + 'instructorGroups' => [], + 'learners' => [], + 'instructors' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $offerings = $ilios->get_offerings(); + $this->assertCount(2, $offerings); + $this->assertEquals(1, $offerings[0]->id); + $this->assertEquals(['1', '2'], $offerings[0]->learnerGroups); + $this->assertEquals(['2', '4'], $offerings[0]->instructorGroups); + $this->assertEquals(['8', '9'], $offerings[0]->learners); + $this->assertEquals(['5', '6'], $offerings[0]->instructors); + $this->assertEquals(2, $offerings[1]->id); + $this->assertEquals([], $offerings[1]->learnerGroups); + $this->assertEquals([], $offerings[1]->instructorGroups); + $this->assertEquals([], $offerings[1]->learners); + $this->assertEquals([], $offerings[1]->instructors); + } + + /** + * Tests the happy path on get_users(). + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_ilms(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1', '2'], + 'instructorGroups' => ['2', '4'], + 'learners' => ['8', '9'], + 'instructors' => ['5', '6'], + ], + [ + 'id' => 2, + 'learnerGroups' => [], + 'instructorGroups' => [], + 'learners' => [], + 'instructors' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $ilms = $ilios->get_ilms(); + $this->assertCount(2, $ilms); + $this->assertEquals(1, $ilms[0]->id); + $this->assertEquals(['1', '2'], $ilms[0]->learnerGroups); + $this->assertEquals(['2', '4'], $ilms[0]->instructorGroups); + $this->assertEquals(['8', '9'], $ilms[0]->learners); + $this->assertEquals(['5', '6'], $ilms[0]->instructors); + $this->assertEquals(2, $ilms[1]->id); + $this->assertEquals([], $ilms[1]->learnerGroups); + $this->assertEquals([], $ilms[1]->instructorGroups); + $this->assertEquals([], $ilms[1]->learners); + $this->assertEquals([], $ilms[1]->instructors); } /** - * Tests the happy path on get_enabled_users_in_school(). + * Tests the happy path on get_users(). * * @return void * @throws GuzzleException * @throws moodle_exception */ - public function test_get_enabled_users_in_school(): void { + public function test_get_users(): void { $this->resetAfterTest(); $accesstoken = helper::create_valid_ilios_api_access_token(); set_config('apikey', $accesstoken, 'enrol_ilios'); $handlerstack = HandlerStack::create(new MockHandler([ new Response(200, [], json_encode([ 'users' => [ - ['id' => 1, 'campusId' => 'xx00001'], - ['id' => 2, 'campusId' => 'xx00002'], + [ + 'id' => 1, + 'enabled' => true, + 'campusId' => 'xx1000001', + ], + [ + 'id' => 2, + 'enabled' => false, + 'campusId' => 'xx1000002', + ], ], ])), ])); di::set(http_client::class, new http_client(['handler' => $handlerstack])); $ilios = di::get(ilios::class); - $users = $ilios->get_enabled_users_in_school(123); + $users = $ilios->get_users(); $this->assertCount(2, $users); $this->assertEquals(1, $users[0]->id); - $this->assertEquals('xx00001', $users[0]->campusId); + $this->assertTrue($users[0]->enabled); + $this->assertEquals('xx1000001', $users[0]->campusId); $this->assertEquals(2, $users[1]->id); - $this->assertEquals('xx00002', $users[1]->campusId); + $this->assertFalse($users[1]->enabled); + $this->assertEquals('xx1000002', $users[1]->campusId); + } + + /** + * Tests retrieving a school from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_school(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'schools' => [ + ['id' => 1, 'title' => 'Medicine', 'programs' => ['2', '4']], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $school = $ilios->get_school(1); + $this->assertEquals(1, $school->id); + $this->assertEquals('Medicine', $school->title); + $this->assertEquals(['2', '4'], $school->programs); + } + + /** + * Tests retrieving a school that's missing from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_school_not_found(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $school = $ilios->get_school(1); + $this->assertNull($school); + } + + /** + * Tests retrieving a cohort from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_cohort(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'cohorts' => [ + [ + 'id' => 1, + 'title' => 'Class of 2023', + 'programYear' => 1, + 'courses' => ['3'], + 'users' => ['1', '2'], + 'learnerGroups' => ['5', '8'], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $cohort = $ilios->get_cohort(1); + $this->assertEquals(1, $cohort->id); + $this->assertEquals('Class of 2023', $cohort->title); + $this->assertEquals(1, $cohort->programYear); + $this->assertEquals(['3'], $cohort->courses); + $this->assertEquals(['1', '2'], $cohort->users); + $this->assertEquals(['5', '8'], $cohort->learnerGroups); + } + + /** + * Tests retrieving a cohort that's missing from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_cohort_not_found(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $cohort = $ilios->get_cohort(1); + $this->assertNull($cohort); + } + + /** + * Tests retrieving a program from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_program(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'programs' => [ + [ + 'id' => 1, + 'title' => 'Doctor of Medicine - MD', + 'shortTitle' => 'MD', + 'school' => 1, + 'programYears' => ['1', '2'], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $program = $ilios->get_program(1); + $this->assertEquals(1, $program->id); + $this->assertEquals('Doctor of Medicine - MD', $program->title); + $this->assertEquals('MD', $program->shortTitle); + $this->assertEquals(1, $program->school); + $this->assertEquals(['1', '2'], $program->programYears); + } + + /** + * Tests retrieving a program that's missing from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_program_not_found(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $program = $ilios->get_program(1); + $this->assertNull($program); + } + + /** + * Tests retrieving a learner-group from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_learner_group(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['5', '6'], + 'instructorGroups' => ['3', '4', '5'], + 'instructors' => ['7'], + 'users' => ['4', '12'], + ], + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 2, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => [], + 'instructorGroups' => [], + 'instructors' => [], + 'users' => [], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $learnergroup = $ilios->get_learner_group(1); + $this->assertEquals(1, $learnergroup->id); + $this->assertEquals('Alpha', $learnergroup->title); + $this->assertEquals(1, $learnergroup->cohort); + $this->assertNull($learnergroup->parent); + $this->assertEquals(['2'], $learnergroup->children); + $this->assertEquals(['1', '2'], $learnergroup->ilmSessions); + $this->assertEquals(['5', '6'], $learnergroup->offerings); + $this->assertEquals(['3', '4', '5'], $learnergroup->instructorGroups); + $this->assertEquals(['7'], $learnergroup->instructors); + $this->assertEquals(['4', '12'], $learnergroup->users); + } + + /** + * Tests retrieving a learner-group that's missing from Ilios. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_learner_group_not_found(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $learnergroup = $ilios->get_learner_group(1); + $this->assertNull($learnergroup); + } + + /** + * Tests retrieving instructors for a given learner-group and its subgroups. + * + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_instructor_ids_from_learner_group(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + // The user ids in the 900 range are users we don't want in the output. + // All other user ids, 1-9 should be in the output of this function. + // Some of these are assigned instructors in various ways, so we can verify that de-duping works. + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 1, + 'title' => 'Alpha', + 'cohort' => 1, + 'parent' => null, + 'children' => ['2', '3'], + 'ilmSessions' => ['1', '2'], + 'offerings' => ['1', '2'], + 'instructorGroups' => ['900'], + 'instructors' => ['900'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1'], + 'instructorGroups' => ['1'], + 'learners' => ['901', '902'], + 'instructors' => [], + ], + [ + 'id' => 2, + 'learnerGroups' => ['1'], + 'instructorGroups' => ['901'], + 'learners' => ['903'], + 'instructors' => ['1'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 1, + 'learnerGroups' => ['1'], + 'instructorGroups' => ['1'], + 'learners' => ['901', '902'], + 'instructors' => [], + ], + [ + 'id' => 2, + 'learnerGroups' => ['1'], + 'instructorGroups' => [], + 'learners' => ['903'], + 'instructors' => ['1'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 2, + 'title' => 'Beta', + 'cohort' => 1, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => [], + 'offerings' => ['3'], + 'instructorGroups' => ['2'], + 'instructors' => ['2'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'offerings' => [ + [ + 'id' => 3, + 'learnerGroups' => ['2'], + 'instructorGroups' => [], + 'learners' => ['904'], + 'instructors' => [], + ], + ], + ])), + new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 2, + 'title' => 'Zwei', + 'school' => 1, + 'learnerGroups' => ['2'], + 'ilmSessions' => [], + 'offerings' => [], + 'users' => ['6', '7'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'learnerGroups' => [ + [ + 'id' => 3, + 'title' => 'Gamma', + 'cohort' => 1, + 'parent' => 1, + 'children' => [], + 'ilmSessions' => ['3'], + 'offerings' => [], + 'instructorGroups' => ['3'], + 'instructors' => ['3'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'ilmSessions' => [ + [ + 'id' => 3, + 'learnerGroups' => ['2'], + 'instructorGroups' => [], + 'learners' => ['905'], + 'instructors' => [], + ], + ], + ])), + new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 3, + 'title' => 'Drei', + 'school' => 1, + 'learnerGroups' => [], + 'ilmSessions' => [], + 'offerings' => ['1'], + 'users' => ['8', '9'], + ], + ], + ])), + new Response(200, [], json_encode([ + 'instructorGroups' => [ + [ + 'id' => 1, + 'title' => 'Eins', + 'school' => 1, + 'learnerGroups' => [], + 'ilmSessions' => ['1'], + 'offerings' => ['1'], + 'users' => ['4', '5'], + ], + ], + ])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $ids = $ilios->get_instructor_ids_from_learner_group(1); + $this->assertEquals(['1', '2', '3', '4', '5', '6', '7', '8', '9'], $ids); } /** @@ -133,6 +899,116 @@ public function test_get(): void { $this->assertEquals('Pharmacy', $data->schools[1]->title); } + /** + * Tests get() with filter- and sorting criteria as input. + * + * @dataProvider get_with_filtering_and_sorting_provider + * @param array $filterby An associative array of filtering criteria. + * @param array $sortby An associative array of sorting criteria. + * @param string $expectedquerystring The expected query string that the given criteria transform into. + * @return void + * @throws GuzzleException + * @throws moodle_exception + */ + public function test_get_with_filtering_and_sorting_criteria( + array $filterby, + array $sortby, + string $expectedquerystring): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + set_config('host_url', 'http://ilios.demo', 'enrol_ilios'); + + $mockclient = $this->createMock(http_client::class); + $mockclient + ->expects($this->once()) + ->method('get') + ->with( + $this->equalTo('http://ilios.demo/api/v3/geflarkniks' . $expectedquerystring), + $this->anything(), + ) + ->willReturn(new Response(200, [], json_encode(['geflarkniks' => [['doesnt-really' => 'matter']]]))); + + di::set(http_client::class, $mockclient); + $ilios = di::get(ilios::class); + $ilios->get('geflarkniks', $filterby, $sortby); + } + + /** + * Data provider for test_get_with_filtering_and_sorting_criteria(). + * Returns test filter and sorting criteria and their expected transformation into a query string. + * + * @return array[] + */ + public static function get_with_filtering_and_sorting_provider(): array { + return [ + [[], [], ''], + [['foo' => 'bar'], [], '?filters[foo]=bar'], + [[], ['name' => 'DESC'], '?order_by[name]=DESC'], + [ + ['id' => [1, 2], 'school' => 5], + ['title' => 'ASC'], + '?filters[id][]=1&filters[id][]=2&filters[school]=5&order_by[title]=ASC', + ], + ]; + } + + + /** + * Tests retrieving a resource by its ID from the Ilios API. + */ + public function test_get_by_id(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(200, [], json_encode(['geflarknik' => [ + ['id' => 1, 'title' => 'whatever'], + ]])), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $response = $ilios->get_by_id('does-not-matter-here', 12345); + $this->assertObjectHasProperty('geflarknik', $response); + $this->assertCount(1, $response->geflarknik); + $this->assertEquals('1', $response->geflarknik[0]->id); + $this->assertEquals('whatever', $response->geflarknik[0]->title); + } + + /** + * Tests that get_by_id() raises an exception if Ilios responds with a 404/not-found. + */ + public function test_get_by_id_fails_on_404(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404, []), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $this->expectException(ClientException::class); + $this->expectExceptionMessage('404 Not Found'); + $this->expectExceptionCode(404); + $ilios->get_by_id('does-not-matter-here', 12345, false); + } + + /** + * Tests that get_by_id() returns NULL if Ilios responds with a 404/not-found. + */ + public function test_get_by_id_returns_null_on_404(): void { + $this->resetAfterTest(); + $accesstoken = helper::create_valid_ilios_api_access_token(); + set_config('apikey', $accesstoken, 'enrol_ilios'); + $handlerstack = HandlerStack::create(new MockHandler([ + new Response(404, []), + ])); + di::set(http_client::class, new http_client(['handler' => $handlerstack])); + $ilios = di::get(ilios::class); + $response = $ilios->get_by_id('does-not-matter-here', 12345); + $this->assertNull($response); + } + /** * Tests that get() fails if the response cannot be JSON-decoded. *