MDL-66809 core_grades: Implement scale-based marking
authorAndrew Nicols <andrew@nicols.co.uk>
Fri, 4 Oct 2019 05:30:20 +0000 (13:30 +0800)
committerMathew May <mathewm@hotmail.co.nz>
Wed, 30 Oct 2019 02:23:41 +0000 (10:23 +0800)
Part of MDL-66074

grade/amd/build/grades/grader/gradingpanel/scale.min.js [new file with mode: 0644]
grade/amd/build/grades/grader/gradingpanel/scale.min.js.map [new file with mode: 0644]
grade/amd/src/grades/grader/gradingpanel/scale.js [new file with mode: 0644]
grade/classes/component_gradeitem.php
grade/classes/grades/grader/gradingpanel/scale/external/fetch.php [new file with mode: 0644]
grade/classes/grades/grader/gradingpanel/scale/external/store.php [new file with mode: 0644]
grade/templates/grades/grader/gradingpanel/scale.mustache [new file with mode: 0644]
grade/tests/grades_grader_gradingpanel_scale_external_fetch_test.php [new file with mode: 0644]
grade/tests/grades_grader_gradingpanel_scale_external_store_test.php [new file with mode: 0644]
lib/db/services.php

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 (file)
index 0000000..df80d6a
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 (file)
index 0000000..0ef4ed9
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 (file)
index 0000000..8413ad2
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+
+/**
+ * Grading panel for simple direct grading.
+ *
+ * @module     core_grades/grades/grader/gradingpanel/scale
+ * @package    core_grades
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @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());
+};
index c2d41bb..eae68a6 100644 (file)
@@ -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 (file)
index 0000000..3e88e4c
--- /dev/null
@@ -0,0 +1,186 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Web service functions relating to scale grades and grading.
+ *
+ * @package    core_grades
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..2a5fd7b
--- /dev/null
@@ -0,0 +1,161 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Web service functions relating to scale grades and grading.
+ *
+ * @package    core_grades
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..6c6ef78
--- /dev/null
@@ -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 <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @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
+    }
+}}
+<form>
+  <div class="form-group">
+    <label for="core_grades-grade-{{uniqid}}">{{#str}}grade, moodle{{/str}}</label>
+    <select class="form-control" name="grade" id="core_grades-grade-{{uniqid}}" aria-describedby="core_grades-help-{{uniqid}}">
+        <option value="-1">{{#str}} nograde, moodle{{/str}}</option>
+    {{#options}}
+        <option value="{{value}}" {{#selected}}selected{{/selected}}>{{title}}</option>
+    {{/options}}
+    </select>
+    <small id="core_grades-help-{{uniqid}}" class="form-text text-muted">{{#str}}grade_help, core_grades{{/str}}</small>
+  </div>
+</form>
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 (file)
index 0000000..4b64859
--- /dev/null
@@ -0,0 +1,255 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for core_grades\component_gradeitems;
+ *
+ * @package   core_grades
+ * @category  test
+ * @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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 (file)
index 0000000..dbf7ab5
--- /dev/null
@@ -0,0 +1,443 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for core_grades\component_gradeitems;
+ *
+ * @package   core_grades
+ * @category  test
+ * @copyright 2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @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 <andrew@nicols.co.uk>
+ * @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,
+        ];
+    }
+}
index a0a34c6..0076942 100644 (file)
@@ -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',