From: Andrew Nicols Date: Fri, 4 Oct 2019 05:30:20 +0000 (+0800) Subject: MDL-66809 core_grades: Implement scale-based marking X-Git-Tag: v3.8.0-beta~106^2~26 X-Git-Url: http://git.moodle.org/gw?p=moodle.git;a=commitdiff_plain;h=b253a4f21d7fa82d708c6bc1be765f02dcd91d1c MDL-66809 core_grades: Implement scale-based marking Part of MDL-66074 --- diff --git a/grade/amd/build/grades/grader/gradingpanel/scale.min.js b/grade/amd/build/grades/grader/gradingpanel/scale.min.js new file mode 100644 index 00000000000..df80d6a0490 Binary files /dev/null and b/grade/amd/build/grades/grader/gradingpanel/scale.min.js differ diff --git a/grade/amd/build/grades/grader/gradingpanel/scale.min.js.map b/grade/amd/build/grades/grader/gradingpanel/scale.min.js.map new file mode 100644 index 00000000000..0ef4ed91164 Binary files /dev/null and b/grade/amd/build/grades/grader/gradingpanel/scale.min.js.map differ diff --git a/grade/amd/src/grades/grader/gradingpanel/scale.js b/grade/amd/src/grades/grader/gradingpanel/scale.js new file mode 100644 index 00000000000..8413ad27ff9 --- /dev/null +++ b/grade/amd/src/grades/grader/gradingpanel/scale.js @@ -0,0 +1,34 @@ +// This file is part of Moodle - http://moodle.org/ +// +// Moodle is free software: you can redistribute it and/or modify +// it under the terms of the GNU General Public License as published by +// the Free Software Foundation, either version 3 of the License, or +// (at your option) any later version. +// +// Moodle is distributed in the hope that it will be useful, +// but WITHOUT ANY WARRANTY; without even the implied warranty of +// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +// GNU General Public License for more details. +// +// You should have received a copy of the GNU General Public License +// along with Moodle. If not, see . + +/** + * Grading panel for simple direct grading. + * + * @module core_grades/grades/grader/gradingpanel/scale + * @package core_grades + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +import {saveGrade, fetchGrade} from './repository'; +// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send() +import jQuery from 'jquery'; + +export const fetchCurrentGrade = (...args) => fetchGrade('scale')(...args); + +export const storeCurrentGrade = (component, context, itemname, userId, rootNode) => { + const form = rootNode.querySelector('form'); + return saveGrade('scale')(component, context, itemname, userId, jQuery(form).serialize()); +}; diff --git a/grade/classes/component_gradeitem.php b/grade/classes/component_gradeitem.php index c2d41bb95ae..eae68a63bc4 100644 --- a/grade/classes/component_gradeitem.php +++ b/grade/classes/component_gradeitem.php @@ -99,6 +99,15 @@ abstract class component_gradeitem { */ abstract protected function get_table_name(): string; + /** + * Get the itemid for the current gradeitem. + * + * @return int + */ + public function get_grade_itemid(): int { + return component_gradeitems::get_itemnumber_from_itemname($this->component, $this->itemname); + } + /** * Whether grading is enabled for this item. * @@ -138,6 +147,8 @@ abstract class component_gradeitem { * @return stdClass */ protected function get_scale(): ?stdClass { + global $DB; + $gradetype = $this->get_gradeitem_value(); if ($gradetype > 0) { return null; @@ -156,7 +167,7 @@ abstract class component_gradeitem { * * @return bool */ - protected function is_using_scale(): bool { + public function is_using_scale(): bool { $gradetype = $this->get_gradeitem_value(); return $gradetype < 0; @@ -289,11 +300,11 @@ abstract class component_gradeitem { } /** - * Get the advanced grading menu items. + * Get the list of available grade items. * * @return array */ - protected function get_advanced_grading_grade_menu(): array { + public function get_grade_menu(): array { return make_grades_menu($this->get_gradeitem_value()); } @@ -302,31 +313,37 @@ abstract class component_gradeitem { * * @param float $grade The value being checked * @throws moodle_exception - * @throws rating_exception * @return bool */ public function check_grade_validity(?float $grade): bool { - if ($this->is_using_scale()) { - // Fetch all options for this scale. - $scaleoptions = make_menu_from_list($this->get_scale()); - if (!array_key_exists($grade, $scaleoptions)) { - // The selected option did not exist. - throw new rating_exception('ratinginvalid', 'rating'); - } - } else if ($grade) { - $maxgrade = $this->get_gradeitem_value(); - if ($grade > $maxgrade) { - // The grade is greater than the maximum possible value. - throw new moodle_exception('error:notinrange', 'core_grading', '', (object) [ - 'maxgrade' => $maxgrade, - 'grade' => $grade, - ]); - } else if ($grade < 0) { - // Negative grades are not supported. - throw new moodle_exception('error:notinrange', 'core_grading', '', (object) [ - 'maxgrade' => $maxgrade, - 'grade' => $grade, - ]); + $grade = grade_floatval(unformat_float($grade)); + if ($grade) { + if ($this->is_using_scale()) { + // Fetch all options for this scale. + $scaleoptions = make_menu_from_list($this->get_scale()->scale); + + if ($grade != -1 && !array_key_exists((int) $grade, $scaleoptions)) { + // The selected option did not exist. + throw new moodle_exception('error:notinrange', 'core_grading', '', (object) [ + 'maxgrade' => count($scaleoptions), + 'grade' => $grade, + ]); + } + } else if ($grade) { + $maxgrade = $this->get_gradeitem_value(); + if ($grade > $maxgrade) { + // The grade is greater than the maximum possible value. + throw new moodle_exception('error:notinrange', 'core_grading', '', (object) [ + 'maxgrade' => $maxgrade, + 'grade' => $grade, + ]); + } else if ($grade < 0) { + // Negative grades are not supported. + throw new moodle_exception('error:notinrange', 'core_grading', '', (object) [ + 'maxgrade' => $maxgrade, + 'grade' => $grade, + ]); + } } } @@ -438,7 +455,7 @@ abstract class component_gradeitem { // Set the allowed grade range. $gradinginstance->get_controller()->set_grade_range( - $this->get_advanced_grading_grade_menu(), + $this->get_grade_menu(), $this->allow_decimals() ); diff --git a/grade/classes/grades/grader/gradingpanel/scale/external/fetch.php b/grade/classes/grades/grader/gradingpanel/scale/external/fetch.php new file mode 100644 index 00000000000..3e88e4ce08c --- /dev/null +++ b/grade/classes/grades/grader/gradingpanel/scale/external/fetch.php @@ -0,0 +1,186 @@ +. + +/** + * Web service functions relating to scale grades and grading. + * + * @package core_grades + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types = 1); + +namespace core_grades\grades\grader\gradingpanel\scale\external; + +use coding_exception; +use context; +use core_grades\component_gradeitem as gradeitem; +use core_grades\component_gradeitems; +use core_user; +use external_api; +use external_function_parameters; +use external_multiple_structure; +use external_single_structure; +use external_value; +use external_warnings; +use moodle_exception; +use required_capability_exception; +use stdClass; + +/** + * External grading panel scale API + * + * @package core_grades + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class fetch extends external_api { + + /** + * Describes the parameters for fetching the grading panel for a simple grade. + * + * @return external_function_parameters + * @since Moodle 3.8 + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters ([ + 'component' => new external_value( + PARAM_ALPHANUMEXT, + 'The name of the component', + VALUE_REQUIRED + ), + 'contextid' => new external_value( + PARAM_INT, + 'The ID of the context being graded', + VALUE_REQUIRED + ), + 'itemname' => new external_value( + PARAM_ALPHANUM, + 'The grade item itemname being graded', + VALUE_REQUIRED + ), + 'gradeduserid' => new external_value( + PARAM_INT, + 'The ID of the user show', + VALUE_REQUIRED + ), + ]); + } + + /** + * Fetch the data required to build a grading panel for a simple grade. + * + * @param string $component + * @param int $contextid + * @param string $itemname + * @param int $gradeduserid + * @return array + * @since Moodle 3.8 + */ + public static function execute(string $component, int $contextid, string $itemname, int $gradeduserid): array { + global $USER; + + [ + 'component' => $component, + 'contextid' => $contextid, + 'itemname' => $itemname, + 'gradeduserid' => $gradeduserid, + ] = self::validate_parameters(self::execute_parameters(), [ + 'component' => $component, + 'contextid' => $contextid, + 'itemname' => $itemname, + 'gradeduserid' => $gradeduserid, + ]); + + // Validate the context. + $context = context::instance_by_id($contextid); + self::validate_context($context); + + // Validate that the supplied itemname is a gradable item. + if (!component_gradeitems::is_valid_itemname($component, $itemname)) { + throw new coding_exception("The '{$itemname}' item is not valid for the '{$component}' component"); + } + + // Fetch the gradeitem instance. + $gradeitem = gradeitem::instance($component, $context, $itemname); + + if (!$gradeitem->is_using_scale()) { + throw new moodle_exception("The {$itemname} item in {$component}/{$contextid} is not configured for grading with scales"); + } + + $gradeduser = \core_user::get_user($gradeduserid); + + return self::get_fetch_data($gradeitem, $gradeduser); + } + + /** + * Get the data to be fetched. + * + * @param component_gradeitem $gradeitem + * @return array + */ + public static function get_fetch_data(gradeitem $gradeitem, stdClass $gradeduser): array { + global $USER; + + $grade = $gradeitem->get_grade_for_user($gradeduser, $USER); + $currentgrade = (int) unformat_float($grade->grade); + + $menu = $gradeitem->get_grade_menu(); + $values = array_map(function($description, $value) use ($currentgrade) { + return [ + 'value' => $value, + 'title' => $description, + 'selected' => ($value == $currentgrade), + ]; + }, $menu, array_keys($menu)); + + return [ + 'templatename' => 'core_grades/grades/grader/gradingpanel/scale', + 'grade' => [ + 'options' => $values, + 'timecreated' => $grade->timecreated, + 'timemodified' => $grade->timemodified, + ], + 'warnings' => [], + ]; + } + + /** + * Describes the data returned from the external function. + * + * @return external_single_structure + * @since Moodle 3.8 + */ + public static function execute_returns(): external_single_structure { + return new external_single_structure([ + 'templatename' => new external_value(PARAM_SAFEPATH, 'The template to use when rendering this data'), + 'grade' => new external_single_structure([ + 'options' => new external_multiple_structure( + new external_single_structure([ + 'value' => new external_value(PARAM_FLOAT, 'The grade value'), + 'title' => new external_value(PARAM_RAW, 'The description fo the option'), + 'selected' => new external_value(PARAM_BOOL, 'Whether this item is currently selected'), + ]), + 'The description of the grade option' + ), + 'timecreated' => new external_value(PARAM_INT, 'The time that the grade was created'), + 'timemodified' => new external_value(PARAM_INT, 'The time that the grade was last updated'), + ]), + 'warnings' => new external_warnings(), + ]); + } +} diff --git a/grade/classes/grades/grader/gradingpanel/scale/external/store.php b/grade/classes/grades/grader/gradingpanel/scale/external/store.php new file mode 100644 index 00000000000..2a5fd7be453 --- /dev/null +++ b/grade/classes/grades/grader/gradingpanel/scale/external/store.php @@ -0,0 +1,161 @@ +. + +/** + * Web service functions relating to scale grades and grading. + * + * @package core_grades + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ + +declare(strict_types = 1); + +namespace core_grades\grades\grader\gradingpanel\scale\external; + +use coding_exception; +use context; +use core_user; +use core_grades\component_gradeitem as gradeitem; +use core_grades\component_gradeitems; +use external_api; +use external_function_parameters; +use external_multiple_structure; +use external_single_structure; +use external_value; +use external_warnings; +use moodle_exception; +use required_capability_exception; + +/** + * External grading panel scale API + * + * @package core_grades + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class store extends external_api { + + /** + * Describes the parameters for fetching the grading panel for a simple grade. + * + * @return external_function_parameters + * @since Moodle 3.8 + */ + public static function execute_parameters(): external_function_parameters { + return new external_function_parameters ([ + 'component' => new external_value( + PARAM_ALPHANUMEXT, + 'The name of the component', + VALUE_REQUIRED + ), + 'contextid' => new external_value( + PARAM_INT, + 'The ID of the context being graded', + VALUE_REQUIRED + ), + 'itemname' => new external_value( + PARAM_ALPHANUM, + 'The grade item itemname being graded', + VALUE_REQUIRED + ), + 'gradeduserid' => new external_value( + PARAM_INT, + 'The ID of the user show', + VALUE_REQUIRED + ), + 'formdata' => new external_value( + PARAM_RAW, + 'The serialised form data representing the grade', + VALUE_REQUIRED + ), + ]); + } + + /** + * Fetch the data required to build a grading panel for a simple grade. + * + * @param string $component + * @param int $contextid + * @param string $itemname + * @param int $gradeduserid + * @return array + * @since Moodle 3.8 + */ + public static function execute(string $component, int $contextid, string $itemname, int $gradeduserid, string $formdata): array { + global $USER; + + [ + 'component' => $component, + 'contextid' => $contextid, + 'itemname' => $itemname, + 'gradeduserid' => $gradeduserid, + 'formdata' => $formdata, + ] = self::validate_parameters(self::execute_parameters(), [ + 'component' => $component, + 'contextid' => $contextid, + 'itemname' => $itemname, + 'gradeduserid' => $gradeduserid, + 'formdata' => $formdata, + ]); + + // Validate the context. + $context = context::instance_by_id($contextid); + self::validate_context($context); + + // Validate that the supplied itemname is a gradable item. + if (!component_gradeitems::is_valid_itemname($component, $itemname)) { + throw new coding_exception("The '{$itemname}' item is not valid for the '{$component}' component"); + } + + // Fetch the gradeitem instance. + $gradeitem = gradeitem::instance($component, $context, $itemname); + + // Validate that this gradeitem is actually enabled. + if (!$gradeitem->is_grading_enabled()) { + throw new moodle_exception("Grading is not enabled for {$itemname} in this context"); + } + + // Fetch the record for the graded user. + $gradeduser = \core_user::get_user($gradeduserid); + + // Require that this user can save grades. + $gradeitem->require_user_can_grade($gradeduser, $USER); + + if (!$gradeitem->is_using_scale()) { + throw new moodle_exception("The {$itemname} item in {$component}/{$contextid} is not configured for grading with scales"); + } + + // Parse the serialised string into an object. + $data = []; + parse_str($formdata, $data); + + // Grade. + $gradeitem->store_grade_from_formdata($gradeduser, $USER, (object) $data); + + return fetch::get_fetch_data($gradeitem, $gradeduser); + } + + /** + * Describes the data returned from the external function. + * + * @return external_single_structure + * @since Moodle 3.8 + */ + public static function execute_returns(): external_single_structure { + return fetch::execute_returns(); + } +} diff --git a/grade/templates/grades/grader/gradingpanel/scale.mustache b/grade/templates/grades/grader/gradingpanel/scale.mustache new file mode 100644 index 00000000000..6c6ef78da92 --- /dev/null +++ b/grade/templates/grades/grader/gradingpanel/scale.mustache @@ -0,0 +1,40 @@ +{{! + This file is part of Moodle - http://moodle.org/ + + Moodle is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + Moodle is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with Moodle. If not, see . +}} +{{! + @template core_grades/grades/grader/gradingpanel/point + + Point-based grading template for use in the grading panel. + + Context variables required for this template: + + Example context (json): + { + "grade": 47 + } +}} +
+
+ + + {{#str}}grade_help, core_grades{{/str}} +
+
diff --git a/grade/tests/grades_grader_gradingpanel_scale_external_fetch_test.php b/grade/tests/grades_grader_gradingpanel_scale_external_fetch_test.php new file mode 100644 index 00000000000..4b64859dc98 --- /dev/null +++ b/grade/tests/grades_grader_gradingpanel_scale_external_fetch_test.php @@ -0,0 +1,255 @@ +. + +/** + * Unit tests for core_grades\component_gradeitems; + * + * @package core_grades + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types = 1); + +namespace core_grades\grades\grader\gradingpanel\scale\external; + +use advanced_testcase; +use coding_exception; +use core_grades\component_gradeitem; +use external_api; +use mod_forum\local\entities\forum as forum_entity; +use moodle_exception; + +/** + * Unit tests for core_grades\component_gradeitems; + * + * @package core_grades + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class fetch_test extends advanced_testcase { + + public static function setupBeforeClass(): void { + global $CFG; + require_once("{$CFG->libdir}/externallib.php"); + } + + /** + * Ensure that an execute with an invalid component is rejected. + */ + public function test_execute_invalid_component(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage("The 'foo' item is not valid for the 'mod_invalid' component"); + fetch::execute('mod_invalid', 1, 'foo', 2); + } + + /** + * Ensure that an execute with an invalid itemname on a valid component is rejected. + */ + public function test_execute_invalid_itemname(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage("The 'foo' item is not valid for the 'mod_forum' component"); + fetch::execute('mod_forum', 1, 'foo', 2); + } + + /** + * Ensure that an execute against a different grading method is rejected. + */ + public function test_execute_incorrect_type(): void { + $this->resetAfterTest(); + + $forum = $this->get_forum_instance([ + // Negative numbers mean a scale. + 'grade_forum' => 5, + ]); + $course = $forum->get_course_record(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher'); + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($teacher); + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage("not configured for grading with scales"); + fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id); + } + + /** + * Ensure that an execute against the correct grading method returns the current state of the user. + */ + public function test_execute_fetch_empty(): void { + $this->resetAfterTest(); + + $options = [ + 'A', + 'B', + 'C' + ]; + $scale = $this->getDataGenerator()->create_scale(['scale' => implode(',', $options)]); + + $forum = $this->get_forum_instance([ + // Negative numbers mean a scale. + 'grade_forum' => -1 * $scale->id + ]); + $course = $forum->get_course_record(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher'); + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($teacher); + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + + $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id); + $result = external_api::clean_returnvalue(fetch::execute_returns(), $result); + + $this->assertIsArray($result); + $this->assertArrayHasKey('templatename', $result); + + $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']); + + $this->assertArrayHasKey('grade', $result); + $this->assertIsArray($result['grade']); + + $this->assertArrayHasKey('options', $result['grade']); + $this->assertCount(count($options), $result['grade']['options']); + rsort($options); + foreach ($options as $index => $option) { + $this->assertArrayHasKey($index, $result['grade']['options']); + + $returnedoption = $result['grade']['options'][$index]; + $this->assertArrayHasKey('value', $returnedoption); + $this->assertEquals(3 - $index, $returnedoption['value']); + + $this->assertArrayHasKey('title', $returnedoption); + $this->assertEquals($option, $returnedoption['title']); + + $this->assertArrayHasKey('selected', $returnedoption); + $this->assertFalse($returnedoption['selected']); + } + + $this->assertIsInt($result['grade']['timecreated']); + $this->assertArrayHasKey('timemodified', $result['grade']); + $this->assertIsInt($result['grade']['timemodified']); + + $this->assertArrayHasKey('warnings', $result); + $this->assertIsArray($result['warnings']); + $this->assertEmpty($result['warnings']); + } + + /** + * Ensure that an execute against the correct grading method returns the current state of the user. + */ + public function test_execute_fetch_graded(): void { + $this->resetAfterTest(); + + $options = [ + 'A', + 'B', + 'C' + ]; + $scale = $this->getDataGenerator()->create_scale(['scale' => implode(',', $options)]); + + $forum = $this->get_forum_instance([ + // Negative numbers mean a scale. + 'grade_forum' => -1 * $scale->id + ]); + $course = $forum->get_course_record(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher'); + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($teacher); + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + $gradeitem->store_grade_from_formdata($student, $teacher, (object) [ + 'grade' => 2, + ]); + + $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id); + $result = external_api::clean_returnvalue(fetch::execute_returns(), $result); + + $this->assertIsArray($result); + $this->assertArrayHasKey('templatename', $result); + + $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']); + + $result = fetch::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id); + $result = external_api::clean_returnvalue(fetch::execute_returns(), $result); + + $this->assertIsArray($result); + $this->assertArrayHasKey('templatename', $result); + + $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']); + + $this->assertArrayHasKey('grade', $result); + $this->assertIsArray($result['grade']); + + $this->assertArrayHasKey('options', $result['grade']); + $this->assertCount(count($options), $result['grade']['options']); + rsort($options); + foreach ($options as $index => $option) { + $this->assertArrayHasKey($index, $result['grade']['options']); + + $returnedoption = $result['grade']['options'][$index]; + $this->assertArrayHasKey('value', $returnedoption); + $this->assertEquals(3 - $index, $returnedoption['value']); + + $this->assertArrayHasKey('title', $returnedoption); + $this->assertEquals($option, $returnedoption['title']); + + $this->assertArrayHasKey('selected', $returnedoption); + } + + // The grade was 2, which relates to the middle option. + $this->assertFalse($result['grade']['options'][0]['selected']); + $this->assertTrue($result['grade']['options'][1]['selected']); + $this->assertFalse($result['grade']['options'][2]['selected']); + + $this->assertIsInt($result['grade']['timecreated']); + $this->assertArrayHasKey('timemodified', $result['grade']); + $this->assertIsInt($result['grade']['timemodified']); + + $this->assertArrayHasKey('warnings', $result); + $this->assertIsArray($result['warnings']); + $this->assertEmpty($result['warnings']); + } + + /** + * Get a forum instance. + * + * @param array $config + * @return forum_entity + */ + protected function get_forum_instance(array $config = []): forum_entity { + $this->resetAfterTest(); + + $datagenerator = $this->getDataGenerator(); + $course = $datagenerator->create_course(); + $forum = $datagenerator->create_module('forum', array_merge($config, ['course' => $course->id])); + + $vaultfactory = \mod_forum\local\container::get_vault_factory(); + $vault = $vaultfactory->get_forum_vault(); + + return $vault->get_from_id((int) $forum->id); + } +} diff --git a/grade/tests/grades_grader_gradingpanel_scale_external_store_test.php b/grade/tests/grades_grader_gradingpanel_scale_external_store_test.php new file mode 100644 index 00000000000..dbf7ab59476 --- /dev/null +++ b/grade/tests/grades_grader_gradingpanel_scale_external_store_test.php @@ -0,0 +1,443 @@ +. + +/** + * Unit tests for core_grades\component_gradeitems; + * + * @package core_grades + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU Public License + */ + +declare(strict_types = 1); + +namespace core_grades\grades\grader\gradingpanel\scale\external; + +use advanced_testcase; +use coding_exception; +use core_grades\component_gradeitem; +use external_api; +use mod_forum\local\entities\forum as forum_entity; +use moodle_exception; +use grade_grade; +use grade_item; + +/** + * Unit tests for core_grades\component_gradeitems; + * + * @package core_grades + * @category test + * @copyright 2019 Andrew Nicols + * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later + */ +class store_test extends advanced_testcase { + + public static function setupBeforeClass(): void { + global $CFG; + require_once("{$CFG->libdir}/externallib.php"); + } + + /** + * Ensure that an execute with an invalid component is rejected. + */ + public function test_execute_invalid_component(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage("The 'foo' item is not valid for the 'mod_invalid' component"); + store::execute('mod_invalid', 1, 'foo', 2, 'formdata'); + } + + /** + * Ensure that an execute with an invalid itemname on a valid component is rejected. + */ + public function test_execute_invalid_itemname(): void { + $this->resetAfterTest(); + $user = $this->getDataGenerator()->create_user(); + $this->setUser($user); + + $this->expectException(coding_exception::class); + $this->expectExceptionMessage("The 'foo' item is not valid for the 'mod_forum' component"); + store::execute('mod_forum', 1, 'foo', 2, 'formdata'); + } + + /** + * Ensure that an execute against a different grading method is rejected. + */ + public function test_execute_incorrect_type(): void { + $this->resetAfterTest(); + + $forum = $this->get_forum_instance([ + // Negative numbers mean a scale. + 'grade_forum' => 5, + ]); + $course = $forum->get_course_record(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher'); + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($teacher); + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage("not configured for grading with scales"); + store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, 'formdata'); + } + + /** + * Ensure that an execute against a different grading method is rejected. + */ + public function test_execute_disabled(): void { + $this->resetAfterTest(); + + $forum = $this->get_forum_instance(); + $course = $forum->get_course_record(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher'); + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + $this->setUser($teacher); + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage("Grading is not enabled"); + store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, 'formdata'); + } + + /** + * Ensure that an execute against the correct grading method returns the current state of the user. + */ + public function test_execute_store_empty(): void { + [ + 'forum' => $forum, + 'options' => $options, + 'student' => $student, + 'teacher' => $teacher, + ] = $this->get_test_data(); + + $this->setUser($teacher); + + $formdata = [ + 'grade' => null, + ]; + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + + $result = store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, http_build_query($formdata)); + $result = external_api::clean_returnvalue(store::execute_returns(), $result); + + // The result should still be empty. + $this->assertIsArray($result); + $this->assertArrayHasKey('templatename', $result); + + $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']); + + $this->assertArrayHasKey('grade', $result); + $this->assertIsArray($result['grade']); + $this->assertArrayHasKey('options', $result['grade']); + $this->assertCount(count($options), $result['grade']['options']); + rsort($options); + foreach ($options as $index => $option) { + $this->assertArrayHasKey($index, $result['grade']['options']); + + $returnedoption = $result['grade']['options'][$index]; + $this->assertArrayHasKey('value', $returnedoption); + $this->assertEquals(3 - $index, $returnedoption['value']); + + $this->assertArrayHasKey('title', $returnedoption); + $this->assertEquals($option, $returnedoption['title']); + + $this->assertArrayHasKey('selected', $returnedoption); + $this->assertFalse($returnedoption['selected']); + } + + $this->assertIsInt($result['grade']['timecreated']); + $this->assertArrayHasKey('timemodified', $result['grade']); + $this->assertIsInt($result['grade']['timemodified']); + + $this->assertArrayHasKey('warnings', $result); + $this->assertIsArray($result['warnings']); + $this->assertEmpty($result['warnings']); + + // Compare against the grade stored in the database. + $storedgradeitem = grade_item::fetch([ + 'courseid' => $forum->get_course_id(), + 'itemtype' => 'mod', + 'itemmodule' => 'forum', + 'iteminstance' => $forum->get_id(), + 'itemnumber' => $gradeitem->get_grade_itemid(), + ]); + $storedgrade = grade_grade::fetch([ + 'userid' => $student->id, + 'itemid' => $storedgradeitem->id, + ]); + + $this->assertEmpty($storedgrade->rawgrade); + } + + /** + * Ensure that an execute against the correct grading method returns the current state of the user. + */ + public function test_execute_store_not_selected(): void { + [ + 'forum' => $forum, + 'options' => $options, + 'student' => $student, + 'teacher' => $teacher, + ] = $this->get_test_data(); + + $this->setUser($teacher); + + $formdata = [ + 'grade' => -1, + ]; + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + + $result = store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, http_build_query($formdata)); + $result = external_api::clean_returnvalue(store::execute_returns(), $result); + + // The result should still be empty. + $this->assertIsArray($result); + $this->assertArrayHasKey('templatename', $result); + + $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']); + + $this->assertArrayHasKey('grade', $result); + $this->assertIsArray($result['grade']); + $this->assertArrayHasKey('options', $result['grade']); + $this->assertCount(count($options), $result['grade']['options']); + rsort($options); + foreach ($options as $index => $option) { + $this->assertArrayHasKey($index, $result['grade']['options']); + + $returnedoption = $result['grade']['options'][$index]; + $this->assertArrayHasKey('value', $returnedoption); + $this->assertEquals(3 - $index, $returnedoption['value']); + + $this->assertArrayHasKey('title', $returnedoption); + $this->assertEquals($option, $returnedoption['title']); + + $this->assertArrayHasKey('selected', $returnedoption); + $this->assertFalse($returnedoption['selected']); + } + + $this->assertIsInt($result['grade']['timecreated']); + $this->assertArrayHasKey('timemodified', $result['grade']); + $this->assertIsInt($result['grade']['timemodified']); + + $this->assertArrayHasKey('warnings', $result); + $this->assertIsArray($result['warnings']); + $this->assertEmpty($result['warnings']); + + // Compare against the grade stored in the database. + $storedgradeitem = grade_item::fetch([ + 'courseid' => $forum->get_course_id(), + 'itemtype' => 'mod', + 'itemmodule' => 'forum', + 'iteminstance' => $forum->get_id(), + 'itemnumber' => $gradeitem->get_grade_itemid(), + ]); + $storedgrade = grade_grade::fetch([ + 'userid' => $student->id, + 'itemid' => $storedgradeitem->id, + ]); + + // No grade will have been saved. + $this->assertFalse($storedgrade); + } + + /** + * Ensure that an execute against the correct grading method returns the current state of the user. + */ + public function test_execute_store_graded(): void { + [ + 'scale' => $scale, + 'forum' => $forum, + 'options' => $options, + 'student' => $student, + 'teacher' => $teacher, + ] = $this->get_test_data(); + + $this->setUser($teacher); + + $formdata = [ + 'grade' => 2, + ]; + $formattedvalue = grade_floatval(unformat_float($formdata['grade'])); + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + + $result = store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, http_build_query($formdata)); + $result = external_api::clean_returnvalue(store::execute_returns(), $result); + + // The result should still be empty. + $this->assertIsArray($result); + $this->assertArrayHasKey('templatename', $result); + + $this->assertEquals('core_grades/grades/grader/gradingpanel/scale', $result['templatename']); + + $this->assertArrayHasKey('grade', $result); + $this->assertIsArray($result['grade']); + $this->assertArrayHasKey('options', $result['grade']); + $this->assertCount(count($options), $result['grade']['options']); + rsort($options); + foreach ($options as $index => $option) { + $this->assertArrayHasKey($index, $result['grade']['options']); + + $returnedoption = $result['grade']['options'][$index]; + $this->assertArrayHasKey('value', $returnedoption); + $this->assertEquals(3 - $index, $returnedoption['value']); + + $this->assertArrayHasKey('title', $returnedoption); + $this->assertEquals($option, $returnedoption['title']); + + $this->assertArrayHasKey('selected', $returnedoption); + } + + // The grade was 2, which relates to the middle option. + $this->assertFalse($result['grade']['options'][0]['selected']); + $this->assertTrue($result['grade']['options'][1]['selected']); + $this->assertFalse($result['grade']['options'][2]['selected']); + + $this->assertIsInt($result['grade']['timecreated']); + $this->assertArrayHasKey('timemodified', $result['grade']); + $this->assertIsInt($result['grade']['timemodified']); + + $this->assertArrayHasKey('warnings', $result); + $this->assertIsArray($result['warnings']); + $this->assertEmpty($result['warnings']); + + // Compare against the grade stored in the database. + $storedgradeitem = grade_item::fetch([ + 'courseid' => $forum->get_course_id(), + 'itemtype' => 'mod', + 'itemmodule' => 'forum', + 'iteminstance' => $forum->get_id(), + 'itemnumber' => $gradeitem->get_grade_itemid(), + ]); + $storedgrade = grade_grade::fetch([ + 'userid' => $student->id, + 'itemid' => $storedgradeitem->id, + ]); + + $this->assertEquals($formattedvalue, $storedgrade->rawgrade); + $this->assertEquals($scale->id, $storedgrade->rawscaleid); + } + + /** + * Ensure that an out-of-range value is rejected. + * + * @dataProvider execute_out_of_range_provider + * @param int $suppliedvalue The value that was submitted + */ + public function test_execute_store_out_of_range(int $suppliedvalue): void { + [ + 'scale' => $scale, + 'forum' => $forum, + 'options' => $options, + 'student' => $student, + 'teacher' => $teacher, + ] = $this->get_test_data(); + + $this->setUser($teacher); + + $formdata = [ + 'grade' => $suppliedvalue, + ]; + + $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum'); + + $this->expectException(moodle_exception::class); + $this->expectExceptionMessage("Invalid grade '{$suppliedvalue}' provided. Grades must be between 0 and 3."); + store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, http_build_query($formdata)); + } + + /** + * Data provider for out of range tests. + * + * @return array + */ + public function execute_out_of_range_provider(): array { + return [ + 'above' => [ + 'supplied' => 500, + ], + 'above just' => [ + 'supplied' => 4, + ], + 'below' => [ + 'supplied' => -100, + ], + '-10' => [ + 'supplied' => -10, + ], + ]; + } + + + /** + * Get a forum instance. + * + * @param array $config + * @return forum_entity + */ + protected function get_forum_instance(array $config = []): forum_entity { + $this->resetAfterTest(); + + $datagenerator = $this->getDataGenerator(); + $course = $datagenerator->create_course(); + $forum = $datagenerator->create_module('forum', array_merge($config, ['course' => $course->id])); + + $vaultfactory = \mod_forum\local\container::get_vault_factory(); + $vault = $vaultfactory->get_forum_vault(); + + return $vault->get_from_id((int) $forum->id); + } + + /** + * Get test data for scaled forums. + * + * @return array + */ + protected function get_test_data(): array { + $this->resetAfterTest(); + + $options = [ + 'A', + 'B', + 'C' + ]; + $scale = $this->getDataGenerator()->create_scale(['scale' => implode(',', $options)]); + + $forum = $this->get_forum_instance([ + // Negative numbers mean a scale. + 'grade_forum' => -1 * $scale->id + ]); + $course = $forum->get_course_record(); + $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher'); + $student = $this->getDataGenerator()->create_and_enrol($course, 'student'); + + return [ + 'forum' => $forum, + 'scale' => $scale, + 'options' => $options, + 'student' => $student, + 'teacher' => $teacher, + ]; + } +} diff --git a/lib/db/services.php b/lib/db/services.php index a0a34c6354c..0076942d698 100644 --- a/lib/db/services.php +++ b/lib/db/services.php @@ -827,6 +827,23 @@ $functions = array( 'ajax' => true, 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], ], + 'core_grades_grader_gradingpanel_scale_fetch' => [ + 'classname' => 'core_grades\\grades\\grader\\gradingpanel\\scale\\external\\fetch', + 'methodname' => 'execute', + 'description' => 'Fetch the data required to display the grader grading panel for scale-based grading, ' . + 'creating the grade item if required', + 'type' => 'write', + 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], + 'core_grades_grader_gradingpanel_scale_store' => [ + 'classname' => 'core_grades\\grades\\grader\\gradingpanel\\scale\\external\\store', + 'methodname' => 'execute', + 'description' => 'Store the data required to display the grader grading panel for scale-based grading', + 'type' => 'write', + 'ajax' => true, + 'services' => [MOODLE_OFFICIAL_MOBILE_SERVICE], + ], 'core_grading_get_definitions' => array( 'classname' => 'core_grading_external', 'methodname' => 'get_definitions',