From d5ed4a3184e8187edad0eae9cb290c4f5cea1a6b Mon Sep 17 00:00:00 2001 From: Jake Dallimore Date: Mon, 24 Jan 2022 17:22:58 +0800 Subject: [PATCH] MDL-69542 enrol_lti: add LTI Advantage grade sync task This change adds a new grade sync task for LTI Advantage and updates the legacy task such that it only operates on legacy tools. This uses the assignment and grades service 2.0. --- .../local/ltiadvantage/task/sync_grades.php | 300 ++++++++ enrol/lti/classes/task/sync_grades.php | 3 +- .../ltiadvantage/task/sync_grades_test.php | 683 ++++++++++++++++++ enrol/lti/version.php | 2 +- 4 files changed, 986 insertions(+), 2 deletions(-) create mode 100644 enrol/lti/classes/local/ltiadvantage/task/sync_grades.php create mode 100644 enrol/lti/tests/local/ltiadvantage/task/sync_grades_test.php diff --git a/enrol/lti/classes/local/ltiadvantage/task/sync_grades.php b/enrol/lti/classes/local/ltiadvantage/task/sync_grades.php new file mode 100644 index 00000000000..e062685b56a --- /dev/null +++ b/enrol/lti/classes/local/ltiadvantage/task/sync_grades.php @@ -0,0 +1,300 @@ +. + +namespace enrol_lti\local\ltiadvantage\task; + +use core\task\scheduled_task; +use enrol_lti\helper; +use enrol_lti\local\ltiadvantage\lib\http_client; +use enrol_lti\local\ltiadvantage\lib\issuer_database; +use enrol_lti\local\ltiadvantage\lib\launch_cache_session; +use enrol_lti\local\ltiadvantage\repository\application_registration_repository; +use enrol_lti\local\ltiadvantage\repository\deployment_repository; +use enrol_lti\local\ltiadvantage\repository\resource_link_repository; +use enrol_lti\local\ltiadvantage\repository\user_repository; +use Packback\Lti1p3\LtiAssignmentsGradesService; +use Packback\Lti1p3\LtiGrade; +use Packback\Lti1p3\LtiLineitem; +use Packback\Lti1p3\LtiRegistration; +use Packback\Lti1p3\LtiServiceConnector; + +/** + * LTI Advantage task responsible for pushing grades to tool platforms. + * + * @package enrol_lti + * @copyright 2021 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class sync_grades extends scheduled_task { + + /** + * Get a descriptive name for this task. + * + * @return string + */ + public function get_name() { + return get_string('tasksyncgrades', 'enrol_lti'); + } + + /** + * Sync grades to the platform using the Assignment and Grade Services. + * + * @param \stdClass $resource the enrol_lti_tools data record for the shared resource. + * @return array an array containing the + */ + protected function sync_grades_for_resource($resource): array { + $usercount = 0; + $sendcount = 0; + $userrepo = new user_repository(); + $resourcelinkrepo = new resource_link_repository(); + $appregistrationrepo = new application_registration_repository(); + $issuerdb = new issuer_database($appregistrationrepo, new deployment_repository()); + + if ($users = $userrepo->find_by_resource($resource->id)) { + $completion = new \completion_info(get_course($resource->courseid)); + $syncedusergrades = []; // Keep track of those users who have had their grade synced during this run. + foreach ($users as $user) { + $mtracecontent = "for the user '{$user->get_localid()}', for the resource '$resource->id' and the course " . + "'$resource->courseid'"; + $usercount++; + + // Check if we do not have a grade service endpoint in either of the resource links. + // Remember, not all launches need to support grade services. + $userresourcelinks = $resourcelinkrepo->find_by_resource_and_user($resource->id, $user->get_id()); + $userlastgrade = $user->get_lastgrade(); + mtrace("Found ".count($userresourcelinks)." resource link(s) $mtracecontent. Attempting to sync grades for all."); + + foreach ($userresourcelinks as $userresourcelink) { + mtrace("Processing resource link '{$userresourcelink->get_resourcelinkid()}'."); + if (!$gradeservice = $userresourcelink->get_grade_service()) { + mtrace("Skipping - No grade service found $mtracecontent."); + continue; + } + + if (!$context = \context::instance_by_id($resource->contextid, IGNORE_MISSING)) { + mtrace("Failed - Invalid contextid '$resource->contextid' for the resource '$resource->id'."); + continue; + } + + $grade = false; + $dategraded = false; + if ($context->contextlevel == CONTEXT_COURSE) { + if ($resource->gradesynccompletion && !$completion->is_course_complete($user->get_localid())) { + mtrace("Skipping - Course not completed $mtracecontent."); + continue; + } + + // Get the grade. + if ($grade = grade_get_course_grade($user->get_localid(), $resource->courseid)) { + $grademax = floatval($grade->item->grademax); + $dategraded = $grade->dategraded; + $grade = $grade->grade; + } + } else if ($context->contextlevel == CONTEXT_MODULE) { + $cm = get_coursemodule_from_id(false, $context->instanceid, 0, false, MUST_EXIST); + + if ($resource->gradesynccompletion) { + $data = $completion->get_data($cm, false, $user->get_localid()); + if (!in_array($data->completionstate, [COMPLETION_COMPLETE_PASS, COMPLETION_COMPLETE])) { + mtrace("Skipping - Activity not completed $mtracecontent."); + continue; + } + } + + $grades = grade_get_grades($cm->course, 'mod', $cm->modname, $cm->instance, + $user->get_localid()); + if (!empty($grades->items[0]->grades)) { + $grade = reset($grades->items[0]->grades); + if (!empty($grade->item)) { + $grademax = floatval($grade->item->grademax); + } else { + $grademax = floatval($grades->items[0]->grademax); + } + $dategraded = $grade->dategraded; + $grade = $grade->grade; + } + } + + if ($grade === false || $grade === null || strlen($grade) < 1) { + mtrace("Skipping - Invalid grade $mtracecontent."); + continue; + } + + if (empty($grademax)) { + mtrace("Skipping - Invalid grademax $mtracecontent."); + continue; + } + + if (!grade_floats_different($grade, $userlastgrade)) { + mtrace("Not sent - The grade $mtracecontent was not sent as the grades are the same."); + continue; + } + $floatgrade = $grade / $grademax; + + try { + // Get an AGS instance for the corresponding application registration and service data. + $appregistration = $appregistrationrepo->find_by_deployment( + $userresourcelink->get_deploymentid() + ); + $registration = $issuerdb->findRegistrationByIssuer( + $appregistration->get_platformid()->out(false), + $appregistration->get_clientid() + ); + global $CFG; + require_once($CFG->libdir . '/filelib.php'); + $sc = new LtiServiceConnector(new launch_cache_session(), new http_client(new \curl())); + + $lineitemurl = $gradeservice->get_lineitemurl(); + $servicedata = [ + 'lineitems' => $gradeservice->get_lineitemsurl()->out(false), + 'lineitem' => $lineitemurl ? $lineitemurl->out(false) : null, + 'scope' => $gradeservice->get_scopes(), + ]; + + $ags = $this->get_ags($sc, $registration, $servicedata); + $ltigrade = LtiGrade::new() + ->setScoreGiven($grade) + ->setScoreMaximum($grademax) + ->setUserId($user->get_sourceid()) + ->setTimestamp(date(\DateTimeInterface::ISO8601, $dategraded)) + ->setActivityProgress('Completed') + ->setGradingProgress('FullyGraded'); + + if (empty($servicedata['lineitem'])) { + // The launch did not include a couple lineitem, so find or create the line item for grading. + $lineitem = $ags->findOrCreateLineitem(new LtiLineitem([ + 'label' => $this->get_line_item_label($resource, $context), + 'scoreMaximum' => $grademax, + 'tag' => 'grade', + 'resourceId' => $userresourcelink->get_resourceid(), + 'resourceLinkId' => $userresourcelink->get_resourcelinkid() + ])); + $response = $ags->putGrade($ltigrade, $lineitem); + } else { + // Let AGS find the coupled line item. + $response = $ags->putGrade($ltigrade); + } + + } catch (\Exception $e) { + mtrace("Failed - The grade '$floatgrade' $mtracecontent failed to send."); + mtrace($e->getMessage()); + continue; + } + + if ($response['status'] == 200) { + $user->set_lastgrade(grade_floatval($grade)); + $syncedusergrades[$user->get_id()] = $user; + mtrace("Success - The grade '$floatgrade' $mtracecontent was sent."); + } else { + mtrace("Failed - The grade '$floatgrade' $mtracecontent failed to send."); + mtrace("Header: {$response['headers']['httpstatus']}"); + } + } + } + // Update the lastgrade value for any users who had a grade synced. Allows skipping on future runs if not changed. + // Update the count of total users having their grades synced, not the total number of grade sync calls made. + foreach ($syncedusergrades as $ltiuser) { + $userrepo->save($ltiuser); + $sendcount = $sendcount + 1; + } + } + return [$usercount, $sendcount]; + } + + /** + * Get the string label for the line item associated with the resource, based on the course or module name. + * + * @param \stdClass $resource the enrol_lti_tools record. + * @param \context $context the context of the resource - either course or module. + * @return string the label to use in the line item. + */ + protected function get_line_item_label(\stdClass $resource, \context $context): string { + $resourcename = 'default'; + if ($context->contextlevel == CONTEXT_COURSE) { + global $DB; + $coursenamesql = "SELECT c.fullname + FROM {enrol_lti_tools} t + JOIN {enrol} e + ON (e.id = t.enrolid) + JOIN {course} c + ON {c.id} = e.courseid + WHERE t.id = :resourceid"; + $coursename = $DB->get_field_sql($coursenamesql, ['resourceid' => $resource->id]); + $resourcename = format_string($coursename, true, ['context' => $context->id]); + } else if ($context->contextlevel == CONTEXT_MODULE) { + foreach (get_fast_modinfo($resource->courseid)->get_cms() as $mod) { + if ($mod->context->id == $context->id) { + $resourcename = $mod->name; + } + } + } + return $resourcename; + } + + /** + * Get an ags instance to make the call to the platform. + * + * @param LtiServiceConnector $sc a service connector instance. + * @param LtiRegistration $registration the registration instance. + * @param array $sd the service data. + * @return LtiAssignmentsGradesService + */ + protected function get_ags(LtiServiceConnector $sc, LtiRegistration $registration, array $sd): LtiAssignmentsGradesService { + return new LtiAssignmentsGradesService($sc, $registration, $sd); + } + + /** + * Performs the synchronisation of grades from the tool to any registered platforms. + * + * @return bool|void + */ + public function execute() { + global $CFG; + + require_once($CFG->dirroot . '/lib/completionlib.php'); + require_once($CFG->libdir . '/gradelib.php'); + require_once($CFG->dirroot . '/grade/querylib.php'); + + if (!is_enabled_auth('lti')) { + mtrace('Skipping task - ' . get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti'))); + return true; + } + if (!enrol_is_enabled('lti')) { + mtrace('Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti')); + return true; + } + + $resources = helper::get_lti_tools([ + 'status' => ENROL_INSTANCE_ENABLED, + 'gradesync' => 1, + 'ltiversion' => 'LTI-1p3' + ]); + if (empty($resources)) { + mtrace('Skipping task - There are no resources with grade sync enabled.'); + return true; + } + + foreach ($resources as $resource) { + mtrace("Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$resource->courseid'."); + + [$usercount, $sendcount] = $this->sync_grades_for_resource($resource); + + mtrace("Completed - Synced grades for tool '$resource->id' in the course '$resource->courseid'. " . + "Processed $usercount users; sent $sendcount grades."); + mtrace(""); + } + } +} diff --git a/enrol/lti/classes/task/sync_grades.php b/enrol/lti/classes/task/sync_grades.php index e8fa2b9e45d..3f2a08e2208 100644 --- a/enrol/lti/classes/task/sync_grades.php +++ b/enrol/lti/classes/task/sync_grades.php @@ -70,7 +70,8 @@ class sync_grades extends \core\task\scheduled_task { } // Get all the enabled tools. - if ($tools = \enrol_lti\helper::get_lti_tools(array('status' => ENROL_INSTANCE_ENABLED, 'gradesync' => 1))) { + if ($tools = \enrol_lti\helper::get_lti_tools(array('status' => ENROL_INSTANCE_ENABLED, 'gradesync' => 1, + 'ltiversion' => 'LTI-1p0/LTI-2p0'))) { foreach ($tools as $tool) { mtrace("Starting - Grade sync for shared tool '$tool->id' for the course '$tool->courseid'."); diff --git a/enrol/lti/tests/local/ltiadvantage/task/sync_grades_test.php b/enrol/lti/tests/local/ltiadvantage/task/sync_grades_test.php new file mode 100644 index 00000000000..79896db8737 --- /dev/null +++ b/enrol/lti/tests/local/ltiadvantage/task/sync_grades_test.php @@ -0,0 +1,683 @@ +. + +namespace enrol_lti\local\ltiadvantage\task; + +use enrol_lti\helper; +use Packback\Lti1p3\LtiAssignmentsGradesService; + +defined('MOODLE_INTERNAL') || die(); + +require_once(__DIR__ . '/../lti_advantage_testcase.php'); + +/** + * Tests for the enrol_lti\local\ltiadvantage\task\sync_grades scheduled task. + * + * @package enrol_lti + * @copyright 2021 Jake Dallimore + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + * @coversDefaultClass \enrol_lti\local\ltiadvantage\task\sync_grades + */ +class sync_grades_test extends \lti_advantage_testcase { + /** + * Get a task which has a mocked ags instance injected, meaning no real calls will be made to the platform. + * + * This allows us to test the behaviour of the task (in terms of which users are in scope and which grades are sent) + * without needing to deal with any auth. + * + * @param string $statuscode the HTTP status code to simulate. + * @param bool $mockexception whether to simulate an exception during the service call or not. + * @return sync_grades instance of the task with a mocked ags instance inside. + */ + protected function get_task_with_mocked_grade_service($statuscode = '200', $mockexception = false): sync_grades { + $mockgradeservice = $this->getMockBuilder(LtiAssignmentsGradesService::class) + ->disableOriginalConstructor() + ->onlyMethods(['putGrade']) + ->getMock(); + $mockgradeservice->method('putGrade')->willReturnCallback(function() use ($statuscode, $mockexception) { + if ($mockexception) { + throw new \Exception(); + } + return ['headers' => ['httpstatus' => "HTTP/2 $statuscode OK"], 'body' => '', 'status' => $statuscode]; + }); + // Get a mock task, with the method 'get_ags()' mocked to return the mocked AGS instance. + $mocktask = $this->getMockBuilder(sync_grades::class) + ->onlyMethods(['get_ags']) + ->getMock(); + $mocktask->method('get_ags')->willReturn($mockgradeservice); + return $mocktask; + } + + /** + * Helper function to set a grade for a user. + * + * @param int $userid the id of the user being graded. + * @param float $grade the grade value, out of 100, to set for the user. + * @param \stdClass $resource the published resource object. + * @return float the fractional grade value expected to be used during sync. + */ + protected function set_user_grade_for_resource(int $userid, float $grade, \stdClass $resource): float { + + global $CFG; + require_once($CFG->libdir . '/accesslib.php'); + require_once($CFG->libdir . '/gradelib.php'); + $context = \context::instance_by_id($resource->contextid); + + if ($context->contextlevel == CONTEXT_COURSE) { + $gi = \grade_item::fetch_course_item($resource->courseid); + } else if ($context->contextlevel == CONTEXT_MODULE) { + $cm = get_coursemodule_from_id('assign', $context->instanceid); + + $gi = \grade_item::fetch([ + 'itemtype' => 'mod', + 'itemmodule' => 'assign', + 'iteminstance' => $cm->instance, + 'courseid' => $resource->courseid + ]); + } + + if ($ggrade = \grade_grade::fetch(['itemid' => $gi->id, 'userid' => $userid])) { + $ggrade->finalgrade = $grade; + $ggrade->rawgrade = $grade; + $ggrade->update(); + } else { + $ggrade = new \grade_grade(); + $ggrade->itemid = $gi->id; + $ggrade->userid = $userid; + $ggrade->rawgrade = $grade; + $ggrade->finalgrade = $grade; + $ggrade->rawgrademax = 100; + $ggrade->rawgrademin = 0; + $ggrade->timecreated = time(); + $ggrade->timemodified = time(); + $ggrade->insert(); + } + return floatval($ggrade->finalgrade / $gi->grademax); + } + + /** + * Helper to set the completion status for published course or module. + * + * @param \stdClass $resource the resource - either a course or module. + * @param int $userid the id of the user to override the completion status for. + * @param bool $complete whether the resource is deemed complete or not. + */ + protected function override_resource_completion_status_for_user(\stdClass $resource, int $userid, + bool $complete): void { + + global $CFG; + require_once($CFG->libdir . '/accesslib.php'); + require_once($CFG->libdir . '/completionlib.php'); + require_once($CFG->libdir . '/datalib.php'); + $this->setAdminUser(); + $context = \context::instance_by_id($resource->contextid); + $completion = new \completion_info(get_course($resource->courseid)); + if ($context->contextlevel == CONTEXT_COURSE) { + $ccompletion = new \completion_completion(['userid' => $userid, 'course' => $resource->courseid]); + if ($complete) { + $ccompletion->mark_complete(); + } else { + $completion->clear_criteria(); + } + } else if ($context->contextlevel == CONTEXT_MODULE) { + $completion->update_state( + get_coursemodule_from_id('assign', $context->instanceid), + $complete ? COMPLETION_COMPLETE : COMPLETION_INCOMPLETE, + $userid, + true + ); + } + } + + /** + * Test confirming task name. + * + * @covers ::get_name + */ + public function test_get_name() { + $this->assertEquals(get_string('tasksyncgrades', 'enrol_lti'), (new sync_grades())->get_name()); + } + + /** + * Test the sync grades task during several runs and for a series of grade changes. + * + * @covers ::execute + */ + public function test_grade_sync_chronological_syncs() { + $this->resetAfterTest(); + + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + $task = $this->get_task_with_mocked_grade_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + + $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); + $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1]); + $student1user = $this->getDataGenerator()->create_user(); + $student2user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); + [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); + + // Grade student1 only. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // Sync and verify that only student1's grade is sent. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 1 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Sync again, verifying no grades are sent because nothing has changed. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Not sent - The grade for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id' was not sent as the grades are the same.", + "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Change student1's grade and add a grade for student2. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 68.5, $resource); + $expectedstudent2grade = $this->set_user_grade_for_resource($student2id, 44.5, $resource); + + // Sync again, verifying both grade changes are sent. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Success - The grade '$expectedstudent2grade' for the user '$student2id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 2 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + } + + /** + * Test a grade sync when there are more than one resource link for the resource. + * + * @covers ::execute + */ + public function test_grade_sync_multiple_resource_links() { + $this->resetAfterTest(); + + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + $task = $this->get_task_with_mocked_grade_service(); + + // Launch the resource first for an instructor using the default resource link in the platform. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid, $resource] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch again as the instructor, this time from a different resource link in the platform. + $teachermocklaunch2 = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], 'RLID-2'); + $launchservice->user_launches_tool($instructoruser, $teachermocklaunch2); + + // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + + $student1reslink1launch = $this->get_mock_launch($resource, $studentusers[0]); + $student2reslink1launch = $this->get_mock_launch($resource, $studentusers[1]); + $student1reslink2launch = $this->get_mock_launch($resource, $studentusers[1], 'RLID-2'); + $student1user = $this->getDataGenerator()->create_user(); + $student2user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1reslink1launch); + [$student2id] = $launchservice->user_launches_tool($student2user, $student2reslink1launch); + $launchservice->user_launches_tool($student1user, $student1reslink2launch); + + // Grade student1 only. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // Sync and verify that only student1's grade is sent but that it's sent for BOTH resource links. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Skipping - Invalid grade for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Found 2 resource link(s) for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id'. Attempting to sync grades for all.", + "Skipping - Invalid grade for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 1 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Verify that the grade was reported as being synced twice - once for each resource link. + $expected = "/Found 2 resource link\(s\) for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id'. Attempting to sync grades for all.\n". + "Processing resource link '.*'.\n". + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.\n". + "Processing resource link '.*'.\n". + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent./"; + $this->assertMatchesRegularExpression($expected, $ob); + } + + /** + * Test grade sync when the resource has syncgrades disabled. + * + * @covers ::execute + */ + public function test_sync_grades_gradesync_disabled() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(true, true, true, helper::MEMBER_SYNC_ENROL_AND_UNENROL, + false); + $launchservice = $this->get_tool_launch_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + $task = $this->get_task_with_mocked_grade_service(); + $this->expectOutputRegex('/Skipping task - There are no resources with grade sync enabled./'); + $task->execute(); + } + + /** + * Test the grade sync task when the auth_lti plugin is disabled. + * + * @covers ::execute + */ + public function test_sync_grades_auth_plugin_disabled() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(false); + $launchservice = $this->get_tool_launch_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + $task = $this->get_task_with_mocked_grade_service(); + $this->expectOutputRegex('/Skipping task - ' . + get_string('pluginnotenabled', 'auth', get_string('pluginname', 'auth_lti')) . '/'); + $task->execute(); + } + + /** + * Test the grade sync task when the enrol_lti plugin is disabled. + * + * @covers ::execute + */ + public function test_sync_grades_enrol_plugin_disabled() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(true, false); + $launchservice = $this->get_tool_launch_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + $task = $this->get_task_with_mocked_grade_service(); + $this->expectOutputRegex('/Skipping task - ' . get_string('enrolisdisabled', 'enrol_lti') . '/'); + $task->execute(); + } + + /** + * Test the grade sync task when the launch data doesn't include the AGS support. + * + * @covers ::execute + */ + public function test_sync_grades_no_service_endpoint() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0], + null, false); + $instructoruser = $this->getDataGenerator()->create_user(); + [$userid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + $task = $this->get_task_with_mocked_grade_service(); + $this->expectOutputRegex( + "/Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.\n". + "Found 1 resource link\(s\) for the user '$userid', for the resource '$resource->id' and the ". + "course '$course->id'. Attempting to sync grades for all.\n". + "Processing resource link '.*'.\n". + "Skipping - No grade service found for the user '$userid', for the resource '$resource->id' and the ". + "course '$course->id'.\n". + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. Processed 1 users; ". + "sent 0 grades./" + ); + $task->execute(); + } + + /** + * Test the grade sync when the context has been deleted in between launch and when the grade sync task is run. + * + * @covers ::execute + */ + public function test_sync_grades_deleted_context() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$userid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + global $CFG; + require_once($CFG->dirroot . '/course/lib.php'); + $modcontext = \context::instance_by_id($resource->contextid); + course_delete_module($modcontext->instanceid); + + $task = $this->get_task_with_mocked_grade_service(); + $this->expectOutputRegex( + "/Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.\n". + "Found 1 resource link\(s\) for the user '$userid', for the resource '$resource->id' and the ". + "course '$course->id'. Attempting to sync grades for all.\n". + "Processing resource link '.*'.\n". + "Failed - Invalid contextid '$resource->contextid' for the resource '$resource->id'.\n". + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. Processed 1 users; ". + "sent 0 grades./" + ); + $task->execute(); + } + + /** + * Test grade sync when completion is required for the activity before sync takes place. + * + * @covers ::execute + */ + public function test_sync_grades_completion_required() { + $this->resetAfterTest(); + global $CFG; + require_once($CFG->libdir . '/completionlib.php'); + + [ + $course, + $resource, + $resource2, + $publishedcourse + ] = $this->create_test_environment(true, true, false, helper::MEMBER_SYNC_ENROL_AND_UNENROL, true, true); + $launchservice = $this->get_tool_launch_service(); + $task = $this->get_task_with_mocked_grade_service(); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch the resource for a few more users, creating those enrolments and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); + $student2mocklaunch = $this->get_mock_launch($resource, $studentusers[1]); + $student1user = $this->getDataGenerator()->create_user(); + $student2user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); + [$student2id] = $launchservice->user_launches_tool($student2user, $student2mocklaunch); + + // Launch the published course as student2. + $student2mockcourselaunch = $this->get_mock_launch($publishedcourse, $studentusers[1], '23456'); + $launchservice->user_launches_tool($student2user, $student2mockcourselaunch); + + // Grade student1 in the assign resource. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // And student2 in the course resource. + $expectedstudent2grade = $this->set_user_grade_for_resource($student2id, 55.5, $publishedcourse); + + // Sync and verify that no grades are sent because resource and published course are both not yet complete. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". + "the course '$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Complete the resource for student1. + $this->override_resource_completion_status_for_user($resource, $student1id, true); + + // Run the sync again, this time confirming the grade for student1 is sent. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Success - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' was sent.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 1 grades.", + "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", + "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". + "the course '$course->id'.", + "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". + "Processed 1 users; sent 0 grades.", + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Fail completion for student1 and confirm no grade is sent, even despite it being changed. + $this->set_user_grade_for_resource($student1id, 33.3, $resource); + $this->override_resource_completion_status_for_user($resource, $student1id, false); + + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades.", + "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", + "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". + "the course '$course->id'.", + "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". + "Processed 1 users; sent 0 grades.", + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Complete the course for student2 and verify the grade is now sent. + $this->override_resource_completion_status_for_user($publishedcourse, $student2id, true); + + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades.", + "Starting - LTI Advantage grade sync for shared resource '$publishedcourse->id' in course '$course->id'.", + "Success - The grade '$expectedstudent2grade' for the user '$student2id', for the resource ". + "'$publishedcourse->id' and the course '$course->id' was sent.", + "Completed - Synced grades for tool '$publishedcourse->id' in the course '$course->id'. ". + "Processed 1 users; sent 1 grades.", + + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Mark the course as in progress again for student2 and verify any new grade changes are not sent. + $this->set_user_grade_for_resource($student2id, 78.8, $publishedcourse); + $this->override_resource_completion_status_for_user($publishedcourse, $student2id, false); + + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Activity not completed for the user '$teacherid', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student1id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Activity not completed for the user '$student2id', for the resource '$resource->id' and the course ". + "'$course->id'.", + "Skipping - Course not completed for the user '$student2id', for the resource '$publishedcourse->id' and ". + "the course '$course->id'.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 3 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + } + + /** + * Test grade sync when the attempt to call the service returns an exception or a bad HTTP response code. + * + * @covers ::execute + */ + public function test_sync_grades_failed_service_call() { + $this->resetAfterTest(); + [$course, $resource] = $this->create_test_environment(); + $launchservice = $this->get_tool_launch_service(); + $task = $this->get_task_with_mocked_grade_service('200', true); + + // Launch the resource for an instructor which will create the domain objects needed for service calls. + $teachermocklaunch = $this->get_mock_launch($resource, $this->get_mock_launch_users_with_ids(['1'], false)[0]); + $instructoruser = $this->getDataGenerator()->create_user(); + [$teacherid] = $launchservice->user_launches_tool($instructoruser, $teachermocklaunch); + + // Launch the resource for a student, creating the enrolment and allowing grading to take place. + $studentusers = $this->get_mock_launch_users_with_ids(['2', '3'], false, + 'http://purl.imsglobal.org/vocab/lis/v2/membership#Learner'); + $student1mocklaunch = $this->get_mock_launch($resource, $studentusers[0]); + $student1user = $this->getDataGenerator()->create_user(); + [$student1id] = $launchservice->user_launches_tool($student1user, $student1mocklaunch); + + // Grade student1 in the assign resource. + $expectedstudent1grade = $this->set_user_grade_for_resource($student1id, 65, $resource); + + // Run the sync, verifying that the response error causes a 'Failed' trace but that the task completes. + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Failed - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' failed to send.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 2 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + + // Now run the sync again, this time with a bad http response code. + $task = $this->get_task_with_mocked_grade_service('400'); + ob_start(); + $task->execute(); + $ob = ob_get_contents(); + ob_end_clean(); + $expectedtraces = [ + "Starting - LTI Advantage grade sync for shared resource '$resource->id' in course '$course->id'.", + "Failed - The grade '$expectedstudent1grade' for the user '$student1id', for the resource ". + "'$resource->id' and the course '$course->id' failed to send.", + "Completed - Synced grades for tool '$resource->id' in the course '$course->id'. ". + "Processed 2 users; sent 0 grades." + ]; + foreach ($expectedtraces as $expectedtrace) { + $this->assertStringContainsString($expectedtrace, $ob); + } + } +} diff --git a/enrol/lti/version.php b/enrol/lti/version.php index fd2c9750473..f8f7821d507 100644 --- a/enrol/lti/version.php +++ b/enrol/lti/version.php @@ -24,7 +24,7 @@ defined('MOODLE_INTERNAL') || die(); -$plugin->version = 2021052515; // The current plugin version (Date: YYYYMMDDXX). +$plugin->version = 2021052516; // The current plugin version (Date: YYYYMMDDXX). $plugin->requires = 2021052500; // Requires this Moodle version. $plugin->component = 'enrol_lti'; // Full name of the plugin (used for diagnostics). $plugin->dependencies = [