diff --git a/course/amd/build/repository.min.js b/course/amd/build/repository.min.js
index 3c301ffbb76..2e4849bbfd8 100644
--- a/course/amd/build/repository.min.js
+++ b/course/amd/build/repository.min.js
@@ -1,2 +1,2 @@
-define ("core_course/repository",["jquery","core/ajax"],function(a,b){return{getEnrolledCoursesByTimelineClassification:function getEnrolledCoursesByTimelineClassification(a,c,d,e){var f={classification:a};if("undefined"!=typeof c){f.limit=c}if("undefined"!=typeof d){f.offset=d}if("undefined"!=typeof e){f.sort=e}return b.call([{methodname:"core_course_get_enrolled_courses_by_timeline_classification",args:f}])[0]},getLastAccessedCourses:function getLastAccessedCourses(a,c,d,e){var f={};if("undefined"!=typeof a){f.userid=a}if("undefined"!=typeof c){f.limit=c}if("undefined"!=typeof d){f.offset=d}if("undefined"!=typeof e){f.sort=e}return b.call([{methodname:"core_course_get_recent_courses",args:f}])[0]}}});
+define ("core_course/repository",["jquery","core/ajax"],function(a,b){return{getEnrolledCoursesByTimelineClassification:function getEnrolledCoursesByTimelineClassification(a,c,d,e){var f={classification:a};if("undefined"!=typeof c){f.limit=c}if("undefined"!=typeof d){f.offset=d}if("undefined"!=typeof e){f.sort=e}return b.call([{methodname:"core_course_get_enrolled_courses_by_timeline_classification",args:f}])[0]},getLastAccessedCourses:function getLastAccessedCourses(a,c,d,e){var f={};if("undefined"!=typeof a){f.userid=a}if("undefined"!=typeof c){f.limit=c}if("undefined"!=typeof d){f.offset=d}if("undefined"!=typeof e){f.sort=e}return b.call([{methodname:"core_course_get_recent_courses",args:f}])[0]},getUsersFromCourseModuleID:function getEnrolledUsersFromCourseModuleID(a){return b.call([{methodname:"core_course_get_enrolled_users_by_cmid",args:{cmid:a}}])[0]}}});
//# sourceMappingURL=repository.min.js.map
diff --git a/course/amd/build/repository.min.js.map b/course/amd/build/repository.min.js.map
index 21f6694971f..1e49dc2d9b0 100644
--- a/course/amd/build/repository.min.js.map
+++ b/course/amd/build/repository.min.js.map
@@ -1 +1 @@
-{"version":3,"sources":["../src/repository.js"],"names":["define","$","Ajax","getEnrolledCoursesByTimelineClassification","classification","limit","offset","sort","args","call","methodname","getLastAccessedCourses","userid"],"mappings":"AAsBAA,OAAM,0BAAC,CAAC,QAAD,CAAW,WAAX,CAAD,CAA0B,SAASC,CAAT,CAAYC,CAAZ,CAAkB,CA0E9C,MAAO,CACHC,0CAA0C,CA/DG,QAA7CA,CAAAA,0CAA6C,CAASC,CAAT,CAAyBC,CAAzB,CAAgCC,CAAhC,CAAwCC,CAAxC,CAA8C,CAC3F,GAAIC,CAAAA,CAAI,CAAG,CACPJ,cAAc,CAAEA,CADT,CAAX,CAIA,GAAqB,WAAjB,QAAOC,CAAAA,CAAX,CAAkC,CAC9BG,CAAI,CAACH,KAAL,CAAaA,CAChB,CAED,GAAsB,WAAlB,QAAOC,CAAAA,CAAX,CAAmC,CAC/BE,CAAI,CAACF,MAAL,CAAcA,CACjB,CAED,GAAoB,WAAhB,QAAOC,CAAAA,CAAX,CAAiC,CAC7BC,CAAI,CAACD,IAAL,CAAYA,CACf,CAOD,MAAOL,CAAAA,CAAI,CAACO,IAAL,CAAU,CALH,CACVC,UAAU,CAAE,6DADF,CAEVF,IAAI,CAAEA,CAFI,CAKG,CAAV,EAAqB,CAArB,CACV,CAuCM,CAEHG,sBAAsB,CA7BG,QAAzBA,CAAAA,sBAAyB,CAASC,CAAT,CAAiBP,CAAjB,CAAwBC,CAAxB,CAAgCC,CAAhC,CAAsC,CAC/D,GAAIC,CAAAA,CAAI,CAAG,EAAX,CAEA,GAAsB,WAAlB,QAAOI,CAAAA,CAAX,CAAmC,CAC/BJ,CAAI,CAACI,MAAL,CAAcA,CACjB,CAED,GAAqB,WAAjB,QAAOP,CAAAA,CAAX,CAAkC,CAC9BG,CAAI,CAACH,KAAL,CAAaA,CAChB,CAED,GAAsB,WAAlB,QAAOC,CAAAA,CAAX,CAAmC,CAC/BE,CAAI,CAACF,MAAL,CAAcA,CACjB,CAED,GAAoB,WAAhB,QAAOC,CAAAA,CAAX,CAAiC,CAC7BC,CAAI,CAACD,IAAL,CAAYA,CACf,CAOD,MAAOL,CAAAA,CAAI,CAACO,IAAL,CAAU,CALH,CACVC,UAAU,CAAE,gCADF,CAEVF,IAAI,CAAEA,CAFI,CAKG,CAAV,EAAqB,CAArB,CACV,CAEM,CAIV,CA9EK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle course ajax actions.\n *\n * @module core_course/repository\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery', 'core/ajax'], function($, Ajax) {\n\n /**\n * Get the list of courses that the logged in user is enrolled in for a given\n * timeline classification.\n *\n * @param {string} classification past, inprogress, or future\n * @param {int} limit Only return this many results\n * @param {int} offset Skip this many results from the start of the result set\n * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'\n * @return {object} jQuery promise resolved with courses.\n */\n var getEnrolledCoursesByTimelineClassification = function(classification, limit, offset, sort) {\n var args = {\n classification: classification\n };\n\n if (typeof limit !== 'undefined') {\n args.limit = limit;\n }\n\n if (typeof offset !== 'undefined') {\n args.offset = offset;\n }\n\n if (typeof sort !== 'undefined') {\n args.sort = sort;\n }\n\n var request = {\n methodname: 'core_course_get_enrolled_courses_by_timeline_classification',\n args: args\n };\n\n return Ajax.call([request])[0];\n };\n\n /**\n * Get the list of courses that the user has most recently accessed.\n *\n * @method getLastAccessedCourses\n * @param {int} userid User from which the courses will be obtained\n * @param {int} limit Only return this many results\n * @param {int} offset Skip this many results from the start of the result set\n * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'\n * @return {promise} Resolved with an array of courses\n */\n var getLastAccessedCourses = function(userid, limit, offset, sort) {\n var args = {};\n\n if (typeof userid !== 'undefined') {\n args.userid = userid;\n }\n\n if (typeof limit !== 'undefined') {\n args.limit = limit;\n }\n\n if (typeof offset !== 'undefined') {\n args.offset = offset;\n }\n\n if (typeof sort !== 'undefined') {\n args.sort = sort;\n }\n\n var request = {\n methodname: 'core_course_get_recent_courses',\n args: args\n };\n\n return Ajax.call([request])[0];\n };\n\n return {\n getEnrolledCoursesByTimelineClassification: getEnrolledCoursesByTimelineClassification,\n getLastAccessedCourses: getLastAccessedCourses\n };\n});\n"],"file":"repository.min.js"}
\ No newline at end of file
+{"version":3,"sources":["../src/repository.js"],"names":["define","$","Ajax","getEnrolledCoursesByTimelineClassification","classification","limit","offset","sort","args","call","methodname","getLastAccessedCourses","userid","getUsersFromCourseModuleID","getEnrolledUsersFromCourseModuleID","cmid"],"mappings":"AAsBAA,OAAM,0BAAC,CAAC,QAAD,CAAW,WAAX,CAAD,CAA0B,SAASC,CAAT,CAAYC,CAAZ,CAAkB,CA2F9C,MAAO,CACHC,0CAA0C,CAhFG,QAA7CA,CAAAA,0CAA6C,CAASC,CAAT,CAAyBC,CAAzB,CAAgCC,CAAhC,CAAwCC,CAAxC,CAA8C,CAC3F,GAAIC,CAAAA,CAAI,CAAG,CACPJ,cAAc,CAAEA,CADT,CAAX,CAIA,GAAqB,WAAjB,QAAOC,CAAAA,CAAX,CAAkC,CAC9BG,CAAI,CAACH,KAAL,CAAaA,CAChB,CAED,GAAsB,WAAlB,QAAOC,CAAAA,CAAX,CAAmC,CAC/BE,CAAI,CAACF,MAAL,CAAcA,CACjB,CAED,GAAoB,WAAhB,QAAOC,CAAAA,CAAX,CAAiC,CAC7BC,CAAI,CAACD,IAAL,CAAYA,CACf,CAOD,MAAOL,CAAAA,CAAI,CAACO,IAAL,CAAU,CALH,CACVC,UAAU,CAAE,6DADF,CAEVF,IAAI,CAAEA,CAFI,CAKG,CAAV,EAAqB,CAArB,CACV,CAwDM,CAEHG,sBAAsB,CA9CG,QAAzBA,CAAAA,sBAAyB,CAASC,CAAT,CAAiBP,CAAjB,CAAwBC,CAAxB,CAAgCC,CAAhC,CAAsC,CAC/D,GAAIC,CAAAA,CAAI,CAAG,EAAX,CAEA,GAAsB,WAAlB,QAAOI,CAAAA,CAAX,CAAmC,CAC/BJ,CAAI,CAACI,MAAL,CAAcA,CACjB,CAED,GAAqB,WAAjB,QAAOP,CAAAA,CAAX,CAAkC,CAC9BG,CAAI,CAACH,KAAL,CAAaA,CAChB,CAED,GAAsB,WAAlB,QAAOC,CAAAA,CAAX,CAAmC,CAC/BE,CAAI,CAACF,MAAL,CAAcA,CACjB,CAED,GAAoB,WAAhB,QAAOC,CAAAA,CAAX,CAAiC,CAC7BC,CAAI,CAACD,IAAL,CAAYA,CACf,CAOD,MAAOL,CAAAA,CAAI,CAACO,IAAL,CAAU,CALH,CACVC,UAAU,CAAE,gCADF,CAEVF,IAAI,CAAEA,CAFI,CAKG,CAAV,EAAqB,CAArB,CACV,CAmBM,CAGHK,0BAA0B,CAdW,QAArCC,CAAAA,kCAAqC,CAASC,CAAT,CAAe,CAQpD,MAAOb,CAAAA,CAAI,CAACO,IAAL,CAAU,CAPH,CACVC,UAAU,CAAE,wCADF,CAEVF,IAAI,CAAE,CACFO,IAAI,CAAEA,CADJ,CAFI,CAOG,CAAV,EAAqB,CAArB,CACV,CAEM,CAKV,CAhGK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * A javascript module to handle course ajax actions.\n *\n * @module core_course/repository\n * @copyright 2018 Ryan Wyllie \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery', 'core/ajax'], function($, Ajax) {\n\n /**\n * Get the list of courses that the logged in user is enrolled in for a given\n * timeline classification.\n *\n * @param {string} classification past, inprogress, or future\n * @param {int} limit Only return this many results\n * @param {int} offset Skip this many results from the start of the result set\n * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'\n * @return {object} jQuery promise resolved with courses.\n */\n var getEnrolledCoursesByTimelineClassification = function(classification, limit, offset, sort) {\n var args = {\n classification: classification\n };\n\n if (typeof limit !== 'undefined') {\n args.limit = limit;\n }\n\n if (typeof offset !== 'undefined') {\n args.offset = offset;\n }\n\n if (typeof sort !== 'undefined') {\n args.sort = sort;\n }\n\n var request = {\n methodname: 'core_course_get_enrolled_courses_by_timeline_classification',\n args: args\n };\n\n return Ajax.call([request])[0];\n };\n\n /**\n * Get the list of courses that the user has most recently accessed.\n *\n * @method getLastAccessedCourses\n * @param {int} userid User from which the courses will be obtained\n * @param {int} limit Only return this many results\n * @param {int} offset Skip this many results from the start of the result set\n * @param {string} sort Column to sort by and direction, e.g. 'shortname asc'\n * @return {promise} Resolved with an array of courses\n */\n var getLastAccessedCourses = function(userid, limit, offset, sort) {\n var args = {};\n\n if (typeof userid !== 'undefined') {\n args.userid = userid;\n }\n\n if (typeof limit !== 'undefined') {\n args.limit = limit;\n }\n\n if (typeof offset !== 'undefined') {\n args.offset = offset;\n }\n\n if (typeof sort !== 'undefined') {\n args.sort = sort;\n }\n\n var request = {\n methodname: 'core_course_get_recent_courses',\n args: args\n };\n\n return Ajax.call([request])[0];\n };\n\n /**\n * Get the list of users enrolled in this cmid.\n *\n * @param {Number} cmid Course Module from which the users will be obtained\n * @returns {Promise} Promise containing a list of users\n */\n var getEnrolledUsersFromCourseModuleID = function(cmid) {\n var request = {\n methodname: 'core_course_get_enrolled_users_by_cmid',\n args: {\n cmid: cmid,\n },\n };\n\n return Ajax.call([request])[0];\n };\n\n return {\n getEnrolledCoursesByTimelineClassification: getEnrolledCoursesByTimelineClassification,\n getLastAccessedCourses: getLastAccessedCourses,\n getUsersFromCourseModuleID: getEnrolledUsersFromCourseModuleID,\n };\n});\n"],"file":"repository.min.js"}
\ No newline at end of file
diff --git a/course/amd/src/repository.js b/course/amd/src/repository.js
index 6340eab31b1..7e530a72543 100644
--- a/course/amd/src/repository.js
+++ b/course/amd/src/repository.js
@@ -94,8 +94,26 @@ define(['jquery', 'core/ajax'], function($, Ajax) {
return Ajax.call([request])[0];
};
+ /**
+ * Get the list of users enrolled in this cmid.
+ *
+ * @param {Number} cmid Course Module from which the users will be obtained
+ * @returns {Promise} Promise containing a list of users
+ */
+ var getEnrolledUsersFromCourseModuleID = function(cmid) {
+ var request = {
+ methodname: 'core_course_get_enrolled_users_by_cmid',
+ args: {
+ cmid: cmid,
+ },
+ };
+
+ return Ajax.call([request])[0];
+ };
+
return {
getEnrolledCoursesByTimelineClassification: getEnrolledCoursesByTimelineClassification,
- getLastAccessedCourses: getLastAccessedCourses
+ getLastAccessedCourses: getLastAccessedCourses,
+ getUsersFromCourseModuleID: getEnrolledUsersFromCourseModuleID,
};
});
diff --git a/course/externallib.php b/course/externallib.php
index bb2b08a7478..d3bab4d1a24 100644
--- a/course/externallib.php
+++ b/course/externallib.php
@@ -4053,4 +4053,82 @@ class core_course_external extends external_api {
public static function get_recent_courses_returns() {
return new external_multiple_structure(course_summary_exporter::get_read_structure(), 'Courses');
}
+
+ /**
+ * Returns description of method parameters
+ *
+ * @return external_function_parameters
+ */
+ public static function get_enrolled_users_by_cmid_parameters() {
+ return new external_function_parameters([
+ 'cmid' => new external_value(PARAM_INT, 'id of the course module', VALUE_REQUIRED),
+ ]);
+ }
+
+ /**
+ * Get all users in a course for a given cmid.
+ *
+ * @param int $cmid Course Module id from which the users will be obtained
+ * @return array List of users
+ * @throws invalid_parameter_exception
+ */
+ public static function get_enrolled_users_by_cmid(int $cmid) {
+ $warnings = [];
+
+ [
+ 'cmid' => $cmid,
+ ] = self::validate_parameters(self::get_enrolled_users_by_cmid_parameters(), [
+ 'cmid' => $cmid,
+ ]);
+
+ list($course, $cm) = get_course_and_cm_from_cmid($cmid);
+ $coursecontext = context_course::instance($course->id);
+ self::validate_context($coursecontext);
+
+ $enrolledusers = get_enrolled_users($coursecontext);
+
+ $users = array_map(function ($user) {
+ $user->fullname = fullname($user);
+ return $user;
+ }, $enrolledusers);
+ sort($users);
+
+ return [
+ 'users' => $users,
+ 'warnings' => $warnings,
+ ];
+ }
+
+ /**
+ * Returns description of method result value
+ *
+ * @return external_description
+ */
+ public static function get_enrolled_users_by_cmid_returns() {
+ return new external_single_structure([
+ 'users' => new external_multiple_structure(self::user_description()),
+ 'warnings' => new external_warnings(),
+ ]);
+ }
+
+ /**
+ * Create user return value description.
+ *
+ * @return external_description
+ */
+ public static function user_description() {
+ $userfields = array(
+ 'id' => new external_value(core_user::get_property_type('id'), 'ID of the user'),
+ 'fullname' => new external_value(PARAM_TEXT, 'The full name of the user', VALUE_OPTIONAL),
+ 'firstname' => new external_value(
+ core_user::get_property_type('firstname'),
+ 'The first name(s) of the user',
+ VALUE_OPTIONAL),
+ 'lastname' => new external_value(
+ core_user::get_property_type('lastname'),
+ 'The family name of the user',
+ VALUE_OPTIONAL),
+ );
+ return new external_single_structure($userfields);
+ }
}
diff --git a/course/modlib.php b/course/modlib.php
index caf3868bbe1..181586dd385 100644
--- a/course/modlib.php
+++ b/course/modlib.php
@@ -27,6 +27,8 @@
defined('MOODLE_INTERNAL') || die;
+use \core_grades\component_gradeitems;
+
require_once($CFG->dirroot.'/course/lib.php');
/**
@@ -213,49 +215,63 @@ function edit_module_post_actions($moduleinfo, $course) {
$hasgrades = plugin_supports('mod', $moduleinfo->modulename, FEATURE_GRADE_HAS_GRADE, false);
$hasoutcomes = plugin_supports('mod', $moduleinfo->modulename, FEATURE_GRADE_OUTCOMES, true);
- // Sync idnumber with grade_item.
- if ($hasgrades && $grade_item = grade_item::fetch(array('itemtype'=>'mod', 'itemmodule'=>$moduleinfo->modulename,
- 'iteminstance'=>$moduleinfo->instance, 'itemnumber'=>0, 'courseid'=>$course->id))) {
- $gradeupdate = false;
- if ($grade_item->idnumber != $moduleinfo->cmidnumber) {
- $grade_item->idnumber = $moduleinfo->cmidnumber;
- $gradeupdate = true;
- }
- if (isset($moduleinfo->gradepass) && $grade_item->gradepass != $moduleinfo->gradepass) {
- $grade_item->gradepass = $moduleinfo->gradepass;
- $gradeupdate = true;
- }
- if ($gradeupdate) {
- $grade_item->update();
- }
- }
-
- if ($hasgrades) {
- $items = grade_item::fetch_all(array('itemtype'=>'mod', 'itemmodule'=>$moduleinfo->modulename,
- 'iteminstance'=>$moduleinfo->instance, 'courseid'=>$course->id));
- } else {
- $items = array();
- }
+ $items = grade_item::fetch_all([
+ 'itemtype' => 'mod',
+ 'itemmodule' => $moduleinfo->modulename,
+ 'iteminstance' => $moduleinfo->instance,
+ 'courseid' => $course->id,
+ ]);
// Create parent category if requested and move to correct parent category.
- if ($items and isset($moduleinfo->gradecat)) {
- if ($moduleinfo->gradecat == -1) {
- $grade_category = new grade_category();
- $grade_category->courseid = $course->id;
- $grade_category->fullname = $moduleinfo->name;
- $grade_category->insert();
- if ($grade_item) {
- $parent = $grade_item->get_parent_category();
- $grade_category->set_parent($parent->id);
- }
- $moduleinfo->gradecat = $grade_category->id;
- }
+ $component = "mod_{$moduleinfo->modulename}";
+ if ($items) {
+ foreach ($items as $item) {
+ $update = false;
- foreach ($items as $itemid=>$unused) {
- $items[$itemid]->set_parent($moduleinfo->gradecat);
- if ($itemid == $grade_item->id) {
- // Use updated grade_item.
- $grade_item = $items[$itemid];
+ // Sync idnumber with grade_item.
+ // Note: This only happens for itemnumber 0 at this time.
+ if ($item->itemnumber == 0 && ($item->idnumber != $moduleinfo->cmidnumber)) {
+ $item->idnumber = $moduleinfo->cmidnumber;
+ $update = true;
+ }
+
+ // Determine the grade category.
+ $gradecatfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $item->itemnumber, 'gradecat');
+ if (property_exists($moduleinfo, $gradecatfieldname)) {
+ $gradecat = $moduleinfo->$gradecatfieldname;
+ if ($gradecat == -1) {
+ $gradecategory = new grade_category();
+ $gradecategory->courseid = $course->id;
+ $gradecategory->fullname = $moduleinfo->name;
+ $gradecategory->insert();
+
+ $parent = $item->get_parent_category();
+ $gradecategory->set_parent($parent->id);
+ $gradecat = $gradecategory->id;
+ }
+
+ $oldgradecat = null;
+ if ($parent = $item->get_parent_category()) {
+ $oldgradecat = $parent->id;
+ }
+ if ($oldgradecat != $gradecat) {
+ $item->set_parent($gradecat);
+ $update = true;
+ }
+ }
+
+ // Determine the gradepass.
+ $gradepassfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $item->itemnumber, 'gradepass');
+ if (isset($moduleinfo->{$gradepassfieldname})) {
+ $gradepass = $moduleinfo->{$gradepassfieldname};
+ if (null !== $gradepass && $gradepass != $item->gradepass) {
+ $item->gradepass = $gradepass;
+ $update = true;
+ }
+ }
+
+ if ($update) {
+ $item->update();
}
}
}
@@ -263,8 +279,6 @@ function edit_module_post_actions($moduleinfo, $course) {
require_once($CFG->libdir.'/grade/grade_outcome.php');
// Add outcomes if requested.
if ($hasoutcomes && $outcomes = grade_outcome::fetch_all_available($course->id)) {
- $grade_items = array();
-
// Outcome grade_item.itemnumber start at 1000, there is nothing above outcomes.
$max_itemnumber = 999;
if ($items) {
@@ -279,7 +293,7 @@ function edit_module_post_actions($moduleinfo, $course) {
$elname = 'outcome_'.$outcome->id;
if (property_exists($moduleinfo, $elname) and $moduleinfo->$elname) {
- // So we have a request for new outcome grade item?
+ // Check if this is a new outcome grade item.
if ($items) {
$outcomeexists = false;
foreach($items as $item) {
@@ -295,25 +309,25 @@ function edit_module_post_actions($moduleinfo, $course) {
$max_itemnumber++;
- $outcome_item = new grade_item();
- $outcome_item->courseid = $course->id;
- $outcome_item->itemtype = 'mod';
- $outcome_item->itemmodule = $moduleinfo->modulename;
- $outcome_item->iteminstance = $moduleinfo->instance;
- $outcome_item->itemnumber = $max_itemnumber;
- $outcome_item->itemname = $outcome->fullname;
- $outcome_item->outcomeid = $outcome->id;
- $outcome_item->gradetype = GRADE_TYPE_SCALE;
- $outcome_item->scaleid = $outcome->scaleid;
- $outcome_item->insert();
-
- // Move the new outcome into correct category and fix sortorder if needed.
- if ($grade_item) {
- $outcome_item->set_parent($grade_item->categoryid);
- $outcome_item->move_after_sortorder($grade_item->sortorder);
+ $outcomeitem = new grade_item();
+ $outcomeitem->courseid = $course->id;
+ $outcomeitem->itemtype = 'mod';
+ $outcomeitem->itemmodule = $moduleinfo->modulename;
+ $outcomeitem->iteminstance = $moduleinfo->instance;
+ $outcomeitem->itemnumber = $max_itemnumber;
+ $outcomeitem->itemname = $outcome->fullname;
+ $outcomeitem->outcomeid = $outcome->id;
+ $outcomeitem->gradetype = GRADE_TYPE_SCALE;
+ $outcomeitem->scaleid = $outcome->scaleid;
+ $outcomeitem->insert();
+ if ($items) {
+ // Move the new outcome into the same category and immediately after the first grade item.
+ $item = reset($items);
+ $outcomeitem->set_parent($item->categoryid);
+ $outcomeitem->move_after_sortorder($item->sortorder);
} else if (isset($moduleinfo->gradecat)) {
- $outcome_item->set_parent($moduleinfo->gradecat);
+ $outcomeitem->set_parent($moduleinfo->gradecat);
}
}
}
@@ -354,7 +368,6 @@ function edit_module_post_actions($moduleinfo, $course) {
return $moduleinfo;
}
-
/**
* Set module info default values for the unset module attributs.
*
@@ -702,34 +715,43 @@ function get_moduleinfo_data($cm, $course) {
}
}
- if ($items = grade_item::fetch_all(array('itemtype'=>'mod', 'itemmodule'=>$data->modulename,
- 'iteminstance'=>$data->instance, 'courseid'=>$course->id))) {
+ $component = "mod_{$data->modulename}";
+ $items = grade_item::fetch_all([
+ 'itemtype' => 'mod',
+ 'itemmodule' => $data->modulename,
+ 'iteminstance' => $data->instance,
+ 'courseid' => $course->id,
+ ]);
+
+ if ($items) {
// Add existing outcomes.
foreach ($items as $item) {
if (!empty($item->outcomeid)) {
$data->{'outcome_' . $item->outcomeid} = 1;
} else if (isset($item->gradepass)) {
- $decimalpoints = $item->get_decimals();
- $data->gradepass = format_float($item->gradepass, $decimalpoints);
+ $gradepassfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $item->itemnumber, 'gradepass');
+ $data->{$gradepassfieldname} = format_float($item->gradepass, $item->get_decimals());
}
+
}
// set category if present
- $gradecat = false;
+ $gradecat = [];
foreach ($items as $item) {
- if ($gradecat === false) {
- $gradecat = $item->categoryid;
- continue;
+ if (!isset($gradecat[$item->itemnumber])) {
+ $gradecat[$item->itemnumber] = $item->categoryid;
}
- if ($gradecat != $item->categoryid) {
- //mixed categories
- $gradecat = false;
- break;
+ if ($gradecat[$item->itemnumber] != $item->categoryid) {
+ // Mixed categories.
+ $gradecat[$item->itemnumber] = false;
}
}
- if ($gradecat !== false) {
- // do not set if mixed categories present
- $data->gradecat = $gradecat;
+ foreach ($gradecat as $itemnumber => $cat) {
+ if ($cat !== false) {
+ $gradecatfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'gradecat');
+ // Do not set if mixed categories present.
+ $data->{$gradecatfieldname} = $cat;
+ }
}
}
return array($cm, $context, $module, $data, $cw);
diff --git a/course/moodleform_mod.php b/course/moodleform_mod.php
index c977a148113..ead3500f358 100644
--- a/course/moodleform_mod.php
+++ b/course/moodleform_mod.php
@@ -1,12 +1,40 @@
libdir.'/formslib.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 .
+
+/**
+ * Moodleform.
+ *
+ * @package core_course
+ * @copyright Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once($CFG->libdir.'/formslib.php');
require_once($CFG->libdir.'/completionlib.php');
require_once($CFG->libdir.'/gradelib.php');
require_once($CFG->libdir.'/plagiarismlib.php');
+use core_grades\component_gradeitems;
+
/**
- * This class adds extra methods to form wrapper specific to be used for module
- * add / update forms mod/{modname}/mod_form.php replaced deprecated mod/{modname}/mod.html
+ * This class adds extra methods to form wrapper specific to be used for module add / update forms
+ * mod/{modname}/mod_form.php replaced deprecated mod/{modname}/mod.html Moodleform.
+ *
+ * @package core_course
+ * @copyright Andrew Nicols
*/
abstract class moodleform_mod extends moodleform {
/** Current data */
@@ -68,6 +96,9 @@ abstract class moodleform_mod extends moodleform {
/** @var object The course format of the current course. */
protected $courseformat;
+ /** @var string Whether this is graded or rated. */
+ private $gradedorrated = null;
+
public function __construct($current, $section, $cm, $course) {
global $CFG;
@@ -215,18 +246,19 @@ abstract class moodleform_mod extends moodleform {
if ($id = $mform->getElementValue('update')) {
$modulename = $mform->getElementValue('modulename');
$instance = $mform->getElementValue('instance');
+ $component = "mod_{$modulename}";
if ($this->_features->gradecat) {
- $gradecat = false;
- if (!empty($CFG->enableoutcomes) and $this->_features->outcomes) {
- $outcomes = grade_outcome::fetch_all_available($COURSE->id);
- if (!empty($outcomes)) {
- $gradecat = true;
- }
- }
-
$hasgradeitems = false;
- $items = grade_item::fetch_all(array('itemtype'=>'mod', 'itemmodule'=>$modulename,'iteminstance'=>$instance, 'courseid'=>$COURSE->id));
+ $items = grade_item::fetch_all([
+ 'itemtype' => 'mod',
+ 'itemmodule' => $modulename,
+ 'iteminstance' => $instance,
+ 'courseid' => $COURSE->id,
+ ]);
+
+ $gradecategories = [];
+ $removecategories = [];
//will be no items if, for example, this activity supports ratings but rating aggregate type == no ratings
if (!empty($items)) {
foreach ($items as $item) {
@@ -241,32 +273,23 @@ abstract class moodleform_mod extends moodleform {
}
foreach ($items as $item) {
- if (is_bool($gradecat)) {
- $gradecat = $item->categoryid;
- continue;
- }
- if ($gradecat != $item->categoryid) {
- //mixed categories
- $gradecat = false;
- break;
+ $gradecatfieldname = component_gradeitems::get_field_name_for_itemnumber(
+ $component,
+ $item->itemnumber,
+ 'gradecat'
+ );
+
+ if (!isset($gradecategories[$gradecatfieldname])) {
+ $gradecategories[$gradecatfieldname] = $item->categoryid;
+ } else if ($gradecategories[$gradecatfieldname] != $item->categoryid) {
+ $removecategories[$gradecatfieldname] = true;
}
}
}
- if (!$hasgradeitems && $mform->elementExists('gradepass')) {
- // Remove form element 'Grade to pass' since there are no grade items (when rating not selected).
- $mform->removeElement('gradepass');
- }
-
- if ($gradecat === false) {
- // items and outcomes in different categories - remove the option
- // TODO: add a "Mixed categories" text instead of removing elements with no explanation
- if ($mform->elementExists('gradecat')) {
- $mform->removeElement('gradecat');
- if ($this->_features->rating && !$mform->elementExists('gradepass')) {
- //if supports ratings then the max grade dropdown wasnt added so the grade box can be removed entirely
- $mform->removeElement('modstandardgrade');
- }
+ foreach ($removecategories as $toremove) {
+ if ($mform->elementExists($toremove)) {
+ $mform->removeElement($toremove);
}
}
}
@@ -274,13 +297,13 @@ abstract class moodleform_mod extends moodleform {
if ($COURSE->groupmodeforce) {
if ($mform->elementExists('groupmode')) {
- $mform->hardFreeze('groupmode'); // groupmode can not be changed if forced from course settings
+ // The groupmode can not be changed if forced from course settings.
+ $mform->hardFreeze('groupmode');
}
}
- // Don't disable/remove groupingid if it is currently set to something,
- // otherwise you cannot turn it off at same time as turning off other
- // option (MDL-30764)
+ // Don't disable/remove groupingid if it is currently set to something, otherwise you cannot turn it off at same
+ // time as turning off other option (MDL-30764).
if (empty($this->_cm) || !$this->_cm->groupingid) {
if ($mform->elementExists('groupmode') && empty($COURSE->groupmodeforce)) {
$mform->hideIf('groupingid', 'groupmode', 'eq', NOGROUPS);
@@ -386,36 +409,45 @@ abstract class moodleform_mod extends moodleform {
}
}
- // Ratings: Don't let them select an aggregate type without selecting a scale.
- // If the user has selected to use ratings but has not chosen a scale or set max points then the form is
- // invalid. If ratings have been selected then the user must select either a scale or max points.
- // This matches (horrible) logic in data_preprocessing.
- if (isset($data['assessed']) && $data['assessed'] > 0 && empty($data['scale'])) {
- $errors['assessed'] = get_string('scaleselectionrequired', 'rating');
- }
+ $component = "mod_{$this->_modname}";
+ $itemnames = component_gradeitems::get_itemname_mapping_for_component($component);
+ foreach ($itemnames as $itemnumber => $itemname) {
+ $gradefieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'grade');
+ $gradepassfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'gradepass');
+ $assessedfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'assessed');
+ $scalefieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'scale');
- // Check that the grade pass is a valid number.
- $gradepassvalid = false;
- if (isset($data['gradepass'])) {
- if (unformat_float($data['gradepass'], true) === false) {
- $errors['gradepass'] = get_string('err_numeric', 'form');
- } else {
- $gradepassvalid = true;
+ // Ratings: Don't let them select an aggregate type without selecting a scale.
+ // If the user has selected to use ratings but has not chosen a scale or set max points then the form is
+ // invalid. If ratings have been selected then the user must select either a scale or max points.
+ // This matches (horrible) logic in data_preprocessing.
+ if (isset($data[$assessedfieldname]) && $data[$assessedfieldname] > 0 && empty($data[$scalefieldname])) {
+ $errors[$assessedfieldname] = get_string('scaleselectionrequired', 'rating');
}
- }
- // Grade to pass: ensure that the grade to pass is valid for points and scales.
- // If we are working with a scale, convert into a positive number for validation.
- if ($gradepassvalid && isset($data['gradepass']) && (!empty($data['grade']) || !empty($data['scale']))) {
- $scale = !empty($data['grade']) ? $data['grade'] : $data['scale'];
- if ($scale < 0) {
- $scalevalues = $DB->get_record('scale', array('id' => -$scale));
- $grade = count(explode(',', $scalevalues->scale));
- } else {
- $grade = $scale;
+ // Check that the grade pass is a valid number.
+ $gradepassvalid = false;
+ if (isset($data[$gradepassfieldname])) {
+ if (unformat_float($data[$gradepassfieldname], true) === false) {
+ $errors[$gradepassfieldname] = get_string('err_numeric', 'form');
+ } else {
+ $gradepassvalid = true;
+ }
}
- if (unformat_float($data['gradepass']) > $grade) {
- $errors['gradepass'] = get_string('gradepassgreaterthangrade', 'grades', $grade);
+
+ // Grade to pass: ensure that the grade to pass is valid for points and scales.
+ // If we are working with a scale, convert into a positive number for validation.
+ if ($gradepassvalid && isset($data[$gradepassfieldname]) && (!empty($data[$gradefieldname]) || !empty($data[$scalefieldname]))) {
+ $scale = !empty($data[$gradefieldname]) ? $data[$gradefieldname] : $data[$scalefieldname];
+ if ($scale < 0) {
+ $scalevalues = $DB->get_record('scale', array('id' => -$scale));
+ $grade = count(explode(',', $scalevalues->scale));
+ } else {
+ $grade = $scale;
+ }
+ if (unformat_float($data[$gradepassfieldname]) > $grade) {
+ $errors[$gradepassfieldname] = get_string('gradepassgreaterthangrade', 'grades', $grade);
+ }
}
}
@@ -485,7 +517,7 @@ abstract class moodleform_mod extends moodleform {
/**
* Adds all the standard elements to a form to edit the settings for an activity module.
*/
- function standard_coursemodule_elements(){
+ protected function standard_coursemodule_elements() {
global $COURSE, $CFG, $DB;
$mform =& $this->_form;
@@ -500,70 +532,10 @@ abstract class moodleform_mod extends moodleform {
}
}
-
if ($this->_features->rating) {
- require_once($CFG->dirroot.'/rating/lib.php');
- $rm = new rating_manager();
-
- $mform->addElement('header', 'modstandardratings', get_string('ratings', 'rating'));
-
- $permission=CAP_ALLOW;
- $rolenamestring = null;
- $isupdate = false;
- if (!empty($this->_cm)) {
- $isupdate = true;
- $context = context_module::instance($this->_cm->id);
-
- $rolenames = get_role_names_with_caps_in_context($context, array('moodle/rating:rate', 'mod/'.$this->_cm->modname.':rate'));
- $rolenamestring = implode(', ', $rolenames);
- } else {
- $rolenamestring = get_string('capabilitychecknotavailable','rating');
- }
- $mform->addElement('static', 'rolewarning', get_string('rolewarning','rating'), $rolenamestring);
- $mform->addHelpButton('rolewarning', 'rolewarning', 'rating');
-
- $mform->addElement('select', 'assessed', get_string('aggregatetype', 'rating') , $rm->get_aggregate_types());
- $mform->setDefault('assessed', 0);
- $mform->addHelpButton('assessed', 'aggregatetype', 'rating');
-
- $gradeoptions = array('isupdate' => $isupdate,
- 'currentgrade' => false,
- 'hasgrades' => false,
- 'canrescale' => $this->_features->canrescale,
- 'useratings' => $this->_features->rating);
- if ($isupdate) {
- $gradeitem = grade_item::fetch(array('itemtype' => 'mod',
- 'itemmodule' => $this->_cm->modname,
- 'iteminstance' => $this->_cm->instance,
- 'itemnumber' => 0,
- 'courseid' => $COURSE->id));
- if ($gradeitem) {
- $gradeoptions['currentgrade'] = $gradeitem->grademax;
- $gradeoptions['currentgradetype'] = $gradeitem->gradetype;
- $gradeoptions['currentscaleid'] = $gradeitem->scaleid;
- $gradeoptions['hasgrades'] = $gradeitem->has_grades();
- }
- }
- $mform->addElement('modgrade', 'scale', get_string('scale'), $gradeoptions);
- $mform->hideIf('scale', 'assessed', 'eq', 0);
- $mform->addHelpButton('scale', 'modgrade', 'grades');
- $mform->setDefault('scale', $CFG->gradepointdefault);
-
- $mform->addElement('checkbox', 'ratingtime', get_string('ratingtime', 'rating'));
- $mform->hideIf('ratingtime', 'assessed', 'eq', 0);
-
- $mform->addElement('date_time_selector', 'assesstimestart', get_string('from'));
- $mform->hideIf('assesstimestart', 'assessed', 'eq', 0);
- $mform->disabledIf('assesstimestart', 'ratingtime');
-
- $mform->addElement('date_time_selector', 'assesstimefinish', get_string('to'));
- $mform->hideIf('assesstimefinish', 'assessed', 'eq', 0);
- $mform->disabledIf('assesstimefinish', 'ratingtime');
+ $this->add_rating_settings($mform, 0);
}
- //doing this here means splitting up the grade related settings on the lesson settings page
- //$this->standard_grading_coursemodule_elements();
-
$mform->addElement('header', 'modstandardelshdr', get_string('modstandardels', 'form'));
$section = get_fast_modinfo($COURSE)->get_section_info($this->_section);
@@ -737,6 +709,110 @@ abstract class moodleform_mod extends moodleform {
$this->plugin_extend_coursemodule_standard_elements();
}
+ /**
+ * Add rating settings.
+ *
+ * @param moodleform_mod $mform
+ * @param int $itemnumber
+ */
+ protected function add_rating_settings($mform, int $itemnumber) {
+ global $CFG, $COURSE;
+
+ if ($this->gradedorrated && $this->gradedorrated !== 'rated') {
+ return;
+ }
+ $this->gradedorrated = 'rated';
+
+ require_once("{$CFG->dirroot}/rating/lib.php");
+ $rm = new rating_manager();
+
+ $component = "mod_{$this->_modname}";
+ $gradecatfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'gradecat');
+ $gradepassfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'gradepass');
+ $assessedfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'assessed');
+ $scalefieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'scale');
+
+ $mform->addElement('header', 'modstandardratings', get_string('ratings', 'rating'));
+
+ $isupdate = !empty($this->_cm);
+
+ $rolenamestring = null;
+ if ($isupdate) {
+ $context = context_module::instance($this->_cm->id);
+ $capabilities = ['moodle/rating:rate', "mod/{$this->_cm->modname}:rate"];
+ $rolenames = get_role_names_with_caps_in_context($context, $capabilities);
+ $rolenamestring = implode(', ', $rolenames);
+ } else {
+ $rolenamestring = get_string('capabilitychecknotavailable', 'rating');
+ }
+
+ $mform->addElement('static', 'rolewarning', get_string('rolewarning', 'rating'), $rolenamestring);
+ $mform->addHelpButton('rolewarning', 'rolewarning', 'rating');
+
+ $mform->addElement('select', $assessedfieldname, get_string('aggregatetype', 'rating') , $rm->get_aggregate_types());
+ $mform->setDefault($assessedfieldname, 0);
+ $mform->addHelpButton($assessedfieldname, 'aggregatetype', 'rating');
+
+ $gradeoptions = [
+ 'isupdate' => $isupdate,
+ 'currentgrade' => false,
+ 'hasgrades' => false,
+ 'canrescale' => false,
+ 'useratings' => true,
+ ];
+ if ($isupdate) {
+ $gradeitem = grade_item::fetch([
+ 'itemtype' => 'mod',
+ 'itemmodule' => $this->_cm->modname,
+ 'iteminstance' => $this->_cm->instance,
+ 'itemnumber' => $itemnumber,
+ 'courseid' => $COURSE->id,
+ ]);
+ if ($gradeitem) {
+ $gradeoptions['currentgrade'] = $gradeitem->grademax;
+ $gradeoptions['currentgradetype'] = $gradeitem->gradetype;
+ $gradeoptions['currentscaleid'] = $gradeitem->scaleid;
+ $gradeoptions['hasgrades'] = $gradeitem->has_grades();
+ }
+ }
+
+ $mform->addElement('modgrade', $scalefieldname, get_string('scale'), $gradeoptions);
+ $mform->hideIf($scalefieldname, $assessedfieldname, 'eq', 0);
+ $mform->addHelpButton($scalefieldname, 'modgrade', 'grades');
+ $mform->setDefault($scalefieldname, $CFG->gradepointdefault);
+
+ $mform->addElement('checkbox', 'ratingtime', get_string('ratingtime', 'rating'));
+ $mform->hideIf('ratingtime', $assessedfieldname, 'eq', 0);
+
+ $mform->addElement('date_time_selector', 'assesstimestart', get_string('from'));
+ $mform->hideIf('assesstimestart', $assessedfieldname, 'eq', 0);
+ $mform->hideIf('assesstimestart', 'ratingtime');
+
+ $mform->addElement('date_time_selector', 'assesstimefinish', get_string('to'));
+ $mform->hideIf('assesstimefinish', $assessedfieldname, 'eq', 0);
+ $mform->hideIf('assesstimefinish', 'ratingtime');
+
+ if ($this->_features->gradecat) {
+ $mform->addElement(
+ 'select',
+ $gradecatfieldname,
+ get_string('gradecategoryonmodform', 'grades'),
+ grade_get_categories_menu($COURSE->id, $this->_outcomesused)
+ );
+ $mform->addHelpButton($gradecatfieldname, 'gradecategoryonmodform', 'grades');
+ $mform->hideIf($gradecatfieldname, $assessedfieldname, 'eq', 0);
+ $mform->hideIf($gradecatfieldname, "{$scalefieldname}[modgrade_type]", 'eq', 'none');
+ }
+
+ // Grade to pass.
+ $mform->addElement('text', $gradepassfieldname, get_string('gradepass', 'grades'));
+ $mform->addHelpButton($gradepassfieldname, 'gradepass', 'grades');
+ $mform->setDefault($gradepassfieldname, '');
+ $mform->setType($gradepassfieldname, PARAM_RAW);
+ $mform->hideIf($gradepassfieldname, $assessedfieldname, 'eq', '0');
+ $mform->hideIf($gradepassfieldname, "{$scalefieldname}[modgrade_type]", 'eq', 'none');
+ }
+
/**
* Plugins can extend the coursemodule settings form.
*/
@@ -811,6 +887,21 @@ abstract class moodleform_mod extends moodleform {
public function standard_grading_coursemodule_elements() {
global $COURSE, $CFG;
+
+ if ($this->gradedorrated && $this->gradedorrated !== 'graded') {
+ return;
+ }
+ if ($this->_features->rating) {
+ return;
+ }
+ $this->gradedorrated = 'graded';
+
+ $itemnumber = 0;
+ $component = "mod_{$this->_modname}";
+ $gradefieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'grade');
+ $gradecatfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'gradecat');
+ $gradepassfieldname = component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, 'gradepass');
+
$mform =& $this->_form;
$isupdate = !empty($this->_cm);
$gradeoptions = array('isupdate' => $isupdate,
@@ -820,31 +911,27 @@ abstract class moodleform_mod extends moodleform {
'useratings' => $this->_features->rating);
if ($this->_features->hasgrades) {
-
- if (!$this->_features->rating || $this->_features->gradecat) {
+ if ($this->_features->gradecat) {
$mform->addElement('header', 'modstandardgrade', get_string('grade'));
}
//if supports grades and grades arent being handled via ratings
- if (!$this->_features->rating) {
-
- if ($isupdate) {
- $gradeitem = grade_item::fetch(array('itemtype' => 'mod',
- 'itemmodule' => $this->_cm->modname,
- 'iteminstance' => $this->_cm->instance,
- 'itemnumber' => 0,
- 'courseid' => $COURSE->id));
- if ($gradeitem) {
- $gradeoptions['currentgrade'] = $gradeitem->grademax;
- $gradeoptions['currentgradetype'] = $gradeitem->gradetype;
- $gradeoptions['currentscaleid'] = $gradeitem->scaleid;
- $gradeoptions['hasgrades'] = $gradeitem->has_grades();
- }
+ if ($isupdate) {
+ $gradeitem = grade_item::fetch(array('itemtype' => 'mod',
+ 'itemmodule' => $this->_cm->modname,
+ 'iteminstance' => $this->_cm->instance,
+ 'itemnumber' => 0,
+ 'courseid' => $COURSE->id));
+ if ($gradeitem) {
+ $gradeoptions['currentgrade'] = $gradeitem->grademax;
+ $gradeoptions['currentgradetype'] = $gradeitem->gradetype;
+ $gradeoptions['currentscaleid'] = $gradeitem->scaleid;
+ $gradeoptions['hasgrades'] = $gradeitem->has_grades();
}
- $mform->addElement('modgrade', 'grade', get_string('grade'), $gradeoptions);
- $mform->addHelpButton('grade', 'modgrade', 'grades');
- $mform->setDefault('grade', $CFG->gradepointdefault);
}
+ $mform->addElement('modgrade', $gradefieldname, get_string('grade'), $gradeoptions);
+ $mform->addHelpButton($gradefieldname, 'modgrade', 'grades');
+ $mform->setDefault($gradefieldname, $CFG->gradepointdefault);
if ($this->_features->advancedgrading
and !empty($this->current->_advancedgradingdata['methods'])
@@ -858,9 +945,7 @@ abstract class moodleform_mod extends moodleform {
$mform->addElement('select', 'advancedgradingmethod_'.$areaname,
get_string('gradingmethod', 'core_grading'), $this->current->_advancedgradingdata['methods']);
$mform->addHelpButton('advancedgradingmethod_'.$areaname, 'gradingmethod', 'core_grading');
- if (!$this->_features->rating) {
- $mform->hideIf('advancedgradingmethod_'.$areaname, 'grade[modgrade_type]', 'eq', 'none');
- }
+ $mform->hideIf('advancedgradingmethod_'.$areaname, "{$gradefieldname}[modgrade_type]", 'eq', 'none');
} else {
// the module defines multiple gradable areas, display a selector
@@ -877,25 +962,19 @@ abstract class moodleform_mod extends moodleform {
}
if ($this->_features->gradecat) {
- $mform->addElement('select', 'gradecat',
+ $mform->addElement('select', $gradecatfieldname,
get_string('gradecategoryonmodform', 'grades'),
grade_get_categories_menu($COURSE->id, $this->_outcomesused));
- $mform->addHelpButton('gradecat', 'gradecategoryonmodform', 'grades');
- if (!$this->_features->rating) {
- $mform->hideIf('gradecat', 'grade[modgrade_type]', 'eq', 'none');
- }
+ $mform->addHelpButton($gradecatfieldname, 'gradecategoryonmodform', 'grades');
+ $mform->hideIf($gradecatfieldname, "{$gradefieldname}[modgrade_type]", 'eq', 'none');
}
// Grade to pass.
- $mform->addElement('text', 'gradepass', get_string('gradepass', 'grades'));
- $mform->addHelpButton('gradepass', 'gradepass', 'grades');
- $mform->setDefault('gradepass', '');
- $mform->setType('gradepass', PARAM_RAW);
- if (!$this->_features->rating) {
- $mform->hideIf('gradepass', 'grade[modgrade_type]', 'eq', 'none');
- } else {
- $mform->hideIf('gradepass', 'assessed', 'eq', '0');
- }
+ $mform->addElement('text', $gradepassfieldname, get_string($gradepassfieldname, 'grades'));
+ $mform->addHelpButton($gradepassfieldname, $gradepassfieldname, 'grades');
+ $mform->setDefault($gradepassfieldname, '');
+ $mform->setType($gradepassfieldname, PARAM_RAW);
+ $mform->hideIf($gradepassfieldname, "{$gradefieldname}[modgrade_type]", 'eq', 'none');
}
}
@@ -1111,5 +1190,3 @@ abstract class moodleform_mod extends moodleform {
return $data;
}
}
-
-
diff --git a/course/tests/courselib_test.php b/course/tests/courselib_test.php
index c5b37fcab24..3a39c146f5d 100644
--- a/course/tests/courselib_test.php
+++ b/course/tests/courselib_test.php
@@ -54,6 +54,9 @@ class core_course_courselib_testcase extends advanced_testcase {
$moduleinfo->blockperiod = 60*60*24;
$moduleinfo->blockafter = 10;
$moduleinfo->warnafter = 5;
+
+ // Grading of whole forum settings.
+ $moduleinfo->grade_forum = 0;
}
/**
@@ -395,6 +398,9 @@ class core_course_courselib_testcase extends advanced_testcase {
$moduleinfo->blockperiod = 60*60*24;
$moduleinfo->blockafter = 10;
$moduleinfo->warnafter = 5;
+
+ // Grading of whole forum settings.
+ $moduleinfo->grade_forum = 0;
}
/**
diff --git a/course/tests/externallib_test.php b/course/tests/externallib_test.php
index ab857e93840..dd752c0f86e 100644
--- a/course/tests/externallib_test.php
+++ b/course/tests/externallib_test.php
@@ -2065,7 +2065,7 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
$outcomegradeitem->cmid = 0;
$outcomegradeitem->courseid = $course->id;
$outcomegradeitem->aggregationcoef = 0;
- $outcomegradeitem->itemnumber = 1; // The activity's original grade item will be 0.
+ $outcomegradeitem->itemnumber = 1000; // Outcomes start at 1000.
$outcomegradeitem->gradetype = GRADE_TYPE_SCALE;
$outcomegradeitem->scaleid = $outcome->scaleid;
$outcomegradeitem->insert();
@@ -2988,4 +2988,55 @@ class core_course_externallib_testcase extends externallib_advanced_testcase {
$this->assertCount(1, $result);
$this->assertEquals($courses[0]->id, array_shift($result)->id);
}
+
+ /**
+ * Test get enrolled users by cmid function.
+ */
+ public function test_get_enrolled_users_by_cmid() {
+ $this->resetAfterTest(true);
+
+ $user1 = self::getDataGenerator()->create_user();
+ $user2 = self::getDataGenerator()->create_user();
+
+ // Set the first created user to the test user.
+ self::setUser($user1);
+
+ // Create course to add the module.
+ $course1 = self::getDataGenerator()->create_course();
+
+ // Forum with tracking off.
+ $record = new stdClass();
+ $record->course = $course1->id;
+ $forum1 = self::getDataGenerator()->create_module('forum', $record);
+
+ // Following lines enrol and assign default role id to the users.
+ $this->getDataGenerator()->enrol_user($user1->id, $course1->id);
+ $this->getDataGenerator()->enrol_user($user2->id, $course1->id);
+
+ // Create what we expect to be returned when querying the course module.
+ $expectedusers = array(
+ 'users' => array(),
+ 'warnings' => array(),
+ );
+
+ $expectedusers['users'][0] = [
+ 'id' => $user1->id,
+ 'fullname' => fullname($user1),
+ 'firstname' => $user1->firstname,
+ 'lastname' => $user1->lastname,
+ ];
+ $expectedusers['users'][1] = [
+ 'id' => $user2->id,
+ 'fullname' => fullname($user2),
+ 'firstname' => $user2->firstname,
+ 'lastname' => $user2->lastname,
+ ];
+
+ // Test getting the users in a given context.
+ $users = core_course_external::get_enrolled_users_by_cmid($forum1->cmid);
+ $users = external_api::clean_returnvalue(core_course_external::get_enrolled_users_by_cmid_returns(), $users);
+
+ $this->assertEquals(2, count($users['users']));
+ $this->assertEquals($expectedusers, $users);
+ }
}
diff --git a/grade/amd/build/grades/grader/gradingpanel/normalise.min.js b/grade/amd/build/grades/grader/gradingpanel/normalise.min.js
new file mode 100644
index 00000000000..7ee975fe678
--- /dev/null
+++ b/grade/amd/build/grades/grader/gradingpanel/normalise.min.js
@@ -0,0 +1,2 @@
+define ("core_grades/grades/grader/gradingpanel/normalise",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.failedUpdate=a.invalidResult=a.normaliseResult=void 0;a.normaliseResult=function normaliseResult(a){return{result:a,failed:!!a.warnings.length,success:!a.warnings.length,error:null}};a.invalidResult=function invalidResult(){return{success:!1,failed:!1,result:{},error:null}};a.failedUpdate=function failedUpdate(a){return{success:!1,failed:!0,result:{},error:a}}});
+//# sourceMappingURL=normalise.min.js.map
diff --git a/grade/amd/build/grades/grader/gradingpanel/normalise.min.js.map b/grade/amd/build/grades/grader/gradingpanel/normalise.min.js.map
new file mode 100644
index 00000000000..c1ae5e77904
--- /dev/null
+++ b/grade/amd/build/grades/grader/gradingpanel/normalise.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../../src/grades/grader/gradingpanel/normalise.js"],"names":["normaliseResult","result","failed","warnings","length","success","error","invalidResult","failedUpdate"],"mappings":"2NA8B+B,QAAlBA,CAAAA,eAAkB,CAAAC,CAAM,CAAI,CACrC,MAAO,CACHA,MAAM,CAANA,CADG,CAEHC,MAAM,CAAE,CAAC,CAACD,CAAM,CAACE,QAAP,CAAgBC,MAFvB,CAGHC,OAAO,CAAE,CAACJ,CAAM,CAACE,QAAP,CAAgBC,MAHvB,CAIHE,KAAK,CAAE,IAJJ,CAMV,C,iBAO4B,QAAhBC,CAAAA,aAAgB,EAAM,CAC/B,MAAO,CACHF,OAAO,GADJ,CAEHH,MAAM,GAFH,CAGHD,MAAM,CAAE,EAHL,CAIHK,KAAK,CAAE,IAJJ,CAMV,C,gBAQ2B,QAAfE,CAAAA,YAAe,CAAAF,CAAK,CAAI,CACjC,MAAO,CACHD,OAAO,GADJ,CAEHH,MAAM,GAFH,CAGHD,MAAM,CAAE,EAHL,CAIHK,KAAK,CAALA,CAJG,CAMV,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Repository for simple direct grading panel.\n *\n * @module core_grades/grades/grader/gradingpanel/repository\n * @package core_grades\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\n/**\n * Normalise a resultset for consumption by the grader.\n *\n * @param {Object} result The result returned from a grading web service\n * @return {Object}\n */\nexport const normaliseResult = result => {\n return {\n result,\n failed: !!result.warnings.length,\n success: !result.warnings.length,\n error: null,\n };\n};\n\n/**\n * Return the resultset used to describe an invalid result.\n *\n * @return {Object}\n */\nexport const invalidResult = () => {\n return {\n success: false,\n failed: false,\n result: {},\n error: null,\n };\n};\n\n/**\n * Return the resultset used to describe a failed update.\n *\n * @param {Object} error\n * @return {Object}\n */\nexport const failedUpdate = error => {\n return {\n success: false,\n failed: true,\n result: {},\n error,\n };\n};\n"],"file":"normalise.min.js"}
\ No newline at end of file
diff --git a/grade/amd/build/grades/grader/gradingpanel/point.min.js b/grade/amd/build/grades/grader/gradingpanel/point.min.js
new file mode 100644
index 00000000000..e7096de2e68
--- /dev/null
+++ b/grade/amd/build/grades/grader/gradingpanel/point.min.js
@@ -0,0 +1,2 @@
+define ("core_grades/grades/grader/gradingpanel/point",["exports","./repository","jquery","./normalise"],function(a,b,c,d){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.storeCurrentGrade=a.fetchCurrentGrade=void 0;c=function(a){return a&&a.__esModule?a:{default:a}}(c);function e(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function f(a){return function(){var b=this,c=arguments;return new Promise(function(d,f){var i=a.apply(b,c);function g(a){e(i,d,f,g,h,"next",a)}function h(a){e(i,d,f,g,h,"throw",a)}g(void 0)})}}a.fetchCurrentGrade=function fetchCurrentGrade(){return(0,b.fetchGrade)("point").apply(void 0,arguments)};var g=function(){var a=f(regeneratorRuntime.mark(function a(e,f,g,h,i){var j,k;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:j=i.querySelector("form");k=j.querySelector("input[name=\"grade\"]");if(!(!k.checkValidity()||!k.value.trim())){a.next=4;break}return a.abrupt("return",d.invalidResult);case 4:a.next=6;return(0,b.saveGrade)("point")(e,f,g,h,(0,c.default)(j).serialize());case 6:return a.abrupt("return",a.sent);case 7:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}();a.storeCurrentGrade=g});
+//# sourceMappingURL=point.min.js.map
diff --git a/grade/amd/build/grades/grader/gradingpanel/point.min.js.map b/grade/amd/build/grades/grader/gradingpanel/point.min.js.map
new file mode 100644
index 00000000000..7bc16698a66
--- /dev/null
+++ b/grade/amd/build/grades/grader/gradingpanel/point.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../../src/grades/grader/gradingpanel/point.js"],"names":["fetchCurrentGrade","storeCurrentGrade","component","context","itemname","userId","rootNode","form","querySelector","grade","checkValidity","value","trim","invalidResult","serialize"],"mappings":"wOA0BA,uD,mVAaiC,QAApBA,CAAAA,iBAAoB,SAAa,iBAAW,OAAX,yBAAb,C,CAY1B,GAAMC,CAAAA,CAAiB,4CAAG,WAAMC,CAAN,CAAiBC,CAAjB,CAA0BC,CAA1B,CAAoCC,CAApC,CAA4CC,CAA5C,2FACvBC,CADuB,CAChBD,CAAQ,CAACE,aAAT,CAAuB,MAAvB,CADgB,CAEvBC,CAFuB,CAEfF,CAAI,CAACC,aAAL,CAAmB,uBAAnB,CAFe,MAIzB,CAACC,CAAK,CAACC,aAAN,EAAD,EAA0B,CAACD,CAAK,CAACE,KAAN,CAAYC,IAAZ,EAJF,2CAKlBC,eALkB,wBAQhB,gBAAU,OAAV,EAAmBX,CAAnB,CAA8BC,CAA9B,CAAuCC,CAAvC,CAAiDC,CAAjD,CAAyD,cAAOE,CAAP,EAAaO,SAAb,EAAzD,CARgB,iFAAH,uDAAvB,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Grading panel for simple direct grading.\n *\n * @module core_grades/grades/grader/gradingpanel/point\n * @package core_grades\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {saveGrade, fetchGrade} from './repository';\n// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send()\nimport jQuery from 'jquery';\nimport {invalidResult} from './normalise';\n\n/**\n * Fetch the current grade for a user.\n *\n * @param {String} component\n * @param {Number} context\n * @param {String} itemname\n * @param {Number} userId\n * @param {Element} rootNode\n * @return {Object}\n */\nexport const fetchCurrentGrade = (...args) => fetchGrade('point')(...args);\n\n/**\n * Store a new grade for a user.\n *\n * @param {String} component\n * @param {Number} context\n * @param {String} itemname\n * @param {Number} userId\n * @param {Element} rootNode\n * @return {Object}\n */\nexport const storeCurrentGrade = async(component, context, itemname, userId, rootNode) => {\n const form = rootNode.querySelector('form');\n const grade = form.querySelector('input[name=\"grade\"]');\n\n if (!grade.checkValidity() || !grade.value.trim()) {\n return invalidResult;\n }\n\n return await saveGrade('point')(component, context, itemname, userId, jQuery(form).serialize());\n};\n"],"file":"point.min.js"}
\ No newline at end of file
diff --git a/grade/amd/build/grades/grader/gradingpanel/repository.min.js b/grade/amd/build/grades/grader/gradingpanel/repository.min.js
new file mode 100644
index 00000000000..34faeffbf3b
--- /dev/null
+++ b/grade/amd/build/grades/grader/gradingpanel/repository.min.js
@@ -0,0 +1,2 @@
+define ("core_grades/grades/grader/gradingpanel/repository",["exports","core/ajax","./normalise"],function(a,b,c){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.saveGrade=a.fetchGrade=void 0;function d(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function e(a){return function(){var b=this,c=arguments;return new Promise(function(e,f){var i=a.apply(b,c);function g(a){d(i,e,f,g,h,"next",a)}function h(a){d(i,e,f,g,h,"throw",a)}g(void 0)})}}a.fetchGrade=function fetchGrade(a){return function(c,d,e,f){return(0,b.call)([{methodname:"core_grades_grader_gradingpanel_".concat(a,"_fetch"),args:{component:c,contextid:d,itemname:e,gradeduserid:f}}])[0]}};a.saveGrade=function saveGrade(a){return function(){var d=e(regeneratorRuntime.mark(function d(e,f,g,h,i){return regeneratorRuntime.wrap(function(d){while(1){switch(d.prev=d.next){case 0:d.t0=c.normaliseResult;d.next=3;return(0,b.call)([{methodname:"core_grades_grader_gradingpanel_".concat(a,"_store"),args:{component:e,contextid:f,itemname:g,gradeduserid:h,formdata:i}}])[0];case 3:d.t1=d.sent;return d.abrupt("return",(0,d.t0)(d.t1));case 5:case"end":return d.stop();}}},d)}));return function(){return d.apply(this,arguments)}}()}});
+//# sourceMappingURL=repository.min.js.map
diff --git a/grade/amd/build/grades/grader/gradingpanel/repository.min.js.map b/grade/amd/build/grades/grader/gradingpanel/repository.min.js.map
new file mode 100644
index 00000000000..2fd79381669
--- /dev/null
+++ b/grade/amd/build/grades/grader/gradingpanel/repository.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../../src/grades/grader/gradingpanel/repository.js"],"names":["fetchGrade","type","component","contextid","itemname","gradeduserid","methodname","args","saveGrade","formdata","normaliseResult"],"mappings":"4hBA0B0B,QAAbA,CAAAA,UAAa,CAAAC,CAAI,QAAI,UAACC,CAAD,CAAYC,CAAZ,CAAuBC,CAAvB,CAAiCC,CAAjC,CAAkD,CAChF,MAAO,WAAU,CAAC,CACdC,UAAU,2CAAqCL,CAArC,UADI,CAEdM,IAAI,CAAE,CACFL,SAAS,CAATA,CADE,CAEFC,SAAS,CAATA,CAFE,CAGFC,QAAQ,CAARA,CAHE,CAIFC,YAAY,CAAZA,CAJE,CAFQ,CAAD,CAAV,EAQH,CARG,CASV,CAV6B,C,aAYL,QAAZG,CAAAA,SAAY,CAAAP,CAAI,oDAAI,WAAMC,CAAN,CAAiBC,CAAjB,CAA4BC,CAA5B,CAAsCC,CAAtC,CAAoDI,CAApD,wFACtBC,iBADsB,gBACA,WAAU,CAAC,CACpCJ,UAAU,2CAAqCL,CAArC,UAD0B,CAEpCM,IAAI,CAAE,CACFL,SAAS,CAATA,CADE,CAEFC,SAAS,CAATA,CAFE,CAGFC,QAAQ,CAARA,CAHE,CAIFC,YAAY,CAAZA,CAJE,CAKFI,QAAQ,CAARA,CALE,CAF8B,CAAD,CAAV,EASzB,CATyB,CADA,qGAAJ,wD","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Repository for simple direct grading panel.\n *\n * @module core_grades/grades/grader/gradingpanel/repository\n * @package core_grades\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nimport {call as fetchMany} from 'core/ajax';\nimport {normaliseResult} from './normalise';\n\nexport const fetchGrade = type => (component, contextid, itemname, gradeduserid) => {\n return fetchMany([{\n methodname: `core_grades_grader_gradingpanel_${type}_fetch`,\n args: {\n component,\n contextid,\n itemname,\n gradeduserid,\n },\n }])[0];\n};\n\nexport const saveGrade = type => async(component, contextid, itemname, gradeduserid, formdata) => {\n return normaliseResult(await fetchMany([{\n methodname: `core_grades_grader_gradingpanel_${type}_store`,\n args: {\n component,\n contextid,\n itemname,\n gradeduserid,\n formdata,\n },\n }])[0]);\n};\n"],"file":"repository.min.js"}
\ No newline at end of file
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..edb9c077cc1
--- /dev/null
+++ b/grade/amd/build/grades/grader/gradingpanel/scale.min.js
@@ -0,0 +1,2 @@
+define ("core_grades/grades/grader/gradingpanel/scale",["exports","./repository","jquery","./normalise"],function(a,b,c,d){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.storeCurrentGrade=a.fetchCurrentGrade=void 0;c=function(a){return a&&a.__esModule?a:{default:a}}(c);a.fetchCurrentGrade=function fetchCurrentGrade(){return(0,b.fetchGrade)("scale").apply(void 0,arguments)};var e=function(a,e,f,g,h){var i=h.querySelector("form"),j=i.querySelector("select[name=\"grade\"]");if(!j.checkValidity()||!j.value.trim()){return d.invalidResult}return(0,b.saveGrade)("scale")(a,e,f,g,(0,c.default)(i).serialize())};a.storeCurrentGrade=e});
+//# sourceMappingURL=scale.min.js.map
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..7ceeb00a6fd
--- /dev/null
+++ b/grade/amd/build/grades/grader/gradingpanel/scale.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../../src/grades/grader/gradingpanel/scale.js"],"names":["fetchCurrentGrade","storeCurrentGrade","component","context","itemname","userId","rootNode","form","querySelector","grade","checkValidity","value","trim","invalidResult","serialize"],"mappings":"wOA0BA,uD,oBAGiC,QAApBA,CAAAA,iBAAoB,SAAa,iBAAW,OAAX,yBAAb,C,CAE1B,GAAMC,CAAAA,CAAiB,CAAG,SAACC,CAAD,CAAYC,CAAZ,CAAqBC,CAArB,CAA+BC,CAA/B,CAAuCC,CAAvC,CAAoD,IAC3EC,CAAAA,CAAI,CAAGD,CAAQ,CAACE,aAAT,CAAuB,MAAvB,CADoE,CAE3EC,CAAK,CAAGF,CAAI,CAACC,aAAL,CAAmB,wBAAnB,CAFmE,CAIjF,GAAI,CAACC,CAAK,CAACC,aAAN,EAAD,EAA0B,CAACD,CAAK,CAACE,KAAN,CAAYC,IAAZ,EAA/B,CAAmD,CAC/C,MAAOC,gBACV,CAED,MAAO,gBAAU,OAAV,EAAmBX,CAAnB,CAA8BC,CAA9B,CAAuCC,CAAvC,CAAiDC,CAAjD,CAAyD,cAAOE,CAAP,EAAaO,SAAb,EAAzD,CACV,CATM,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Grading panel for simple direct grading.\n *\n * @module core_grades/grades/grader/gradingpanel/scale\n * @package core_grades\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {saveGrade, fetchGrade} from './repository';\n// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send()\nimport jQuery from 'jquery';\nimport {invalidResult} from './normalise';\n\nexport const fetchCurrentGrade = (...args) => fetchGrade('scale')(...args);\n\nexport const storeCurrentGrade = (component, context, itemname, userId, rootNode) => {\n const form = rootNode.querySelector('form');\n const grade = form.querySelector('select[name=\"grade\"]');\n\n if (!grade.checkValidity() || !grade.value.trim()) {\n return invalidResult;\n }\n\n return saveGrade('scale')(component, context, itemname, userId, jQuery(form).serialize());\n};\n"],"file":"scale.min.js"}
\ No newline at end of file
diff --git a/grade/amd/src/grades/grader/gradingpanel/normalise.js b/grade/amd/src/grades/grader/gradingpanel/normalise.js
new file mode 100644
index 00000000000..2ac0bea1070
--- /dev/null
+++ b/grade/amd/src/grades/grader/gradingpanel/normalise.js
@@ -0,0 +1,67 @@
+// 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 .
+
+/**
+ * Repository for simple direct grading panel.
+ *
+ * @module core_grades/grades/grader/gradingpanel/repository
+ * @package core_grades
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Normalise a resultset for consumption by the grader.
+ *
+ * @param {Object} result The result returned from a grading web service
+ * @return {Object}
+ */
+export const normaliseResult = result => {
+ return {
+ result,
+ failed: !!result.warnings.length,
+ success: !result.warnings.length,
+ error: null,
+ };
+};
+
+/**
+ * Return the resultset used to describe an invalid result.
+ *
+ * @return {Object}
+ */
+export const invalidResult = () => {
+ return {
+ success: false,
+ failed: false,
+ result: {},
+ error: null,
+ };
+};
+
+/**
+ * Return the resultset used to describe a failed update.
+ *
+ * @param {Object} error
+ * @return {Object}
+ */
+export const failedUpdate = error => {
+ return {
+ success: false,
+ failed: true,
+ result: {},
+ error,
+ };
+};
diff --git a/grade/amd/src/grades/grader/gradingpanel/point.js b/grade/amd/src/grades/grader/gradingpanel/point.js
new file mode 100644
index 00000000000..5dea4dbf11f
--- /dev/null
+++ b/grade/amd/src/grades/grader/gradingpanel/point.js
@@ -0,0 +1,61 @@
+// 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/point
+ * @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';
+import {invalidResult} from './normalise';
+
+/**
+ * Fetch the current grade for a user.
+ *
+ * @param {String} component
+ * @param {Number} context
+ * @param {String} itemname
+ * @param {Number} userId
+ * @param {Element} rootNode
+ * @return {Object}
+ */
+export const fetchCurrentGrade = (...args) => fetchGrade('point')(...args);
+
+/**
+ * Store a new grade for a user.
+ *
+ * @param {String} component
+ * @param {Number} context
+ * @param {String} itemname
+ * @param {Number} userId
+ * @param {Element} rootNode
+ * @return {Object}
+ */
+export const storeCurrentGrade = async(component, context, itemname, userId, rootNode) => {
+ const form = rootNode.querySelector('form');
+ const grade = form.querySelector('input[name="grade"]');
+
+ if (!grade.checkValidity() || !grade.value.trim()) {
+ return invalidResult;
+ }
+
+ return await saveGrade('point')(component, context, itemname, userId, jQuery(form).serialize());
+};
diff --git a/grade/amd/src/grades/grader/gradingpanel/repository.js b/grade/amd/src/grades/grader/gradingpanel/repository.js
new file mode 100644
index 00000000000..c7a51bdfb49
--- /dev/null
+++ b/grade/amd/src/grades/grader/gradingpanel/repository.js
@@ -0,0 +1,50 @@
+// 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 .
+
+/**
+ * Repository for simple direct grading panel.
+ *
+ * @module core_grades/grades/grader/gradingpanel/repository
+ * @package core_grades
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+import {call as fetchMany} from 'core/ajax';
+import {normaliseResult} from './normalise';
+
+export const fetchGrade = type => (component, contextid, itemname, gradeduserid) => {
+ return fetchMany([{
+ methodname: `core_grades_grader_gradingpanel_${type}_fetch`,
+ args: {
+ component,
+ contextid,
+ itemname,
+ gradeduserid,
+ },
+ }])[0];
+};
+
+export const saveGrade = type => async(component, contextid, itemname, gradeduserid, formdata) => {
+ return normaliseResult(await fetchMany([{
+ methodname: `core_grades_grader_gradingpanel_${type}_store`,
+ args: {
+ component,
+ contextid,
+ itemname,
+ gradeduserid,
+ formdata,
+ },
+ }])[0]);
+};
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..3caca5ead0a
--- /dev/null
+++ b/grade/amd/src/grades/grader/gradingpanel/scale.js
@@ -0,0 +1,41 @@
+// 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';
+import {invalidResult} from './normalise';
+
+export const fetchCurrentGrade = (...args) => fetchGrade('scale')(...args);
+
+export const storeCurrentGrade = (component, context, itemname, userId, rootNode) => {
+ const form = rootNode.querySelector('form');
+ const grade = form.querySelector('select[name="grade"]');
+
+ if (!grade.checkValidity() || !grade.value.trim()) {
+ return invalidResult;
+ }
+
+ return saveGrade('scale')(component, context, itemname, userId, jQuery(form).serialize());
+};
diff --git a/grade/classes/component_gradeitem.php b/grade/classes/component_gradeitem.php
new file mode 100644
index 00000000000..a8f6b34b5f6
--- /dev/null
+++ b/grade/classes/component_gradeitem.php
@@ -0,0 +1,477 @@
+.
+
+/**
+ * Compontent definition of a gradeitem.
+ *
+ * @package core_grades
+ * @copyright Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_grades;
+
+use context;
+use gradingform_controller;
+use gradingform_instance;
+use moodle_exception;
+use stdClass;
+use grade_item as core_gradeitem;
+use grading_manager;
+
+/**
+ * Compontent definition of a gradeitem.
+ *
+ * @package core_grades
+ * @copyright Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class component_gradeitem {
+
+ /** @var array The scale data for the current grade item */
+ protected $scale;
+
+ /** @var string The component */
+ protected $component;
+
+ /** @var context The context for this activity */
+ protected $context;
+
+ /** @var string The item name */
+ protected $itemname;
+
+ /** @var int The grade itemnumber */
+ protected $itemnumber;
+
+ /**
+ * component_gradeitem constructor.
+ *
+ * @param string $component
+ * @param context $context
+ * @param string $itemname
+ * @throws \coding_exception
+ */
+ final protected function __construct(string $component, context $context, string $itemname) {
+ $this->component = $component;
+ $this->context = $context;
+ $this->itemname = $itemname;
+ $this->itemnumber = component_gradeitems::get_itemnumber_from_itemname($component, $itemname);
+ }
+
+ /**
+ * Fetch an instance of a specific component_gradeitem.
+ *
+ * @param string $component
+ * @param context $context
+ * @param string $itemname
+ * @return self
+ */
+ public static function instance(string $component, context $context, string $itemname): self {
+ $itemnumber = component_gradeitems::get_itemnumber_from_itemname($component, $itemname);
+
+ $classname = "{$component}\\grades\\{$itemname}_gradeitem";
+ if (!class_exists($classname)) {
+ throw new coding_exception("Unknown gradeitem {$itemname} for component {$classname}");
+ }
+
+ return $classname::load_from_context($context);
+ }
+
+ /**
+ * Load an instance of the current component_gradeitem based on context.
+ *
+ * @param context $context
+ * @return self
+ */
+ abstract public static function load_from_context(context $context): self;
+
+ /**
+ * The table name used for grading.
+ *
+ * @return string
+ */
+ 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.
+ *
+ * @return bool
+ */
+ abstract public function is_grading_enabled(): bool;
+
+ /**
+ * Get the grade value for this instance.
+ * The itemname is translated to the relevant grade field for the activity.
+ *
+ * @return int
+ */
+ abstract protected function get_gradeitem_value(): ?int;
+
+ /**
+ * Whether the grader can grade the gradee.
+ *
+ * @param stdClass $gradeduser The user being graded
+ * @param stdClass $grader The user who is grading
+ * @return bool
+ */
+ abstract public function user_can_grade(stdClass $gradeduser, stdClass $grader): bool;
+
+ /**
+ * Require that the user can grade, throwing an exception if not.
+ *
+ * @param stdClass $gradeduser The user being graded
+ * @param stdClass $grader The user who is grading
+ * @throws required_capability_exception
+ */
+ abstract public function require_user_can_grade(stdClass $gradeduser, stdClass $grader): void;
+
+ /**
+ * Get the scale if a scale is being used.
+ *
+ * @return stdClass
+ */
+ protected function get_scale(): ?stdClass {
+ global $DB;
+
+ $gradetype = $this->get_gradeitem_value();
+ if ($gradetype > 0) {
+ return null;
+ }
+
+ // This is a scale.
+ if (null === $this->scale) {
+ $this->scale = $DB->get_record('scale', ['id' => -1 * $gradetype]);
+ }
+
+ return $this->scale;
+ }
+
+ /**
+ * Check whether a scale is being used for this grade item.
+ *
+ * @return bool
+ */
+ public function is_using_scale(): bool {
+ $gradetype = $this->get_gradeitem_value();
+
+ return $gradetype < 0;
+ }
+
+ /**
+ * Whether this grade item is configured to use direct grading.
+ *
+ * @return bool
+ */
+ public function is_using_direct_grading(): bool {
+ if ($this->is_using_scale()) {
+ return false;
+ }
+
+ if ($this->get_advanced_grading_controller()) {
+ return false;
+ }
+
+ return true;
+ }
+
+ /**
+ * Whether this grade item is configured to use advanced grading.
+ *
+ * @return bool
+ */
+ public function is_using_advanced_grading(): bool {
+ if ($this->is_using_scale()) {
+ return false;
+ }
+
+ if ($this->get_advanced_grading_controller()) {
+ return true;
+ }
+
+ return false;
+ }
+
+ /**
+ * Get the name of the advanced grading method.
+ *
+ * @return string
+ */
+ public function get_advanced_grading_method(): ?string {
+ $gradingmanager = $this->get_grading_manager();
+
+ if (empty($gradingmanager)) {
+ return null;
+ }
+
+ return $gradingmanager->get_active_method();
+ }
+
+ /**
+ * Get the name of the component responsible for grading this gradeitem.
+ *
+ * @return string
+ */
+ public function get_grading_component_name(): ?string {
+ if (!$this->is_grading_enabled()) {
+ return null;
+ }
+
+ if ($method = $this->get_advanced_grading_method()) {
+ return "gradingform_{$method}";
+ }
+
+ return 'core_grades';
+ }
+
+ /**
+ * Get the name of the component subtype responsible for grading this gradeitem.
+ *
+ * @return string
+ */
+ public function get_grading_component_subtype(): ?string {
+ if (!$this->is_grading_enabled()) {
+ return null;
+ }
+
+ if ($method = $this->get_advanced_grading_method()) {
+ return null;
+ }
+
+ if ($this->is_using_scale()) {
+ return 'scale';
+ }
+
+ return 'point';
+ }
+
+ /**
+ * Whether decimals are allowed.
+ *
+ * @return bool
+ */
+ protected function allow_decimals(): bool {
+ return $this->get_gradeitem_value() > 0;
+ }
+
+ /**
+ * Get the grading manager for this advanced grading definition.
+ *
+ * @return grading_manager
+ */
+ protected function get_grading_manager(): ?grading_manager {
+ require_once(__DIR__ . '/../grading/lib.php');
+ return get_grading_manager($this->context, $this->component, $this->itemname);
+
+ }
+
+ /**
+ * Get the advanced grading controller if advanced grading is enabled.
+ *
+ * @return gradingform_controller
+ */
+ protected function get_advanced_grading_controller(): ?gradingform_controller {
+ $gradingmanager = $this->get_grading_manager();
+
+ if (empty($gradingmanager)) {
+ return null;
+ }
+
+ if ($gradingmethod = $gradingmanager->get_active_method()) {
+ return $gradingmanager->get_controller($gradingmethod);
+ }
+
+ return null;
+ }
+
+ /**
+ * Get the list of available grade items.
+ *
+ * @return array
+ */
+ public function get_grade_menu(): array {
+ return make_grades_menu($this->get_gradeitem_value());
+ }
+
+ /**
+ * Check whether the supplied grade is valid and throw an exception if not.
+ *
+ * @param float $grade The value being checked
+ * @throws moodle_exception
+ * @return bool
+ */
+ public function check_grade_validity(?float $grade): bool {
+ $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,
+ ]);
+ }
+ }
+ }
+
+ return true;
+ }
+
+ /**
+ * Create an empty row in the grade for the specified user and grader.
+ *
+ * @param stdClass $gradeduser The user being graded
+ * @param stdClass $grader The user who is grading
+ * @return stdClass The newly created grade record
+ */
+ abstract public function create_empty_grade(stdClass $gradeduser, stdClass $grader): stdClass;
+
+ /**
+ * Get the grade record for the specified grade id.
+ *
+ * @param int $gradeid
+ * @return stdClass
+ * @throws \dml_exception
+ */
+ public function get_grade(int $gradeid): stdClass {
+ global $DB;
+
+ $grade = $DB->get_record($this->get_table_name(), ['id' => $gradeid]);
+
+ return $grade ?: null;
+ }
+
+ /**
+ * Get the grade for the specified user.
+ *
+ * @param stdClass $gradeduser The user being graded
+ * @param stdClass $grader The user who is grading
+ * @return stdClass The grade value
+ */
+ abstract public function get_grade_for_user(stdClass $gradeduser, stdClass $grader): ?stdClass;
+
+ /**
+ * Get grades for all users for the specified gradeitem.
+ *
+ * @return stdClass[] The grades
+ */
+ abstract public function get_all_grades(): array;
+
+ /**
+ * Create or update the grade.
+ *
+ * @param stdClass $grade
+ * @return bool Success
+ */
+ abstract protected function store_grade(stdClass $grade): bool;
+
+ /**
+ * Create or update the grade.
+ *
+ * @param stdClass $gradeduser The user being graded
+ * @param stdClass $grader The user who is grading
+ * @param stdClass $formdata The data submitted
+ * @return bool Success
+ */
+ public function store_grade_from_formdata(stdClass $gradeduser, stdClass $grader, stdClass $formdata): bool {
+ // Require gradelib for grade_floatval.
+ require_once(__DIR__ . '/../../lib/gradelib.php');
+ $grade = $this->get_grade_for_user($gradeduser, $grader);
+
+ if ($this->is_using_advanced_grading()) {
+ $instanceid = $formdata->instanceid;
+ $gradinginstance = $this->get_advanced_grading_instance($grader, $grade, (int) $instanceid);
+ $grade->grade = $gradinginstance->submit_and_get_grade($formdata->advancedgrading, $grade->id);
+
+ if ($grade->grade == -1) {
+ // In advanced grading, a value of -1 means no data.
+ return false;
+ }
+ } else {
+ // Handle the case when grade is set to No Grade.
+ if (isset($formdata->grade)) {
+ $grade->grade = grade_floatval(unformat_float($formdata->grade));
+ }
+ }
+
+ return $this->store_grade($grade);
+ }
+
+ /**
+ * Get the advanced grading instance for the specified grade entry.
+ *
+ * @param stdClass $grader The user who is grading
+ * @param stdClass $grade The row from the grade table.
+ * @param int $instanceid The instanceid of the advanced grading form
+ * @return gradingform_instance
+ */
+ public function get_advanced_grading_instance(stdClass $grader, stdClass $grade, int $instanceid = null): ?gradingform_instance {
+ $controller = $this->get_advanced_grading_controller($this->itemname);
+
+ if (empty($controller)) {
+ // Advanced grading not enabeld for this item.
+ return null;
+ }
+
+ if (!$controller->is_form_available()) {
+ // The form is not available for this item.
+ return null;
+ }
+
+ // Fetch the instance for the specified graderid/itemid.
+ $gradinginstance = $controller->fetch_instance(
+ (int) $grader->id,
+ (int) $grade->id,
+ $instanceid
+ );
+
+ // Set the allowed grade range.
+ $gradinginstance->get_controller()->set_grade_range(
+ $this->get_grade_menu(),
+ $this->allow_decimals()
+ );
+
+ return $gradinginstance;
+ }
+}
diff --git a/grade/classes/component_gradeitems.php b/grade/classes/component_gradeitems.php
new file mode 100644
index 00000000000..daa260d3d9a
--- /dev/null
+++ b/grade/classes/component_gradeitems.php
@@ -0,0 +1,219 @@
+.
+
+/**
+ * Helper class to fetch information about component grade items.
+ *
+ * @package core_grades
+ * @copyright Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_grades;
+
+use code_grades\local\gradeitem\itemnumber_mapping;
+use code_grades\local\gradeitem\advancedgrading_mapping;
+
+/**
+ * Helper class to fetch information about component grade items.
+ *
+ * @package core_grades
+ * @copyright Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class component_gradeitems {
+
+ /**
+ * Get the gradeitems classname for the specific component.
+ *
+ * @param string $component The component to fetch the classname for
+ * @return string The composed classname
+ */
+ protected static function get_component_classname(string $component): string {
+ return "{$component}\\grades\gradeitems";
+ }
+
+ /**
+ * Get the grade itemnumber mapping for a component.
+ *
+ * @param string $component The component that the grade item belongs to
+ * @return array
+ */
+ public static function get_itemname_mapping_for_component(string $component): array {
+ $classname = "{$component}\\grades\gradeitems";
+
+ if (!class_exists($classname)) {
+ return [
+ 0 => '',
+ ];
+ }
+
+ if (!is_subclass_of($classname, 'core_grades\local\gradeitem\itemnumber_mapping')) {
+ throw new \coding_exception("The {$classname} class does not implement " . itemnumber_mapping::class);
+ }
+
+ return $classname::get_itemname_mapping_for_component();
+ }
+
+ /**
+ * Whether the named grading item exists.
+ *
+ * @param string $component
+ * @param string $itemname
+ * @return bool
+ */
+ public static function is_valid_itemname(string $component, string $itemname): bool {
+ $items = self::get_itemname_mapping_for_component($component);
+
+ return array_search($itemname, $items) !== false;
+ }
+
+ /**
+ * Check whether the component class defines the advanced grading items.
+ *
+ * @param string $component The component to check
+ * @return bool
+ */
+ public static function defines_advancedgrading_itemnames_for_component(string $component): bool {
+ return is_subclass_of(self::get_component_classname($component), 'core_grades\local\gradeitem\advancedgrading_mapping');
+ }
+
+ /**
+ * Get the list of advanced grading item names for the named component.
+ *
+ * @param string $component
+ * @return array
+ */
+ public static function get_advancedgrading_itemnames_for_component(string $component): array {
+ $classname = self::get_component_classname($component);
+ if (!self::defines_advancedgrading_itemnames_for_component($component)) {
+ throw new \coding_exception("The {$classname} class does not implement " . advancedgrading_mapping::class);
+ }
+
+ return $classname::get_advancedgrading_itemnames();
+ }
+
+ /**
+ * Whether the named grading item name supports advanced grading.
+ *
+ * @param string $component
+ * @param string $itemname
+ * @return bool
+ */
+ public static function is_advancedgrading_itemname(string $component, string $itemname): bool {
+ $gradingareas = self::get_advancedgrading_itemnames_for_component($component);
+
+ return array_search($itemname, $gradingareas) !== false;
+ }
+
+ /**
+ * Get the suffixed field name for an activity field mapped from its itemnumber.
+ *
+ * For legacy reasons, the first itemnumber has no suffix on field names.
+ *
+ * @param string $component The component that the grade item belongs to
+ * @param int $itemnumber The grade itemnumber
+ * @param string $fieldname The name of the field to be rewritten
+ * @return string The translated field name
+ */
+ public static function get_field_name_for_itemnumber(string $component, int $itemnumber, string $fieldname): string {
+ $itemname = static::get_itemname_from_itemnumber($component, $itemnumber);
+
+ if ($itemname) {
+ return "{$fieldname}_{$itemname}";
+ }
+
+ return $fieldname;
+ }
+
+ /**
+ * Get the suffixed field name for an activity field mapped from its itemnumber.
+ *
+ * For legacy reasons, the first itemnumber has no suffix on field names.
+ *
+ * @param string $component The component that the grade item belongs to
+ * @param string $itemname The grade itemname
+ * @param string $fieldname The name of the field to be rewritten
+ * @return string The translated field name
+ */
+ public static function get_field_name_for_itemname(string $component, string $itemname, string $fieldname): string {
+ if (empty($itemname)) {
+ return $fieldname;
+ }
+
+ $itemnumber = static::get_itemnumber_from_itemname($component, $itemname);
+
+ if ($itemnumber > 0) {
+ return "{$fieldname}_{$itemname}";
+ }
+
+ return $fieldname;
+ }
+
+ /**
+ * Get the itemname for an itemnumber.
+ *
+ * For legacy compatability when the itemnumber is 0, the itemname will always be empty.
+ *
+ * @param string $component The component that the grade item belongs to
+ * @param int $itemnumber The grade itemnumber
+ * @return int The grade itemnumber of the itemname
+ */
+ public static function get_itemname_from_itemnumber(string $component, int $itemnumber): string {
+ if ($itemnumber === 0) {
+ return '';
+ }
+
+ $mappings = self::get_itemname_mapping_for_component($component);
+
+ if (isset($mappings[$itemnumber])) {
+ return $mappings[$itemnumber];
+ }
+
+ if ($itemnumber >= 1000) {
+ // An itemnumber >= 1000 belongs to an outcome.
+ return '';
+ }
+
+ throw new \coding_exception("Unknown itemnumber mapping for {$itemnumber} in {$component}");
+ }
+
+ /**
+ * Get the itemnumber for a item name.
+ *
+ * For legacy compatability when the itemname is empty, the itemnumber will always be 0.
+ *
+ * @param string $component The component that the grade item belongs to
+ * @param string $itemname The grade itemname
+ * @return int The grade itemname of the itemnumber
+ */
+ public static function get_itemnumber_from_itemname(string $component, string $itemname): int {
+ if (empty($itemname)) {
+ return 0;
+ }
+
+ $mappings = self::get_itemname_mapping_for_component($component);
+
+ $flipped = array_flip($mappings);
+ if (isset($flipped[$itemname])) {
+ return $flipped[$itemname];
+ }
+
+ throw new \coding_exception("Unknown itemnumber mapping for {$itemname} in {$component}");
+ }
+}
diff --git a/grade/classes/grades/grader/gradingpanel/point/external/fetch.php b/grade/classes/grades/grader/gradingpanel/point/external/fetch.php
new file mode 100644
index 00000000000..b8332e51988
--- /dev/null
+++ b/grade/classes/grades/grader/gradingpanel/point/external/fetch.php
@@ -0,0 +1,172 @@
+.
+
+/**
+ * Web service functions relating to point 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\point\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;
+use stdClass;
+
+/**
+ * External grading panel point 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
+ * @throws \dml_exception
+ * @throws \invalid_parameter_exception
+ * @throws \restricted_context_exception
+ * @throws coding_exception
+ * @throws moodle_exception
+ * @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_direct_grading()) {
+ throw new moodle_exception("The {$itemname} item in {$component}/{$contextid} is not configured for direct grading");
+ }
+
+ // Fetch the actual data.
+ $gradeduser = \core_user::get_user($gradeduserid);
+ $grade = $gradeitem->get_grade_for_user($gradeduser, $USER);
+
+ return self::get_fetch_data($grade);
+ }
+
+ /**
+ * Get the data to be fetched.
+ *
+ * @param stdClass $grade
+ * @return array
+ */
+ public static function get_fetch_data(stdClass $grade): array {
+ return [
+ 'templatename' => 'core_grades/grades/grader/gradingpanel/point',
+ 'grade' => [
+ 'grade' => $grade->grade,
+ '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([
+ 'grade' => new external_value(PARAM_FLOAT, 'The numeric grade'),
+ '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/point/external/store.php b/grade/classes/grades/grader/gradingpanel/point/external/store.php
new file mode 100644
index 00000000000..3649e5382a8
--- /dev/null
+++ b/grade/classes/grades/grader/gradingpanel/point/external/store.php
@@ -0,0 +1,170 @@
+.
+
+/**
+ * Web service functions relating to point 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\point\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 point 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
+ * @param string $formdata
+ * @return array
+ * @throws \dml_exception
+ * @throws \invalid_parameter_exception
+ * @throws \restricted_context_exception
+ * @throws coding_exception
+ * @throws moodle_exception
+ * @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_direct_grading()) {
+ throw new moodle_exception("The {$itemname} item in {$component}/{$contextid} is not configured for direct grading");
+ }
+
+ // Parse the serialised string into an object.
+ $data = [];
+ parse_str($formdata, $data);
+
+ // Grade.
+ $gradeitem->store_grade_from_formdata($gradeduser, $USER, (object) $data);
+
+ // Fetch the updated grade back out.
+ $grade = $gradeitem->get_grade_for_user($gradeduser, $USER);
+
+ return fetch::get_fetch_data($grade);
+ }
+
+ /**
+ * 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/classes/grades/grader/gradingpanel/scale/external/fetch.php b/grade/classes/grades/grader/gradingpanel/scale/external/fetch.php
new file mode 100644
index 00000000000..dfd9acd38a9
--- /dev/null
+++ b/grade/classes/grades/grader/gradingpanel/scale/external/fetch.php
@@ -0,0 +1,192 @@
+.
+
+/**
+ * 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
+ * @throws \dml_exception
+ * @throws \invalid_parameter_exception
+ * @throws \restricted_context_exception
+ * @throws coding_exception
+ * @throws moodle_exception
+ * @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 gradeitem $gradeitem
+ * @param stdClass $gradeduser
+ * @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..6c7fa5b8427
--- /dev/null
+++ b/grade/classes/grades/grader/gradingpanel/scale/external/store.php
@@ -0,0 +1,167 @@
+.
+
+/**
+ * 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
+ * @param string $formdata
+ * @return array
+ * @throws \dml_exception
+ * @throws \invalid_parameter_exception
+ * @throws \restricted_context_exception
+ * @throws coding_exception
+ * @throws moodle_exception
+ * @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/classes/local/gradeitem/advancedgrading_mapping.php b/grade/classes/local/gradeitem/advancedgrading_mapping.php
new file mode 100644
index 00000000000..104ccfb5ad1
--- /dev/null
+++ b/grade/classes/local/gradeitem/advancedgrading_mapping.php
@@ -0,0 +1,43 @@
+.
+
+/**
+ * Grade item, itemnumber mapping.
+ *
+ * @package core_grades
+ * @copyright Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_grades\local\gradeitem;
+
+/**
+ * Grade item, itemnumber mapping.
+ *
+ * @package core_grades
+ * @copyright Andrew Nicols
+ */
+interface advancedgrading_mapping {
+
+ /**
+ * Get the list of advanced grading item names for this component.
+ *
+ * @return array
+ */
+ public static function get_advancedgrading_itemnames(): array;
+}
diff --git a/grade/classes/local/gradeitem/itemnumber_mapping.php b/grade/classes/local/gradeitem/itemnumber_mapping.php
new file mode 100644
index 00000000000..5db9726a24a
--- /dev/null
+++ b/grade/classes/local/gradeitem/itemnumber_mapping.php
@@ -0,0 +1,43 @@
+.
+
+/**
+ * Grade item, itemnumber mapping.
+ *
+ * @package core_grades
+ * @copyright Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace core_grades\local\gradeitem;
+
+/**
+ * Grade item, itemnumber mapping.
+ *
+ * @package core_grades
+ * @copyright Andrew Nicols
+ */
+interface itemnumber_mapping {
+
+ /**
+ * Get the grade item mapping of item number to item name.
+ *
+ * @return array
+ */
+ public static function get_itemname_mapping_for_component(): array;
+}
diff --git a/grade/grading/form/guide/amd/build/grades/grader/gradingpanel.min.js b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel.min.js
new file mode 100644
index 00000000000..4ef79c5b3e3
--- /dev/null
+++ b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel.min.js
@@ -0,0 +1,2 @@
+define ("gradingform_guide/grades/grader/gradingpanel",["exports","core/ajax","core_grades/grades/grader/gradingpanel/normalise","jquery"],function(a,b,c,d){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.storeCurrentGrade=a.fetchCurrentGrade=void 0;d=function(a){return a&&a.__esModule?a:{default:a}}(d);function e(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function f(a){return function(){var b=this,c=arguments;return new Promise(function(d,f){var i=a.apply(b,c);function g(a){e(i,d,f,g,h,"next",a)}function h(a){e(i,d,f,g,h,"throw",a)}g(void 0)})}}a.fetchCurrentGrade=function fetchCurrentGrade(a,c,d,e){return(0,b.call)([{methodname:"gradingform_guide_grader_gradingpanel_fetch",args:{component:a,contextid:c,itemname:d,gradeduserid:e}}])[0]};var g=function(){var a=f(regeneratorRuntime.mark(function a(e,f,g,h,i){var j;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:j=i.querySelector("form");a.t0=c.normaliseResult;a.next=4;return(0,b.call)([{methodname:"gradingform_guide_grader_gradingpanel_store",args:{component:e,contextid:f,itemname:g,gradeduserid:h,formdata:(0,d.default)(j).serialize()}}])[0];case 4:a.t1=a.sent;return a.abrupt("return",(0,a.t0)(a.t1));case 6:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}();a.storeCurrentGrade=g});
+//# sourceMappingURL=gradingpanel.min.js.map
diff --git a/grade/grading/form/guide/amd/build/grades/grader/gradingpanel.min.js.map b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel.min.js.map
new file mode 100644
index 00000000000..93c16158ddc
--- /dev/null
+++ b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../src/grades/grader/gradingpanel.js"],"names":["fetchCurrentGrade","component","contextid","itemname","gradeduserid","methodname","args","storeCurrentGrade","rootNode","form","querySelector","normaliseResult","formdata","serialize"],"mappings":"0QA4BA,uD,mVAYiC,QAApBA,CAAAA,iBAAoB,CAACC,CAAD,CAAYC,CAAZ,CAAuBC,CAAvB,CAAiCC,CAAjC,CAAkD,CAC/E,MAAO,WAAU,CAAC,CACdC,UAAU,8CADI,CAEdC,IAAI,CAAE,CACFL,SAAS,CAATA,CADE,CAEFC,SAAS,CAATA,CAFE,CAGFC,QAAQ,CAARA,CAHE,CAIFC,YAAY,CAAZA,CAJE,CAFQ,CAAD,CAAV,EAQH,CARG,CASV,C,CAaM,GAAMG,CAAAA,CAAiB,4CAAG,WAAMN,CAAN,CAAiBC,CAAjB,CAA4BC,CAA5B,CAAsCC,CAAtC,CAAoDI,CAApD,yFACvBC,CADuB,CAChBD,CAAQ,CAACE,aAAT,CAAuB,MAAvB,CADgB,MAGtBC,iBAHsB,gBAGA,WAAU,CAAC,CACpCN,UAAU,8CAD0B,CAEpCC,IAAI,CAAE,CACFL,SAAS,CAATA,CADE,CAEFC,SAAS,CAATA,CAFE,CAGFC,QAAQ,CAARA,CAHE,CAIFC,YAAY,CAAZA,CAJE,CAKFQ,QAAQ,CAAE,cAAOH,CAAP,EAAaI,SAAb,EALR,CAF8B,CAAD,CAAV,EASzB,CATyB,CAHA,qGAAH,uDAAvB,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Grading panel for gradingform_guide.\n *\n * @module gradingform_guide/grades/grader/gradingpanel\n * @package gradingform_guide\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport {normaliseResult} from 'core_grades/grades/grader/gradingpanel/normalise';\n\n// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send()\nimport jQuery from 'jquery';\n\n/**\n * For a given component, contextid, itemname & gradeduserid we can fetch the currently assigned grade.\n *\n * @param {String} component\n * @param {Number} contextid\n * @param {String} itemname\n * @param {Number} gradeduserid\n *\n * @returns {Promise}\n */\nexport const fetchCurrentGrade = (component, contextid, itemname, gradeduserid) => {\n return fetchMany([{\n methodname: `gradingform_guide_grader_gradingpanel_fetch`,\n args: {\n component,\n contextid,\n itemname,\n gradeduserid,\n },\n }])[0];\n};\n\n/**\n * For a given component, contextid, itemname & gradeduserid we can store the currently assigned grade in a given form.\n *\n * @param {String} component\n * @param {Number} contextid\n * @param {String} itemname\n * @param {Number} gradeduserid\n * @param {HTMLElement} rootNode\n *\n * @returns {Promise}\n */\nexport const storeCurrentGrade = async(component, contextid, itemname, gradeduserid, rootNode) => {\n const form = rootNode.querySelector('form');\n\n return normaliseResult(await fetchMany([{\n methodname: `gradingform_guide_grader_gradingpanel_store`,\n args: {\n component,\n contextid,\n itemname,\n gradeduserid,\n formdata: jQuery(form).serialize(),\n },\n }])[0]);\n};\n"],"file":"gradingpanel.min.js"}
\ No newline at end of file
diff --git a/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments.min.js b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments.min.js
new file mode 100644
index 00000000000..de22d4d0182
--- /dev/null
+++ b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments.min.js
@@ -0,0 +1,2 @@
+define ("gradingform_guide/grades/grader/gradingpanel/comments",["exports","./comments/selectors"],function(a,b){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.init=void 0;b=function(a){return a&&a.__esModule?a:{default:a}}(b);a.init=function init(a){var c=document.querySelector("#".concat(a));c.addEventListener("click",function(a){if(!a.target.matches(b.default.frequentComment)){return}a.preventDefault();var c=a.target.closest(b.default.frequentComment),d=c.closest(b.default.criterion),e=d.querySelector(b.default.remark);if(!e){return}if(e.value.trim()){e.value+="\n".concat(c.innerHTML)}else{e.value+=c.innerHTML}})}});
+//# sourceMappingURL=comments.min.js.map
diff --git a/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments.min.js.map b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments.min.js.map
new file mode 100644
index 00000000000..5f600058c5c
--- /dev/null
+++ b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../../src/grades/grader/gradingpanel/comments.js"],"names":["init","rootId","rootNode","document","querySelector","addEventListener","e","target","matches","Selectors","frequentComment","preventDefault","clicked","closest","criterion","remark","value","trim","innerHTML"],"mappings":"6LAwBA,uD,OAOoB,QAAPA,CAAAA,IAAO,CAACC,CAAD,CAAY,CAC5B,GAAMC,CAAAA,CAAQ,CAAGC,QAAQ,CAACC,aAAT,YAA2BH,CAA3B,EAAjB,CAEAC,CAAQ,CAACG,gBAAT,CAA0B,OAA1B,CAAmC,SAACC,CAAD,CAAO,CACtC,GAAI,CAACA,CAAC,CAACC,MAAF,CAASC,OAAT,CAAiBC,UAAUC,eAA3B,CAAL,CAAkD,CAC9C,MACH,CAEDJ,CAAC,CAACK,cAAF,GALsC,GAOhCC,CAAAA,CAAO,CAAGN,CAAC,CAACC,MAAF,CAASM,OAAT,CAAiBJ,UAAUC,eAA3B,CAPsB,CAQhCI,CAAS,CAAGF,CAAO,CAACC,OAAR,CAAgBJ,UAAUK,SAA1B,CARoB,CAShCC,CAAM,CAAGD,CAAS,CAACV,aAAV,CAAwBK,UAAUM,MAAlC,CATuB,CAWtC,GAAI,CAACA,CAAL,CAAa,CACT,MACH,CAGD,GAAIA,CAAM,CAACC,KAAP,CAAaC,IAAb,EAAJ,CAAyB,CACrBF,CAAM,CAACC,KAAP,cAAqBJ,CAAO,CAACM,SAA7B,CACH,CAFD,IAEO,CACHH,CAAM,CAACC,KAAP,EAAgBJ,CAAO,CAACM,SAC3B,CACJ,CArBD,CAsBH,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Grading panel frequently used comments selector.\n *\n * @module gradingform_guide/grades/grader/gradingpanel/comments\n * @package gradingform_guide\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport Selectors from './comments/selectors';\n\n/**\n * Manage the frequently used comments in the Marking Guide form.\n *\n * @param {String} rootId\n */\nexport const init = (rootId) => {\n const rootNode = document.querySelector(`#${rootId}`);\n\n rootNode.addEventListener('click', (e) => {\n if (!e.target.matches(Selectors.frequentComment)) {\n return;\n }\n\n e.preventDefault();\n\n const clicked = e.target.closest(Selectors.frequentComment);\n const criterion = clicked.closest(Selectors.criterion);\n const remark = criterion.querySelector(Selectors.remark);\n\n if (!remark) {\n return;\n }\n\n // Either append the comment to an existing comment or set it as the comment.\n if (remark.value.trim()) {\n remark.value += `\\n${clicked.innerHTML}`;\n } else {\n remark.value += clicked.innerHTML;\n }\n });\n};\n"],"file":"comments.min.js"}
\ No newline at end of file
diff --git a/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments/selectors.min.js b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments/selectors.min.js
new file mode 100644
index 00000000000..d325a53f68d
--- /dev/null
+++ b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments/selectors.min.js
@@ -0,0 +1,2 @@
+define ("gradingform_guide/grades/grader/gradingpanel/comments/selectors",["exports"],function(a){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.default=void 0;a.default={frequentComment:"[data-gradingform_guide-role=\"frequent-comment\"]",criterion:"[data-gradingform-guide-role=\"criterion\"]",remark:"[data-gradingform-guide-role=\"remark\"]"};return a.default});
+//# sourceMappingURL=selectors.min.js.map
diff --git a/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments/selectors.min.js.map b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments/selectors.min.js.map
new file mode 100644
index 00000000000..539a41d8769
--- /dev/null
+++ b/grade/grading/form/guide/amd/build/grades/grader/gradingpanel/comments/selectors.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../../../src/grades/grader/gradingpanel/comments/selectors.js"],"names":["frequentComment","criterion","remark"],"mappings":"2LAuBe,CACXA,eAAe,CAAE,oDADN,CAEXC,SAAS,CAAE,6CAFA,CAGXC,MAAM,CAAE,0CAHG,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Define all of the selectors we will be using on the Marking Guide interface.\n *\n * @module gradingform_guide/grades/grader/gradingpanel/comments/selectors\n * @package gradingform_guide\n * @copyright 2019 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\nexport default {\n frequentComment: '[data-gradingform_guide-role=\"frequent-comment\"]',\n criterion: '[data-gradingform-guide-role=\"criterion\"]',\n remark: '[data-gradingform-guide-role=\"remark\"]',\n};\n"],"file":"selectors.min.js"}
\ No newline at end of file
diff --git a/grade/grading/form/guide/amd/src/grades/grader/gradingpanel.js b/grade/grading/form/guide/amd/src/grades/grader/gradingpanel.js
new file mode 100644
index 00000000000..5f0860860ed
--- /dev/null
+++ b/grade/grading/form/guide/amd/src/grades/grader/gradingpanel.js
@@ -0,0 +1,77 @@
+// 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 gradingform_guide.
+ *
+ * @module gradingform_guide/grades/grader/gradingpanel
+ * @package gradingform_guide
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import {call as fetchMany} from 'core/ajax';
+import {normaliseResult} from 'core_grades/grades/grader/gradingpanel/normalise';
+
+// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send()
+import jQuery from 'jquery';
+
+/**
+ * For a given component, contextid, itemname & gradeduserid we can fetch the currently assigned grade.
+ *
+ * @param {String} component
+ * @param {Number} contextid
+ * @param {String} itemname
+ * @param {Number} gradeduserid
+ *
+ * @returns {Promise}
+ */
+export const fetchCurrentGrade = (component, contextid, itemname, gradeduserid) => {
+ return fetchMany([{
+ methodname: `gradingform_guide_grader_gradingpanel_fetch`,
+ args: {
+ component,
+ contextid,
+ itemname,
+ gradeduserid,
+ },
+ }])[0];
+};
+
+/**
+ * For a given component, contextid, itemname & gradeduserid we can store the currently assigned grade in a given form.
+ *
+ * @param {String} component
+ * @param {Number} contextid
+ * @param {String} itemname
+ * @param {Number} gradeduserid
+ * @param {HTMLElement} rootNode
+ *
+ * @returns {Promise}
+ */
+export const storeCurrentGrade = async(component, contextid, itemname, gradeduserid, rootNode) => {
+ const form = rootNode.querySelector('form');
+
+ return normaliseResult(await fetchMany([{
+ methodname: `gradingform_guide_grader_gradingpanel_store`,
+ args: {
+ component,
+ contextid,
+ itemname,
+ gradeduserid,
+ formdata: jQuery(form).serialize(),
+ },
+ }])[0]);
+};
diff --git a/grade/grading/form/guide/amd/src/grades/grader/gradingpanel/comments.js b/grade/grading/form/guide/amd/src/grades/grader/gradingpanel/comments.js
new file mode 100644
index 00000000000..c2cbfb58d6d
--- /dev/null
+++ b/grade/grading/form/guide/amd/src/grades/grader/gradingpanel/comments.js
@@ -0,0 +1,57 @@
+// 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 frequently used comments selector.
+ *
+ * @module gradingform_guide/grades/grader/gradingpanel/comments
+ * @package gradingform_guide
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import Selectors from './comments/selectors';
+
+/**
+ * Manage the frequently used comments in the Marking Guide form.
+ *
+ * @param {String} rootId
+ */
+export const init = (rootId) => {
+ const rootNode = document.querySelector(`#${rootId}`);
+
+ rootNode.addEventListener('click', (e) => {
+ if (!e.target.matches(Selectors.frequentComment)) {
+ return;
+ }
+
+ e.preventDefault();
+
+ const clicked = e.target.closest(Selectors.frequentComment);
+ const criterion = clicked.closest(Selectors.criterion);
+ const remark = criterion.querySelector(Selectors.remark);
+
+ if (!remark) {
+ return;
+ }
+
+ // Either append the comment to an existing comment or set it as the comment.
+ if (remark.value.trim()) {
+ remark.value += `\n${clicked.innerHTML}`;
+ } else {
+ remark.value += clicked.innerHTML;
+ }
+ });
+};
diff --git a/grade/grading/form/guide/amd/src/grades/grader/gradingpanel/comments/selectors.js b/grade/grading/form/guide/amd/src/grades/grader/gradingpanel/comments/selectors.js
new file mode 100644
index 00000000000..45bd89f4417
--- /dev/null
+++ b/grade/grading/form/guide/amd/src/grades/grader/gradingpanel/comments/selectors.js
@@ -0,0 +1,28 @@
+// 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 .
+
+/**
+ * Define all of the selectors we will be using on the Marking Guide interface.
+ *
+ * @module gradingform_guide/grades/grader/gradingpanel/comments/selectors
+ * @package gradingform_guide
+ * @copyright 2019 Mathew May
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+export default {
+ frequentComment: '[data-gradingform_guide-role="frequent-comment"]',
+ criterion: '[data-gradingform-guide-role="criterion"]',
+ remark: '[data-gradingform-guide-role="remark"]',
+};
diff --git a/grade/grading/form/guide/classes/grades/grader/gradingpanel/external/fetch.php b/grade/grading/form/guide/classes/grades/grader/gradingpanel/external/fetch.php
new file mode 100644
index 00000000000..fe341825f37
--- /dev/null
+++ b/grade/grading/form/guide/classes/grades/grader/gradingpanel/external/fetch.php
@@ -0,0 +1,292 @@
+.
+
+/**
+ * Web services relating to fetching of a marking guide for the grading panel.
+ *
+ * @package gradingform_guide
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace gradingform_guide\grades\grader\gradingpanel\external;
+
+global $CFG;
+
+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_format_value;
+use external_function_parameters;
+use external_multiple_structure;
+use external_single_structure;
+use external_value;
+use external_warnings;
+use moodle_exception;
+use stdClass;
+require_once($CFG->dirroot.'/grade/grading/form/guide/lib.php');
+
+/**
+ * Web services relating to fetching of a marking guide for the grading panel.
+ *
+ * @package gradingform_guide
+ * @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
+ * @throws \dml_exception
+ * @throws \invalid_parameter_exception
+ * @throws \restricted_context_exception
+ * @throws coding_exception
+ * @throws moodle_exception
+ * @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 (MARKING_GUIDE !== $gradeitem->get_advanced_grading_method()) {
+ throw new moodle_exception(
+ "The {$itemname} item in {$component}/{$contextid} is not configured for advanced grading with a marking guide"
+ );
+ }
+
+ // Fetch the actual data.
+ $gradeduser = core_user::get_user($gradeduserid);
+
+ return self::get_fetch_data($gradeitem, $gradeduser);
+ }
+
+ /**
+ * Get the data to be fetched.
+ *
+ * @param gradeitem $gradeitem
+ * @param stdClass $gradeduser
+ * @return array
+ */
+ public static function get_fetch_data(gradeitem $gradeitem, stdClass $gradeduser): array {
+ global $USER;
+
+ $grade = $gradeitem->get_grade_for_user($gradeduser, $USER);
+ $instance = $gradeitem->get_advanced_grading_instance($USER, $grade);
+ $controller = $instance->get_controller();
+ $definition = $controller->get_definition();
+ $fillings = $instance->get_guide_filling();
+ $context = $controller->get_context();
+ $definitionid = (int) $definition->id;
+
+ $criterion = [];
+ if ($definition->guide_criteria) {
+ $criterion = array_map(function($criterion) use ($definitionid, $fillings, $context) {
+ $result = [
+ 'id' => $criterion['id'],
+ 'name' => $criterion['shortname'],
+ 'maxscore' => $criterion['maxscore'],
+ 'description' => self::get_formatted_text(
+ $context,
+ $definitionid,
+ 'description',
+ $criterion['description'],
+ (int) $criterion['descriptionformat']
+ ),
+ 'descriptionmarkers' => self::get_formatted_text(
+ $context,
+ $definitionid,
+ 'descriptionmarkers',
+ $criterion['descriptionmarkers'],
+ (int) $criterion['descriptionmarkersformat']
+ ),
+ 'score' => null,
+ 'remark' => null,
+ ];
+
+ if (array_key_exists($criterion['id'], $fillings['criteria'])) {
+ $filling = $fillings['criteria'][$criterion['id']];
+
+ $result['score'] = $filling['score'];
+ $result['remark'] = self::get_formatted_text(
+ $context,
+ $definitionid,
+ 'remark',
+ $filling['remark'],
+ (int) $filling['remarkformat']
+ );
+ }
+
+ return $result;
+ }, $definition->guide_criteria);
+ }
+
+ $comments = [];
+ if ($definition->guide_comments) {
+ $comments = array_map(function($comment) use ($definitionid, $context) {
+ return [
+ 'id' => $comment['id'],
+ 'sortorder' => $comment['sortorder'],
+ 'description' => self::get_formatted_text(
+ $context,
+ $definitionid,
+ 'description',
+ $comment['description'],
+ (int) $comment['descriptionformat']
+ ),
+ ];
+ }, $definition->guide_comments);
+ }
+
+ return [
+ 'templatename' => 'gradingform_guide/grades/grader/gradingpanel',
+ 'grade' => [
+ 'instanceid' => $instance->get_id(),
+ 'criterion' => $criterion,
+ 'hascomments' => !empty($comments),
+ 'comments' => $comments,
+ '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([
+ 'instanceid' => new external_value(PARAM_INT, 'The id of the current grading instance'),
+ 'criterion' => new external_multiple_structure(
+ new external_single_structure([
+ 'id' => new external_value(PARAM_INT, 'The id of the criterion'),
+ 'name' => new external_value(PARAM_RAW, 'The name of the criterion'),
+ 'maxscore' => new external_value(PARAM_FLOAT, 'The maximum score for this criterion'),
+ 'description' => new external_value(PARAM_RAW, 'The description of the criterion'),
+ 'descriptionmarkers' => new external_value(PARAM_RAW, 'The description of the criterion for markers'),
+ 'score' => new external_value(PARAM_FLOAT, 'The current score for user being assessed', VALUE_OPTIONAL),
+ 'remark' => new external_value(PARAM_RAW, 'Any remarks for this criterion for the user being assessed', VALUE_OPTIONAL),
+ ]),
+ 'The criterion by which this item will be graded'
+ ),
+ 'hascomments' => new external_value(PARAM_BOOL, 'Whether there are any frequently-used comments'),
+ 'comments' => new external_multiple_structure(
+ new external_single_structure([
+ 'id' => new external_value(PARAM_INT, 'Comment id'),
+ 'sortorder' => new external_value(PARAM_INT, 'The sortorder of this comment'),
+ 'description' => new external_value(PARAM_RAW, 'The comment value'),
+ ]),
+ 'Frequently used comments'
+ ),
+ '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(),
+ ]);
+ }
+
+ /**
+ * Get a formatted version of the remark/description/etc.
+ *
+ * @param context $context
+ * @param int $definitionid
+ * @param string $filearea The file area of the field
+ * @param string $text The text to be formatted
+ * @param int $format The input format of the string
+ * @return string
+ */
+ protected static function get_formatted_text(context $context, int $definitionid, string $filearea, string $text, int $format): string {
+ $formatoptions = [
+ 'noclean' => false,
+ 'trusted' => false,
+ 'filter' => true,
+ ];
+
+ [$newtext, ] = external_format_text($text, $format, $context, 'grading', $filearea, $definitionid, $formatoptions);
+
+ return $newtext;
+ }
+}
diff --git a/grade/grading/form/guide/classes/grades/grader/gradingpanel/external/store.php b/grade/grading/form/guide/classes/grades/grader/gradingpanel/external/store.php
new file mode 100644
index 00000000000..f1300e0e2ba
--- /dev/null
+++ b/grade/grading/form/guide/classes/grades/grader/gradingpanel/external/store.php
@@ -0,0 +1,169 @@
+.
+
+/**
+ * Web services relating to fetching of a marking guide for the grading panel.
+ *
+ * @package gradingform_guide
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace gradingform_guide\grades\grader\gradingpanel\external;
+
+global $CFG;
+
+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_single_structure;
+use external_value;
+use moodle_exception;
+require_once($CFG->dirroot.'/grade/grading/form/guide/lib.php');
+
+/**
+ * Web services relating to storing of a marking guide for the grading panel.
+ *
+ * @package gradingform_guide
+ * @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 storing 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
+ * @param string $formdata
+ * @return array
+ * @throws \dml_exception
+ * @throws \invalid_parameter_exception
+ * @throws \restricted_context_exception
+ * @throws coding_exception
+ * @throws moodle_exception
+ * @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 (MARKING_GUIDE !== $gradeitem->get_advanced_grading_method()) {
+ throw new moodle_exception(
+ "The {$itemname} item in {$component}/{$contextid} is not configured for advanced grading with a marking guide"
+ );
+ }
+
+ // 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/grading/form/guide/db/services.php b/grade/grading/form/guide/db/services.php
new file mode 100644
index 00000000000..88ea5fda701
--- /dev/null
+++ b/grade/grading/form/guide/db/services.php
@@ -0,0 +1,43 @@
+.
+
+/**
+ * External functions and service definitions for the Marking Guide advanced grading form.
+ *
+ * @package gradingform_guide
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+$functions = [
+ 'gradingform_guide_grader_gradingpanel_fetch' => [
+ 'classname' => 'gradingform_guide\\grades\\grader\\gradingpanel\\external\\fetch',
+ 'methodname' => 'execute',
+ 'description' => 'Fetch the data required to display the grader grading panel, ' .
+ 'creating the grade item if required',
+ 'type' => 'write',
+ 'ajax' => true,
+ ],
+ 'gradingform_guide_grader_gradingpanel_store' => [
+ 'classname' => 'gradingform_guide\\grades\\grader\\gradingpanel\\external\\store',
+ 'methodname' => 'execute',
+ 'description' => 'Store the grading data for a user from the grader grading panel.',
+ 'type' => 'write',
+ 'ajax' => true,
+ ],
+];
diff --git a/grade/grading/form/guide/lang/en/gradingform_guide.php b/grade/grading/form/guide/lang/en/gradingform_guide.php
index 9343b4aabdf..6c9aebd2d6e 100644
--- a/grade/grading/form/guide/lang/en/gradingform_guide.php
+++ b/grade/grading/form/guide/lang/en/gradingform_guide.php
@@ -25,6 +25,7 @@
defined('MOODLE_INTERNAL') || die();
$string['addcomment'] = 'Add frequently used comment';
+$string['additionalcomments'] = 'Additional comments';
$string['addcriterion'] = 'Add criterion';
$string['alwaysshowdefinition'] = 'Show guide definition to students';
$string['backtoediting'] = 'Back to editing';
@@ -73,6 +74,7 @@ $string['insertcomment'] = 'Insert frequently used comment';
$string['maxscore'] = 'Maximum score';
$string['name'] = 'Name';
$string['needregrademessage'] = 'The marking guide definition was changed after this student had been graded. The student can not see this marking guide until you check the marking guide and update the grade.';
+$string['outof'] = 'Out of {$a}';
$string['pluginname'] = 'Marking guide';
$string['previewmarkingguide'] = 'Preview marking guide';
$string['privacy:metadata:criterionid'] = 'An identifier to a criterion for advanced marking.';
diff --git a/grade/grading/form/guide/lib.php b/grade/grading/form/guide/lib.php
index 4a4e002e994..f878c5e66a9 100644
--- a/grade/grading/form/guide/lib.php
+++ b/grade/grading/form/guide/lib.php
@@ -26,6 +26,9 @@ defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot.'/grade/grading/form/lib.php');
+/** guide: Used to compare our gradeitem_type against. */
+const MARKING_GUIDE = 'guide';
+
/**
* This controller encapsulates the guide grading logic
*
@@ -997,3 +1000,15 @@ class gradingform_guide_instance extends gradingform_instance {
return $html;
}
}
+
+/**
+ * Get the icon mapping for font-awesome.
+ *
+ * @return array
+ */
+function gradingform_guide_get_fontawesome_icon_map(): array {
+ return [
+ 'gradingform_guide:info' => 'fa-info-circle',
+ 'gradingform_guide:plus' => 'fa-plus',
+ ];
+}
diff --git a/grade/grading/form/guide/pix/info.png b/grade/grading/form/guide/pix/info.png
new file mode 100644
index 00000000000..6f9aa778b78
Binary files /dev/null and b/grade/grading/form/guide/pix/info.png differ
diff --git a/grade/grading/form/guide/pix/info.svg b/grade/grading/form/guide/pix/info.svg
new file mode 100644
index 00000000000..f14d54902ca
--- /dev/null
+++ b/grade/grading/form/guide/pix/info.svg
@@ -0,0 +1,3 @@
+
+]>
\ No newline at end of file
diff --git a/grade/grading/form/guide/pix/plus.png b/grade/grading/form/guide/pix/plus.png
new file mode 100644
index 00000000000..988d917581e
Binary files /dev/null and b/grade/grading/form/guide/pix/plus.png differ
diff --git a/grade/grading/form/guide/pix/plus.svg b/grade/grading/form/guide/pix/plus.svg
new file mode 100644
index 00000000000..63ccf861cbb
--- /dev/null
+++ b/grade/grading/form/guide/pix/plus.svg
@@ -0,0 +1,3 @@
+
+]>
\ No newline at end of file
diff --git a/grade/grading/form/guide/styles.css b/grade/grading/form/guide/styles.css
index e3cd9dcb903..08cbb9d3a61 100644
--- a/grade/grading/form/guide/styles.css
+++ b/grade/grading/form/guide/styles.css
@@ -241,3 +241,8 @@
max-height: 80vh;
overflow-y: auto;
}
+.gradingform_guide-frequent-comments {
+ position: absolute;
+ top: 7px;
+ right: 0px;
+}
diff --git a/grade/grading/form/guide/styles.scss b/grade/grading/form/guide/styles.scss
new file mode 100644
index 00000000000..4898971a0cf
--- /dev/null
+++ b/grade/grading/form/guide/styles.scss
@@ -0,0 +1,5 @@
+.gradingform_guide-fac {
+ position: absolute;
+ right: -5px;
+ top: 5px;
+}
diff --git a/grade/grading/form/guide/templates/grades/grader/gradingpanel.mustache b/grade/grading/form/guide/templates/grades/grader/gradingpanel.mustache
new file mode 100644
index 00000000000..7d816e66af1
--- /dev/null
+++ b/grade/grading/form/guide/templates/grades/grader/gradingpanel.mustache
@@ -0,0 +1,134 @@
+{{!
+ 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 gradingform_rubric/grades/grader/gradingpanel
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * none
+
+ Context variables required for this template:
+ * instanceid: Instance of the module this grading form belongs too
+ * criterion: A gradeable item in the Marking Guide
+ * name: Name of the gradeable item
+ * id: ID of the gradeable item
+ * description: Description shown to students for this gradeable item
+ * descriptionmarkers: Description shown to teachers for this gradeable item
+ * maxscore: Max allowable assinable points for this item
+ * score: Current score assigned to the learner for this item
+ * remark: Text input for the teacher to relay to the student
+ * hascomments: Flag for frequently used comments
+ * comments: Array of frequently used comments
+ * description: Description of a frequently used comment
+
+ Example context (json):
+ {
+ "instanceid": "42",
+ "criterion": [
+ {
+ "name": "Motivation",
+ "id": 13,
+ "description": "Show your motivation to rock climbing",
+ "descriptionmarkers": "Does the student show interest in climbing?",
+ "maxscore": 37,
+ "score": 20,
+ "remark": "That's great!",
+ "hascomments": true,
+ "comments": [
+ {"description": "Great work!"},
+ {"description": "You should really try it before jumping to conclusions"}
+ ]
+ }
+ ]
+ }
+}}
+
+{{#js}}
+require(['gradingform_guide/grades/grader/gradingpanel/comments'], function(Comments) {
+ Comments.init('gradingform_guide-{{uniqid}}');
+});
+{{/js}}
diff --git a/grade/grading/form/guide/tests/coverage.php b/grade/grading/form/guide/tests/coverage.php
new file mode 100644
index 00000000000..84ed112e668
--- /dev/null
+++ b/grade/grading/form/guide/tests/coverage.php
@@ -0,0 +1,41 @@
+.
+
+/**
+ * Coverage information for the gradingform_guide plugin.
+ *
+ * @package gradingform_guide
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Coverage information for the gradingform_guide plugin.
+ *
+ * @package gradingform_guide
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+return new class extends phpunit_coverage_info {
+ // Array The list of folders relative to the plugin root to whitelist in coverage generation.
+ protected $whitelistfolders = [
+ 'classes',
+ 'tests/generator',
+ ];
+};
diff --git a/grade/grading/form/guide/tests/generator/lib.php b/grade/grading/form/guide/tests/generator/lib.php
index faf0cb14f07..ffb7634a7fb 100644
--- a/grade/grading/form/guide/tests/generator/lib.php
+++ b/grade/grading/form/guide/tests/generator/lib.php
@@ -173,10 +173,14 @@ class gradingform_guide_generator extends component_generator_base {
* @param context_module $context
* @return gradingform_guide_controller
*/
- public function get_test_guide(context_module $context): gradingform_guide_controller {
+ public function get_test_guide(
+ context_module $context,
+ string $component = 'mod_assign',
+ string $areaname = 'submission'
+ ): gradingform_guide_controller {
$generator = \testing_util::get_data_generator();
$gradinggenerator = $generator->get_plugin_generator('core_grading');
- $controller = $gradinggenerator->create_instance($context, 'mod_assign', 'submission', 'guide');
+ $controller = $gradinggenerator->create_instance($context, $component, $areaname, 'guide');
$generator = \testing_util::get_data_generator();
$guidegenerator = $generator->get_plugin_generator('gradingform_guide');
diff --git a/grade/grading/form/guide/tests/grades_grader_gradingpanel_guide_external_fetch_test.php b/grade/grading/form/guide/tests/grades_grader_gradingpanel_guide_external_fetch_test.php
new file mode 100644
index 00000000000..bee39c5a7d9
--- /dev/null
+++ b/grade/grading/form/guide/tests/grades_grader_gradingpanel_guide_external_fetch_test.php
@@ -0,0 +1,296 @@
+.
+
+/**
+ * Unit tests for core_grades\component_gradeitems;
+ *
+ * @package gradingform_guide
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+declare(strict_types = 1);
+
+namespace gradingform_guide\grades\grader\gradingpanel\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 gradingform_guide
+ * @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 advanced grading with a marking guide");
+ 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();
+
+ [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ] = $this->get_test_data();
+
+ $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('gradingform_guide/grades/grader/gradingpanel', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+
+ $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']);
+
+ $this->assertArrayHasKey('criterion', $result['grade']);
+ $criteria = $result['grade']['criterion'];
+ $this->assertCount(count($definition->guide_criteria), $criteria);
+ foreach ($criteria as $criterion) {
+ $this->assertArrayHasKey('id', $criterion);
+ $criterionid = $criterion['id'];
+ $sourcecriterion = $definition->guide_criteria[$criterionid];
+
+ $this->assertArrayHasKey('name', $criterion);
+ $this->assertEquals($sourcecriterion['shortname'], $criterion['name']);
+
+ $this->assertArrayHasKey('maxscore', $criterion);
+ $this->assertEquals($sourcecriterion['maxscore'], $criterion['maxscore']);
+
+ $this->assertArrayHasKey('description', $criterion);
+ $this->assertEquals($sourcecriterion['description'], $criterion['description']);
+
+ $this->assertArrayHasKey('descriptionmarkers', $criterion);
+ $this->assertEquals($sourcecriterion['descriptionmarkers'], $criterion['descriptionmarkers']);
+
+ $this->assertArrayHasKey('score', $criterion);
+ $this->assertEmpty($criterion['score']);
+
+ $this->assertArrayHasKey('remark', $criterion);
+ $this->assertEmpty($criterion['remark']);
+ }
+ }
+
+ /**
+ * 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();
+ $generator = \testing_util::get_data_generator();
+ $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+ [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ] = $this->get_test_data();
+
+ $this->setUser($teacher);
+
+ $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
+ $grade = $gradeitem->get_grade_for_user($student, $teacher);
+ $instance = $gradeitem->get_advanced_grading_instance($teacher, $grade);
+
+ $submissiondata = $guidegenerator->get_test_form_data($controller, (int) $student->id,
+ 10, 'Propper good speling',
+ 0, 'ASCII art is not a picture'
+ );
+
+ $gradeitem->store_grade_from_formdata($student, $teacher, (object) [
+ 'instanceid' => $instance->get_id(),
+ 'advancedgrading' => $submissiondata,
+ ]);
+
+ $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('gradingform_guide/grades/grader/gradingpanel', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+
+ $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']);
+
+ $this->assertArrayHasKey('criterion', $result['grade']);
+ $criteria = $result['grade']['criterion'];
+ $this->assertCount(count($definition->guide_criteria), $criteria);
+ foreach ($criteria as $criterion) {
+ $this->assertArrayHasKey('id', $criterion);
+ $criterionid = $criterion['id'];
+ $sourcecriterion = $definition->guide_criteria[$criterionid];
+
+ $this->assertArrayHasKey('name', $criterion);
+ $this->assertEquals($sourcecriterion['shortname'], $criterion['name']);
+
+ $this->assertArrayHasKey('maxscore', $criterion);
+ $this->assertEquals($sourcecriterion['maxscore'], $criterion['maxscore']);
+
+ $this->assertArrayHasKey('description', $criterion);
+ $this->assertEquals($sourcecriterion['description'], $criterion['description']);
+
+ $this->assertArrayHasKey('descriptionmarkers', $criterion);
+ $this->assertEquals($sourcecriterion['descriptionmarkers'], $criterion['descriptionmarkers']);
+
+ $this->assertArrayHasKey('score', $criterion);
+ $this->assertArrayHasKey('remark', $criterion);
+ }
+
+ $this->assertEquals(10, $criteria[0]['score']);
+ $this->assertEquals('Propper good speling', $criteria[0]['remark']);
+ $this->assertEquals(0, $criteria[1]['score']);
+ $this->assertEquals('ASCII art is not a picture', $criteria[1]['remark']);
+ }
+
+ /**
+ * 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 forums graded using a marking guide.
+ *
+ * @return array
+ */
+ protected function get_test_data(): array {
+ global $DB;
+
+ $this->resetAfterTest();
+
+ $generator = \testing_util::get_data_generator();
+ $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+ $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);
+ $controller = $guidegenerator->get_test_guide($forum->get_context(), 'forum', 'forum');
+ $definition = $controller->get_definition();
+
+ $DB->set_field('forum', 'grade_forum', count($definition->guide_criteria), ['id' => $forum->get_id()]);
+ return [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ];
+ }
+}
diff --git a/grade/grading/form/guide/tests/grades_grader_gradingpanel_guide_external_store_test.php b/grade/grading/form/guide/tests/grades_grader_gradingpanel_guide_external_store_test.php
new file mode 100644
index 00000000000..c55badf01b7
--- /dev/null
+++ b/grade/grading/form/guide/tests/grades_grader_gradingpanel_guide_external_store_test.php
@@ -0,0 +1,249 @@
+.
+
+/**
+ * Unit tests for core_grades\component_gradeitems;
+ *
+ * @package gradingform_guide
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+declare(strict_types = 1);
+
+namespace gradingform_guide\grades\grader\gradingpanel\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 gradingform_guide
+ * @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([
+ '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 advanced grading with a marking guide");
+ 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_graded(): void {
+ $this->resetAfterTest();
+ $generator = \testing_util::get_data_generator();
+ $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+ [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ] = $this->get_test_data();
+
+ $this->setUser($teacher);
+
+ $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
+ $grade = $gradeitem->get_grade_for_user($student, $teacher);
+ $instance = $gradeitem->get_advanced_grading_instance($teacher, $grade);
+
+ $submissiondata = $guidegenerator->get_test_form_data($controller, (int) $student->id,
+ 10, 'Propper good speling',
+ 0, 'ASCII art is not a picture'
+ );
+
+ $formdata = http_build_query((object) [
+ 'instanceid' => $instance->get_id(),
+ 'advancedgrading' => $submissiondata,
+ ], '', '&');
+
+ $result = store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, $formdata);
+ $result = external_api::clean_returnvalue(store::execute_returns(), $result);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('templatename', $result);
+
+ $this->assertEquals('gradingform_guide/grades/grader/gradingpanel', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+
+ $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']);
+
+ $this->assertArrayHasKey('criterion', $result['grade']);
+ $criteria = $result['grade']['criterion'];
+ $this->assertCount(count($definition->guide_criteria), $criteria);
+ foreach ($criteria as $criterion) {
+ $this->assertArrayHasKey('id', $criterion);
+ $criterionid = $criterion['id'];
+ $sourcecriterion = $definition->guide_criteria[$criterionid];
+
+ $this->assertArrayHasKey('name', $criterion);
+ $this->assertEquals($sourcecriterion['shortname'], $criterion['name']);
+
+ $this->assertArrayHasKey('maxscore', $criterion);
+ $this->assertEquals($sourcecriterion['maxscore'], $criterion['maxscore']);
+
+ $this->assertArrayHasKey('description', $criterion);
+ $this->assertEquals($sourcecriterion['description'], $criterion['description']);
+
+ $this->assertArrayHasKey('descriptionmarkers', $criterion);
+ $this->assertEquals($sourcecriterion['descriptionmarkers'], $criterion['descriptionmarkers']);
+
+ $this->assertArrayHasKey('score', $criterion);
+ $this->assertArrayHasKey('remark', $criterion);
+ }
+
+ $this->assertEquals(10, $criteria[0]['score']);
+ $this->assertEquals('Propper good speling', $criteria[0]['remark']);
+ $this->assertEquals(0, $criteria[1]['score']);
+ $this->assertEquals('ASCII art is not a picture', $criteria[1]['remark']);
+ }
+
+ /**
+ * 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 forums graded using a marking guide.
+ *
+ * @return array
+ */
+ protected function get_test_data(): array {
+ global $DB;
+
+ $this->resetAfterTest();
+
+ $generator = \testing_util::get_data_generator();
+ $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+ $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);
+ $controller = $guidegenerator->get_test_guide($forum->get_context(), 'forum', 'forum');
+ $definition = $controller->get_definition();
+
+ $DB->set_field('forum', 'grade_forum', count($definition->guide_criteria), ['id' => $forum->get_id()]);
+ return [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ];
+ }
+}
diff --git a/grade/grading/form/guide/version.php b/grade/grading/form/guide/version.php
index 9b6c8270a0f..a774d44e9b4 100644
--- a/grade/grading/form/guide/version.php
+++ b/grade/grading/form/guide/version.php
@@ -25,6 +25,6 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'gradingform_guide';
-$plugin->version = 2019052000;
+$plugin->version = 2019100300;
$plugin->requires = 2019051100;
-$plugin->maturity = MATURITY_STABLE;
\ No newline at end of file
+$plugin->maturity = MATURITY_STABLE;
diff --git a/grade/grading/form/lib.php b/grade/grading/form/lib.php
index 2f0666221df..9c94f0d5dad 100644
--- a/grade/grading/form/lib.php
+++ b/grade/grading/form/lib.php
@@ -521,13 +521,53 @@ abstract class gradingform_controller {
* @param int $raterid
* @param int $itemid
* @return gradingform_instance
+ * @throws dml_exception
*/
public function get_or_create_instance($instanceid, $raterid, $itemid) {
- global $DB;
- if ($instanceid &&
- $instance = $DB->get_record('grading_instances', array('id' => $instanceid, 'raterid' => $raterid, 'itemid' => $itemid), '*', IGNORE_MISSING)) {
- return $this->get_instance($instance);
+ if (!is_numeric($instanceid)) {
+ $instanceid = null;
}
+ return $this->fetch_instance($raterid, $itemid, $instanceid);
+ }
+
+ /**
+ * If an instanceid is specified and grading instance exists and it is created by this rater for
+ * this item, then the instance is returned.
+ *
+ * If instanceid is not known, then null can be passed to fetch the current instance matchign the specified raterid
+ * and itemid.
+ *
+ * If the instanceid is falsey, or no instance was found, then create a new instance for the specified rater and item.
+ *
+ * @param int $raterid
+ * @param int $itemid
+ * @param int $instanceid
+ * @return gradingform_instance
+ * @throws dml_exception
+ */
+ public function fetch_instance(int $raterid, int $itemid, ?int $instanceid): gradingform_instance {
+ global $DB;
+
+ $instance = null;
+ if (null === $instanceid) {
+ if ($instance = $this->get_current_instance($raterid, $itemid)) {
+ return $instance;
+ }
+ $instanceid = $instancerecord->id ?? null;
+ }
+
+ if (!empty($instanceid)) {
+ $instance = $DB->get_record('grading_instances', [
+ 'id' => $instanceid,
+ 'raterid' => $raterid,
+ 'itemid' => $itemid,
+ ], '*', IGNORE_MISSING);
+
+ if ($instance) {
+ return $this->get_instance($instance);
+ }
+ }
+
return $this->create_instance($raterid, $itemid);
}
diff --git a/grade/grading/form/rubric/amd/build/grades/grader/gradingpanel.min.js b/grade/grading/form/rubric/amd/build/grades/grader/gradingpanel.min.js
new file mode 100644
index 00000000000..b3a2eeae770
--- /dev/null
+++ b/grade/grading/form/rubric/amd/build/grades/grader/gradingpanel.min.js
@@ -0,0 +1,2 @@
+define ("gradingform_rubric/grades/grader/gradingpanel",["exports","core/ajax","core_grades/grades/grader/gradingpanel/normalise","jquery"],function(a,b,c,d){"use strict";Object.defineProperty(a,"__esModule",{value:!0});a.storeCurrentGrade=a.fetchCurrentGrade=void 0;d=function(a){return a&&a.__esModule?a:{default:a}}(d);function e(a,b,c,d,e,f,g){try{var h=a[f](g),i=h.value}catch(a){c(a);return}if(h.done){b(i)}else{Promise.resolve(i).then(d,e)}}function f(a){return function(){var b=this,c=arguments;return new Promise(function(d,f){var i=a.apply(b,c);function g(a){e(i,d,f,g,h,"next",a)}function h(a){e(i,d,f,g,h,"throw",a)}g(void 0)})}}a.fetchCurrentGrade=function fetchCurrentGrade(a,c,d,e){return(0,b.call)([{methodname:"gradingform_rubric_grader_gradingpanel_fetch",args:{component:a,contextid:c,itemname:d,gradeduserid:e}}])[0]};var g=function(){var a=f(regeneratorRuntime.mark(function a(e,f,g,h,i){var j;return regeneratorRuntime.wrap(function(a){while(1){switch(a.prev=a.next){case 0:j=i.querySelector("form");a.t0=c.normaliseResult;a.next=4;return(0,b.call)([{methodname:"gradingform_rubric_grader_gradingpanel_store",args:{component:e,contextid:f,itemname:g,gradeduserid:h,formdata:(0,d.default)(j).serialize()}}])[0];case 4:a.t1=a.sent;return a.abrupt("return",(0,a.t0)(a.t1));case 6:case"end":return a.stop();}}},a)}));return function(){return a.apply(this,arguments)}}();a.storeCurrentGrade=g});
+//# sourceMappingURL=gradingpanel.min.js.map
diff --git a/grade/grading/form/rubric/amd/build/grades/grader/gradingpanel.min.js.map b/grade/grading/form/rubric/amd/build/grades/grader/gradingpanel.min.js.map
new file mode 100644
index 00000000000..8611812e7c4
--- /dev/null
+++ b/grade/grading/form/rubric/amd/build/grades/grader/gradingpanel.min.js.map
@@ -0,0 +1 @@
+{"version":3,"sources":["../../../src/grades/grader/gradingpanel.js"],"names":["fetchCurrentGrade","component","contextid","itemname","gradeduserid","methodname","args","storeCurrentGrade","rootNode","form","querySelector","normaliseResult","formdata","serialize"],"mappings":"2QA6BA,uD,mVAYiC,QAApBA,CAAAA,iBAAoB,CAACC,CAAD,CAAYC,CAAZ,CAAuBC,CAAvB,CAAiCC,CAAjC,CAAkD,CAC/E,MAAO,WAAU,CAAC,CACdC,UAAU,+CADI,CAEdC,IAAI,CAAE,CACFL,SAAS,CAATA,CADE,CAEFC,SAAS,CAATA,CAFE,CAGFC,QAAQ,CAARA,CAHE,CAIFC,YAAY,CAAZA,CAJE,CAFQ,CAAD,CAAV,EAQH,CARG,CASV,C,CAaM,GAAMG,CAAAA,CAAiB,4CAAG,WAAMN,CAAN,CAAiBC,CAAjB,CAA4BC,CAA5B,CAAsCC,CAAtC,CAAoDI,CAApD,yFACvBC,CADuB,CAChBD,CAAQ,CAACE,aAAT,CAAuB,MAAvB,CADgB,MAGtBC,iBAHsB,gBAGA,WAAU,CAAC,CACpCN,UAAU,+CAD0B,CAEpCC,IAAI,CAAE,CACFL,SAAS,CAATA,CADE,CAEFC,SAAS,CAATA,CAFE,CAGFC,QAAQ,CAARA,CAHE,CAIFC,YAAY,CAAZA,CAJE,CAKFQ,QAAQ,CAAE,cAAOH,CAAP,EAAaI,SAAb,EALR,CAF8B,CAAD,CAAV,EASzB,CATyB,CAHA,qGAAH,uDAAvB,C","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Grading panel for gradingform_rubric.\n *\n * @module gradingform_rubric/grades/grader/gradingpanel\n * @package gradingform_rubric\n * @copyright 2019 Mathew May \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\n\nimport {call as fetchMany} from 'core/ajax';\nimport {normaliseResult} from 'core_grades/grades/grader/gradingpanel/normalise';\n\n\n// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send()\nimport jQuery from 'jquery';\n\n/**\n * For a given component, contextid, itemname & gradeduserid we can fetch the currently assigned grade.\n *\n * @param {String} component\n * @param {Number} contextid\n * @param {String} itemname\n * @param {Number} gradeduserid\n *\n * @returns {Promise}\n */\nexport const fetchCurrentGrade = (component, contextid, itemname, gradeduserid) => {\n return fetchMany([{\n methodname: `gradingform_rubric_grader_gradingpanel_fetch`,\n args: {\n component,\n contextid,\n itemname,\n gradeduserid,\n },\n }])[0];\n};\n\n/**\n * For a given component, contextid, itemname & gradeduserid we can store the currently assigned grade in a given form.\n *\n * @param {String} component\n * @param {Number} contextid\n * @param {String} itemname\n * @param {Number} gradeduserid\n * @param {HTMLElement} rootNode\n *\n * @returns {Promise}\n */\nexport const storeCurrentGrade = async(component, contextid, itemname, gradeduserid, rootNode) => {\n const form = rootNode.querySelector('form');\n\n return normaliseResult(await fetchMany([{\n methodname: `gradingform_rubric_grader_gradingpanel_store`,\n args: {\n component,\n contextid,\n itemname,\n gradeduserid,\n formdata: jQuery(form).serialize(),\n },\n }])[0]);\n};\n"],"file":"gradingpanel.min.js"}
\ No newline at end of file
diff --git a/grade/grading/form/rubric/amd/src/grades/grader/gradingpanel.js b/grade/grading/form/rubric/amd/src/grades/grader/gradingpanel.js
new file mode 100644
index 00000000000..52307f5778a
--- /dev/null
+++ b/grade/grading/form/rubric/amd/src/grades/grader/gradingpanel.js
@@ -0,0 +1,78 @@
+// 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 gradingform_rubric.
+ *
+ * @module gradingform_rubric/grades/grader/gradingpanel
+ * @package gradingform_rubric
+ * @copyright 2019 Mathew May
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import {call as fetchMany} from 'core/ajax';
+import {normaliseResult} from 'core_grades/grades/grader/gradingpanel/normalise';
+
+
+// Note: We use jQuery.serializer here until we can rewrite Ajax to use XHR.send()
+import jQuery from 'jquery';
+
+/**
+ * For a given component, contextid, itemname & gradeduserid we can fetch the currently assigned grade.
+ *
+ * @param {String} component
+ * @param {Number} contextid
+ * @param {String} itemname
+ * @param {Number} gradeduserid
+ *
+ * @returns {Promise}
+ */
+export const fetchCurrentGrade = (component, contextid, itemname, gradeduserid) => {
+ return fetchMany([{
+ methodname: `gradingform_rubric_grader_gradingpanel_fetch`,
+ args: {
+ component,
+ contextid,
+ itemname,
+ gradeduserid,
+ },
+ }])[0];
+};
+
+/**
+ * For a given component, contextid, itemname & gradeduserid we can store the currently assigned grade in a given form.
+ *
+ * @param {String} component
+ * @param {Number} contextid
+ * @param {String} itemname
+ * @param {Number} gradeduserid
+ * @param {HTMLElement} rootNode
+ *
+ * @returns {Promise}
+ */
+export const storeCurrentGrade = async(component, contextid, itemname, gradeduserid, rootNode) => {
+ const form = rootNode.querySelector('form');
+
+ return normaliseResult(await fetchMany([{
+ methodname: `gradingform_rubric_grader_gradingpanel_store`,
+ args: {
+ component,
+ contextid,
+ itemname,
+ gradeduserid,
+ formdata: jQuery(form).serialize(),
+ },
+ }])[0]);
+};
diff --git a/grade/grading/form/rubric/classes/grades/grader/gradingpanel/external/fetch.php b/grade/grading/form/rubric/classes/grades/grader/gradingpanel/external/fetch.php
new file mode 100644
index 00000000000..c60f421d826
--- /dev/null
+++ b/grade/grading/form/rubric/classes/grades/grader/gradingpanel/external/fetch.php
@@ -0,0 +1,298 @@
+.
+
+/**
+ * Web services relating to fetching of a rubric for the grading panel.
+ *
+ * @package gradingform_rubric
+ * @copyright 2019 Mathew May
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace gradingform_rubric\grades\grader\gradingpanel\external;
+
+global $CFG;
+
+use coding_exception;
+use context;
+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 stdClass;
+use moodle_exception;
+require_once($CFG->dirroot.'/grade/grading/form/rubric/lib.php');
+
+/**
+ * Web services relating to fetching of a rubric for the grading panel.
+ *
+ * @package gradingform_rubric
+ * @copyright 2019 Mathew May
+ * @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 {
+ [
+ '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 (RUBRIC !== $gradeitem->get_advanced_grading_method()) {
+ throw new moodle_exception(
+ "The {$itemname} item in {$component}/{$contextid} is not configured for advanced grading with a rubric"
+ );
+ }
+
+ // Fetch the actual data.
+ $gradeduser = \core_user::get_user($gradeduserid);
+
+ return self::get_fetch_data($gradeitem, $gradeduser);
+ }
+
+ /**
+ * Get the data to be fetched and create the structure ready for Mustache.
+ *
+ * @param gradeitem $gradeitem
+ * @param stdClass $gradeduser
+ * @return array
+ */
+ public static function get_fetch_data(gradeitem $gradeitem, stdClass $gradeduser): array {
+ global $USER;
+
+ // Set up all the controllers etc that we'll be needing.
+ $grade = $gradeitem->get_grade_for_user($gradeduser, $USER);
+ $instance = $gradeitem->get_advanced_grading_instance($USER, $grade);
+ $controller = $instance->get_controller();
+ $definition = $controller->get_definition();
+ $fillings = $instance->get_rubric_filling();
+ $context = $controller->get_context();
+ $definitionid = (int) $definition->id;
+
+ $teacherdescription = self::get_formatted_text(
+ $context,
+ $definitionid,
+ 'description',
+ $definition->description,
+ (int) $definition->descriptionformat
+ );
+
+ $criterion = [];
+ if ($definition->rubric_criteria) {
+ // Iterate over the defined criterion in the rubric and map out what we need to render each item.
+ $criterion = array_map(function($criterion) use ($definitionid, $fillings, $context) {
+ // The general structure we'll be returning, we still need to get the remark (if any) and the levels associated.
+ $result = [
+ 'id' => $criterion['id'],
+ 'description' => self::get_formatted_text(
+ $context,
+ $definitionid,
+ 'description',
+ $criterion['description'],
+ (int) $criterion['descriptionformat']
+ ),
+ ];
+
+ // Do we have an existing grade filling? if so lets get the remark associated to this criteria.
+ $filling = [];
+ if (array_key_exists($criterion['id'], $fillings['criteria'])) {
+ $filling = $fillings['criteria'][$criterion['id']];
+ $result['remark'] = self::get_formatted_text($context,
+ $definitionid,
+ 'remark',
+ $filling['remark'],
+ (int) $filling['remarkformat']
+ );
+ }
+
+ // Lets build the levels within a criteria and figure out what needs to go where.
+ $result['levels'] = array_map(function($level) use ($criterion, $filling, $context, $definitionid) {
+ // The bulk of what'll be returned can be defined easily we'll add to this further down.
+ $result = [
+ 'id' => $level['id'],
+ 'criterionid' => $criterion['id'],
+ 'score' => $level['score'],
+ 'definition' => self::get_formatted_text(
+ $context,
+ $definitionid,
+ 'definition',
+ $level['definition'],
+ (int) $level['definitionformat']
+ ),
+ 'checked' => null,
+ ];
+
+ // Consult the grade filling to see if a level has been selected and if it is the current level.
+ if (array_key_exists('levelid', $filling) && $filling['levelid'] == $level['id']) {
+ $result['checked'] = true;
+ }
+
+ return $result;
+ }, $criterion['levels']);
+
+ $nulllevel = [
+ 'id' => null,
+ 'criterionid' => $criterion['id'],
+ 'score' => '-',
+ 'definition' => 'Not set',
+ 'checked' => null,
+ ];
+ // Consult the grade filling to see if a level has been selected and if it is the current level.
+ if (array_key_exists('levelid', $filling) && $filling['levelid'] == 0) {
+ $nulllevel['checked'] = true;
+ }
+
+ array_unshift($result['levels'], $nulllevel);
+
+ return $result;
+ }, $definition->rubric_criteria);
+ }
+
+ return [
+ 'templatename' => 'gradingform_rubric/grades/grader/gradingpanel',
+ 'grade' => [
+ 'instanceid' => $instance->get_id(),
+ 'criteria' => $criterion,
+ 'rubricmode' => 'evaluate editable',
+ 'teacherdescription' => $teacherdescription,
+ 'canedit' => false,
+ '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([
+ 'instanceid' => new external_value(PARAM_INT, 'The id of the current grading instance'),
+ 'rubricmode' => new external_value(PARAM_RAW, 'The mode i.e. evaluate editable'),
+ 'canedit' => new external_value(PARAM_BOOL, 'Can the user edit this'),
+ 'criteria' => new external_multiple_structure(
+ new external_single_structure([
+ 'id' => new external_value(PARAM_INT, 'ID of the Criteria'),
+ 'description' => new external_value(PARAM_RAW, 'Description of the Criteria'),
+ 'remark' => new external_value(PARAM_RAW, 'Any remarks for this criterion for the user being assessed', VALUE_OPTIONAL),
+ 'levels' => new external_multiple_structure(new external_single_structure([
+ 'id' => new external_value(PARAM_INT, 'ID of level'),
+ 'criterionid' => new external_value(PARAM_INT, 'ID of the criterion this matches to'),
+ 'score' => new external_value(PARAM_RAW, 'What this level is worth'),
+ 'definition' => new external_value(PARAM_RAW, 'Definition of the level'),
+ 'checked' => new external_value(PARAM_BOOL, 'Selected flag'),
+ ])),
+ ])
+ ),
+ '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(),
+ ]);
+ }
+
+ /**
+ * Get a formatted version of the remark/description/etc.
+ *
+ * @param context $context
+ * @param int $definitionid
+ * @param string $filearea The file area of the field
+ * @param string $text The text to be formatted
+ * @param int $format The input format of the string
+ * @return string
+ */
+ protected static function get_formatted_text(context $context, int $definitionid, string $filearea, string $text, int $format): string {
+ $formatoptions = [
+ 'noclean' => false,
+ 'trusted' => false,
+ 'filter' => true,
+ ];
+ [$newtext, ] = external_format_text($text, $format, $context, 'grading', $filearea, $definitionid, $formatoptions);
+ return $newtext;
+ }
+}
diff --git a/grade/grading/form/rubric/classes/grades/grader/gradingpanel/external/store.php b/grade/grading/form/rubric/classes/grades/grader/gradingpanel/external/store.php
new file mode 100644
index 00000000000..67ccfc7f538
--- /dev/null
+++ b/grade/grading/form/rubric/classes/grades/grader/gradingpanel/external/store.php
@@ -0,0 +1,166 @@
+.
+
+/**
+ * Web services relating to fetching of a rubric for the grading panel.
+ *
+ * @package gradingform_rubric
+ * @copyright 2019 Mathew May
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+declare(strict_types = 1);
+
+namespace gradingform_rubric\grades\grader\gradingpanel\external;
+
+global $CFG;
+
+use coding_exception;
+use context;
+use core_grades\component_gradeitem as gradeitem;
+use core_grades\component_gradeitems;
+use external_api;
+use external_function_parameters;
+use external_single_structure;
+use external_value;
+use moodle_exception;
+require_once($CFG->dirroot.'/grade/grading/form/rubric/lib.php');
+
+/**
+ * Web services relating to storing of a rubric for the grading panel.
+ *
+ * @package gradingform_rubric
+ * @copyright 2019 Mathew May
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class store extends external_api {
+
+ /**
+ * Describes the parameters for storing 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
+ * @param string $formdata
+ * @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 (RUBRIC !== $gradeitem->get_advanced_grading_method()) {
+ throw new moodle_exception(
+ "The {$itemname} item in {$component}/{$contextid} is not configured for advanced grading with a rubric"
+ );
+ }
+
+ // Parse the serialised string into an object.
+ $data = [];
+ parse_str($formdata, $data);
+
+ // Grade.
+ $gradeitem->store_grade_from_formdata($gradeduser, $USER, (object) $data);
+
+ // Fetch the updated grade back out.
+ $grade = $gradeitem->get_grade_for_user($gradeduser, $USER);
+
+ 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/grading/form/rubric/db/services.php b/grade/grading/form/rubric/db/services.php
new file mode 100644
index 00000000000..08b90a960f9
--- /dev/null
+++ b/grade/grading/form/rubric/db/services.php
@@ -0,0 +1,45 @@
+.
+
+/**
+ * Rubric external functions and service definitions.
+ *
+ * @package gradingform_rubric
+ * @copyright 2019 Mathew May
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+$functions = [
+ 'gradingform_rubric_grader_gradingpanel_fetch' => [
+ 'classname' => 'gradingform_rubric\\grades\\grader\\gradingpanel\\external\\fetch',
+ 'methodname' => 'execute',
+ 'description' => 'Fetch the data required to display the grader grading panel, ' .
+ 'creating the grade item if required',
+ 'type' => 'write',
+ 'ajax' => true,
+ ],
+ 'gradingform_rubric_grader_gradingpanel_store' => [
+ 'classname' => 'gradingform_rubric\\grades\\grader\\gradingpanel\\external\\store',
+ 'methodname' => 'execute',
+ 'description' => 'Store the grading data for a user from the grader grading panel.',
+ 'type' => 'write',
+ 'ajax' => true,
+ ],
+];
+
+
diff --git a/grade/grading/form/rubric/lang/en/gradingform_rubric.php b/grade/grading/form/rubric/lang/en/gradingform_rubric.php
index d5951aec2f5..6e2d29973ff 100644
--- a/grade/grading/form/rubric/lang/en/gradingform_rubric.php
+++ b/grade/grading/form/rubric/lang/en/gradingform_rubric.php
@@ -58,6 +58,7 @@ $string['lockzeropoints_help'] = 'This setting only applies if the sum of the mi
$string['name'] = 'Name';
$string['needregrademessage'] = 'The rubric definition was changed after this student had been graded. The student can not see this rubric until you check the rubric and update the grade.';
$string['pluginname'] = 'Rubric';
+$string['pointsvalue'] = '{$a} points';
$string['previewrubric'] = 'Preview rubric';
$string['privacy:metadata:criterionid'] = 'An identifier for a specific criterion being graded.';
$string['privacy:metadata:fillingssummary'] = 'Stores information about the user\'s grade created by the rubric.';
diff --git a/grade/grading/form/rubric/lib.php b/grade/grading/form/rubric/lib.php
index 39af91091c7..361e73beecb 100644
--- a/grade/grading/form/rubric/lib.php
+++ b/grade/grading/form/rubric/lib.php
@@ -25,6 +25,10 @@
defined('MOODLE_INTERNAL') || die();
require_once($CFG->dirroot.'/grade/grading/form/lib.php');
+require_once($CFG->dirroot.'/lib/filelib.php');
+
+/** rubric: Used to compare our gradeitem_type against. */
+const RUBRIC = 'rubric';
/**
* This controller encapsulates the rubric grading logic
diff --git a/grade/grading/form/rubric/styles.css b/grade/grading/form/rubric/styles.css
index 5dbbfec7dfa..3b9a2d2fe5b 100644
--- a/grade/grading/form/rubric/styles.css
+++ b/grade/grading/form/rubric/styles.css
@@ -308,3 +308,7 @@
position: relative;
float: right;
}
+
+.gradingpanel-gradingform_rubric [aria-checked="true"] {
+ border: 1px solid black;
+}
diff --git a/grade/grading/form/rubric/templates/grades/grader/gradingpanel.mustache b/grade/grading/form/rubric/templates/grades/grader/gradingpanel.mustache
new file mode 100644
index 00000000000..94845f15866
--- /dev/null
+++ b/grade/grading/form/rubric/templates/grades/grader/gradingpanel.mustache
@@ -0,0 +1,105 @@
+{{!
+ 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 gradingform_rubric/grades/grader/gradingpanel
+
+ Classes required for JS:
+ * none
+
+ Data attributes required for JS:
+ * none
+
+ Context variables required for this template:
+ * instanceid: Instance of the module this grading form belongs too
+ * criteria: A gradeable item in the Marking Guide
+ * id: The ID of the criteria
+ * description: Description of the criteria
+ * levels: The level that a criteria can be graded at
+ * criterionid: The ID of the criteria
+ * checked: Flag for if this is the currently selected level
+ * definition: Definition of the level
+ * remark: Text input for the teacher to relay to the student
+
+ Example context (json):
+ {
+ "instanceid": "42",
+ "criteria": [
+ {
+ "id": 13,
+ "description": "Show your motivation to rock climbing",
+ "levels": [
+ {
+ "criterionid": 13,
+ "checked": true,
+ "definition": "Great work!"
+ }
+ ],
+ "remark": "That's great!"
+ }
+ ]
+ }
+}}
+
diff --git a/grade/grading/form/rubric/tests/coverage.php b/grade/grading/form/rubric/tests/coverage.php
new file mode 100644
index 00000000000..31da9126f52
--- /dev/null
+++ b/grade/grading/form/rubric/tests/coverage.php
@@ -0,0 +1,41 @@
+.
+
+/**
+ * Coverage information for the gradingform_rubric plugin.
+ *
+ * @package gradingform_rubric
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Coverage information for the gradingform_rubric plugin.
+ *
+ * @package gradingform_rubric
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+return new class extends phpunit_coverage_info {
+ // Array The list of folders relative to the plugin root to whitelist in coverage generation.
+ protected $whitelistfolders = [
+ 'classes',
+ 'tests/generator',
+ ];
+};
diff --git a/grade/grading/form/rubric/tests/grades_grader_gradingpanel_rubric_external_fetch_test.php b/grade/grading/form/rubric/tests/grades_grader_gradingpanel_rubric_external_fetch_test.php
new file mode 100644
index 00000000000..ec1ac948dd9
--- /dev/null
+++ b/grade/grading/form/rubric/tests/grades_grader_gradingpanel_rubric_external_fetch_test.php
@@ -0,0 +1,316 @@
+.
+
+/**
+ * Unit tests for core_grades\component_gradeitems;
+ *
+ * @package gradingform_rubric
+ * @category test
+ * @copyright 2019 Mathew May
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+declare(strict_types = 1);
+
+namespace gradingform_rubric\grades\grader\gradingpanel\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 gradingform_rubric
+ * @category test
+ * @copyright 2019 Mathew May
+ * @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 advanced grading with a rubric");
+ 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();
+
+ [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ] = $this->get_test_data();
+
+ $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('gradingform_rubric/grades/grader/gradingpanel', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+
+ $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']);
+
+ $this->assertArrayHasKey('criteria', $result['grade']);
+ $criteria = $result['grade']['criteria'];
+ $this->assertCount(count($definition->rubric_criteria), $criteria);
+ foreach ($criteria as $criterion) {
+ $this->assertArrayHasKey('id', $criterion);
+ $criterionid = $criterion['id'];
+ $sourcecriterion = $definition->rubric_criteria[$criterionid];
+
+ $this->assertArrayHasKey('description', $criterion);
+ $this->assertEquals($sourcecriterion['description'], $criterion['description']);
+
+ $this->assertArrayHasKey('levels', $criterion);
+
+ $levels = $criterion['levels'];
+ foreach ($levels as $level) {
+ $levelid = $level['id'];
+ if (!isset($levelid)) {
+ continue;
+ }
+ $sourcelevel = $sourcecriterion['levels'][$levelid];
+
+ $this->assertArrayHasKey('criterionid', $level);
+ $this->assertEquals($criterionid, $level['criterionid']);
+
+ $this->assertArrayHasKey('checked', $level);
+
+ $this->assertArrayHasKey('definition', $level);
+ $this->assertEquals($sourcelevel['definition'], $level['definition']);
+
+ $this->assertArrayHasKey('score', $level);
+ $this->assertEquals($sourcelevel['score'], $level['score']);
+ }
+ }
+ }
+
+ /**
+ * 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();
+ $generator = \testing_util::get_data_generator();
+ $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+ [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ] = $this->get_test_data();
+
+ $this->setUser($teacher);
+
+ $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
+ $grade = $gradeitem->get_grade_for_user($student, $teacher);
+ $instance = $gradeitem->get_advanced_grading_instance($teacher, $grade);
+
+ $submissiondata = $rubricgenerator->get_test_form_data($controller, (int) $student->id,
+ 0, 'Too many mistakes. Please try again.',
+ 2, 'Great number of pictures. Well done.'
+ );
+
+ $gradeitem->store_grade_from_formdata($student, $teacher, (object) [
+ 'instanceid' => $instance->get_id(),
+ 'advancedgrading' => $submissiondata,
+ ]);
+
+ $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('gradingform_rubric/grades/grader/gradingpanel', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+
+ $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']);
+
+ $this->assertArrayHasKey('criteria', $result['grade']);
+ $criteria = $result['grade']['criteria'];
+ $this->assertCount(count($definition->rubric_criteria), $criteria);
+ foreach ($criteria as $criterion) {
+ $this->assertArrayHasKey('id', $criterion);
+ $criterionid = $criterion['id'];
+ $sourcecriterion = $definition->rubric_criteria[$criterionid];
+
+ $this->assertArrayHasKey('description', $criterion);
+ $this->assertEquals($sourcecriterion['description'], $criterion['description']);
+
+ $this->assertArrayHasKey('remark', $criterion);
+
+ $this->assertArrayHasKey('levels', $criterion);
+
+ $levels = $criterion['levels'];
+ foreach ($levels as $level) {
+ $levelid = $level['id'];
+ if (!isset($levelid)) {
+ continue;
+ }
+ $sourcelevel = $sourcecriterion['levels'][$levelid];
+
+ $this->assertArrayHasKey('criterionid', $level);
+ $this->assertEquals($criterionid, $level['criterionid']);
+
+ $this->assertArrayHasKey('checked', $level);
+
+ $this->assertArrayHasKey('definition', $level);
+ $this->assertEquals($sourcelevel['definition'], $level['definition']);
+
+ $this->assertArrayHasKey('score', $level);
+ $this->assertEquals($sourcelevel['score'], $level['score']);
+ }
+
+ }
+
+ $this->assertEquals(1, $criteria[0]['levels'][1]['checked']);
+ $this->assertEquals('Too many mistakes. Please try again.', $criteria[0]['remark']);
+ $this->assertEquals(1, $criteria[1]['levels'][3]['checked']);
+ $this->assertEquals('Great number of pictures. Well done.', $criteria[1]['remark']);
+ }
+
+ /**
+ * 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 forums graded using a rubric.
+ *
+ * @return array
+ */
+ protected function get_test_data(): array {
+ global $DB;
+
+ $this->resetAfterTest();
+
+ $generator = \testing_util::get_data_generator();
+ $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+ $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);
+ $controller = $rubricgenerator->get_test_rubric($forum->get_context(), 'forum', 'forum');
+ $definition = $controller->get_definition();
+
+ $DB->set_field('forum', 'grade_forum', count($definition->rubric_criteria), ['id' => $forum->get_id()]);
+ return [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ];
+ }
+}
diff --git a/grade/grading/form/rubric/tests/grades_grader_gradingpanel_rubric_external_store_test.php b/grade/grading/form/rubric/tests/grades_grader_gradingpanel_rubric_external_store_test.php
new file mode 100644
index 00000000000..67954a45ebb
--- /dev/null
+++ b/grade/grading/form/rubric/tests/grades_grader_gradingpanel_rubric_external_store_test.php
@@ -0,0 +1,261 @@
+.
+
+/**
+ * Unit tests for core_grades\component_gradeitems;
+ *
+ * @package gradingform_rubric
+ * @category test
+ * @copyright 2019 Mathew May
+ * @license http://www.gnu.org/copyleft/gpl.html GNU Public License
+ */
+
+declare(strict_types = 1);
+
+namespace gradingform_rubric\grades\grader\gradingpanel\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 gradingform_rubric
+ * @category test
+ * @copyright 2019 Mathew May
+ * @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([
+ '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);
+ 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_graded(): void {
+ $this->resetAfterTest();
+ $generator = \testing_util::get_data_generator();
+ $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+ [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ] = $this->get_test_data();
+
+ $this->setUser($teacher);
+
+ $gradeitem = component_gradeitem::instance('mod_forum', $forum->get_context(), 'forum');
+ $grade = $gradeitem->get_grade_for_user($student, $teacher);
+ $instance = $gradeitem->get_advanced_grading_instance($teacher, $grade);
+
+ $submissiondata = $rubricgenerator->get_test_form_data($controller, (int) $student->id,
+ 0, 'Too many mistakes. Please try again.',
+ 2, 'Great number of pictures. Well done.'
+ );
+
+ $formdata = http_build_query((object) [
+ 'instanceid' => $instance->get_id(),
+ 'advancedgrading' => $submissiondata,
+ ], '', '&');
+
+ $result = store::execute('mod_forum', (int) $forum->get_context()->id, 'forum', (int) $student->id, $formdata);
+ $result = external_api::clean_returnvalue(store::execute_returns(), $result);
+
+ $this->assertIsArray($result);
+ $this->assertArrayHasKey('templatename', $result);
+
+ $this->assertEquals('gradingform_rubric/grades/grader/gradingpanel', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+
+ $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']);
+
+ $this->assertArrayHasKey('criteria', $result['grade']);
+ $criteria = $result['grade']['criteria'];
+ $this->assertCount(count($definition->rubric_criteria), $criteria);
+ foreach ($criteria as $criterion) {
+ $this->assertArrayHasKey('id', $criterion);
+ $criterionid = $criterion['id'];
+ $sourcecriterion = $definition->rubric_criteria[$criterionid];
+
+ $this->assertArrayHasKey('description', $criterion);
+ $this->assertEquals($sourcecriterion['description'], $criterion['description']);
+
+ $this->assertArrayHasKey('remark', $criterion);
+
+ $this->assertArrayHasKey('levels', $criterion);
+
+ $levels = $criterion['levels'];
+ foreach ($levels as $level) {
+ $levelid = $level['id'];
+ if (!isset($levelid)) {
+ continue;
+ }
+ $sourcelevel = $sourcecriterion['levels'][$levelid];
+
+ $this->assertArrayHasKey('criterionid', $level);
+ $this->assertEquals($criterionid, $level['criterionid']);
+
+ $this->assertArrayHasKey('checked', $level);
+
+ $this->assertArrayHasKey('definition', $level);
+ $this->assertEquals($sourcelevel['definition'], $level['definition']);
+
+ $this->assertArrayHasKey('score', $level);
+ $this->assertEquals($sourcelevel['score'], $level['score']);
+ }
+
+ }
+
+ $this->assertEquals(1, $criteria[0]['levels'][1]['checked']);
+ $this->assertEquals('Too many mistakes. Please try again.', $criteria[0]['remark']);
+ $this->assertEquals(1, $criteria[1]['levels'][3]['checked']);
+ $this->assertEquals('Great number of pictures. Well done.', $criteria[1]['remark']);
+ }
+
+ /**
+ * 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 forums graded using a rubric.
+ *
+ * @return array
+ */
+ protected function get_test_data(): array {
+ global $DB;
+
+ $this->resetAfterTest();
+
+ $generator = \testing_util::get_data_generator();
+ $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+ $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);
+ $controller = $rubricgenerator->get_test_rubric($forum->get_context(), 'forum', 'forum');
+ $definition = $controller->get_definition();
+
+ $DB->set_field('forum', 'grade_forum', count($definition->rubric_criteria), ['id' => $forum->get_id()]);
+ return [
+ 'forum' => $forum,
+ 'controller' => $controller,
+ 'definition' => $definition,
+ 'student' => $student,
+ 'teacher' => $teacher,
+ ];
+ }
+}
diff --git a/grade/grading/form/rubric/version.php b/grade/grading/form/rubric/version.php
index 0b35f3dfc3f..f08c267090a 100644
--- a/grade/grading/form/rubric/version.php
+++ b/grade/grading/form/rubric/version.php
@@ -25,7 +25,7 @@
defined('MOODLE_INTERNAL') || die();
$plugin->component = 'gradingform_rubric';
-$plugin->version = 2019052000;
+$plugin->version = 2019052006;
$plugin->requires = 2019051100;
diff --git a/grade/grading/lib.php b/grade/grading/lib.php
index d2f57e9f63b..d4954489cd4 100644
--- a/grade/grading/lib.php
+++ b/grade/grading/lib.php
@@ -24,6 +24,8 @@
defined('MOODLE_INTERNAL') || die();
+use core_grades\component_gradeitems;
+
/**
* Factory method returning an instance of the grading manager
*
@@ -288,14 +290,29 @@ class grading_manager {
public static function available_areas($component) {
global $CFG;
+ if (component_gradeitems::defines_advancedgrading_itemnames_for_component($component)) {
+ $result = [];
+ foreach (component_gradeitems::get_advancedgrading_itemnames_for_component($component) as $itemnumber => $itemname) {
+ $result[$itemname] = get_string("gradeitem:{$itemname}", $component);
+ }
+
+ return $result;
+ }
+
list($plugintype, $pluginname) = core_component::normalize_component($component);
if ($component === 'core_grading') {
return array();
} else if ($plugintype === 'mod') {
- return plugin_callback('mod', $pluginname, 'grading', 'areas_list', null, array());
-
+ $callbackfunction = "grading_areas_list";
+ if (component_callback_exists($component, $callbackfunction)) {
+ debugging(
+ "Components supporting advanced grading should be updated to implement the component_gradeitems class",
+ DEBUG_DEVELOPER
+ );
+ return component_callback($component, $callbackfunction, [], []);
+ }
} else {
throw new coding_exception('Unsupported area location');
}
diff --git a/grade/grading/tests/coverage.php b/grade/grading/tests/coverage.php
new file mode 100644
index 00000000000..9b2b565097d
--- /dev/null
+++ b/grade/grading/tests/coverage.php
@@ -0,0 +1,41 @@
+.
+
+/**
+ * Coverage information for the core_grading subsystem.
+ *
+ * @package core_grading
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Coverage information for the core_grading subsystem.
+ *
+ * @package core_grading
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+return new class extends phpunit_coverage_info {
+ // Array The list of folders relative to the plugin root to whitelist in coverage generation.
+ protected $whitelistfolders = [
+ 'classes',
+ 'tests/generator',
+ ];
+};
diff --git a/grade/templates/grades/grader/gradingpanel/point.mustache b/grade/templates/grades/grader/gradingpanel/point.mustache
new file mode 100644
index 00000000000..ebf77c30a97
--- /dev/null
+++ b/grade/templates/grades/grader/gradingpanel/point.mustache
@@ -0,0 +1,35 @@
+{{!
+ 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
+ }
+}}
+
diff --git a/grade/templates/grades/grader/gradingpanel/scale.mustache b/grade/templates/grades/grader/gradingpanel/scale.mustache
new file mode 100644
index 00000000000..fdac05d3873
--- /dev/null
+++ b/grade/templates/grades/grader/gradingpanel/scale.mustache
@@ -0,0 +1,42 @@
+{{!
+ 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/scale
+
+ Scale-based grading template for use in the grading panel.
+
+ Context variables required for this template:
+
+ Example context (json):
+ {
+ "value": 1,
+ "selected": true,
+ "title": "Motivational"
+ }
+}}
+
diff --git a/grade/tests/behat/grade_to_pass.feature b/grade/tests/behat/grade_to_pass.feature
index e257fd95206..2cec4d0482f 100644
--- a/grade/tests/behat/grade_to_pass.feature
+++ b/grade/tests/behat/grade_to_pass.feature
@@ -196,8 +196,8 @@ Feature: We can set the grade to pass value
| Forum name | Test Forum 1 |
| Description | Test |
| Ratings > Aggregate type | Average of ratings |
+ | Ratings > Grade to pass | 90 |
| Ratings > scale[modgrade_point] | 60 |
- | Grade > Grade to pass | 90 |
Then I should see "The grade to pass can not be greater than the maximum possible grade 60"
Scenario: Set a valid grade to pass for forum activity
@@ -206,10 +206,10 @@ Feature: We can set the grade to pass value
| Forum name | Test Forum 1 |
| Description | Test |
| Ratings > Aggregate type | Average of ratings |
- | Grade > Grade to pass | 90 |
+ | Ratings > Grade to pass | 90 |
And I navigate to "View > Grader report" in the course gradebook
And I turn editing mode on
- And I click on "Edit forum Test Forum 1" "link"
+ And I click on "Edit forum Rating grade for Test Forum 1" "link"
And I expand all fieldsets
Then the field "Grade to pass" matches value "90"
And I set the field "Grade to pass" to "80"
@@ -217,7 +217,7 @@ Feature: We can set the grade to pass value
And I am on "Course 1" course homepage
And I follow "Test Forum 1"
And I follow "Edit settings"
- And the field "Grade to pass" matches value "80"
+ And the field "Ratings > Grade to pass" matches value "80"
Scenario: Set a valid grade to pass for glossary activity
When I turn editing mode on
diff --git a/grade/tests/component_gradeitems_test.php b/grade/tests/component_gradeitems_test.php
new file mode 100644
index 00000000000..a84e5774f52
--- /dev/null
+++ b/grade/tests/component_gradeitems_test.php
@@ -0,0 +1,653 @@
+.
+
+/**
+ * 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 tests\core_grades {
+
+ use advanced_testcase;
+ use core_grades\component_gradeitems;
+ use coding_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 component_gradeitems_test extends advanced_testcase {
+
+ /**
+ * Ensure that a component which does not implement the mapping class excepts.
+ */
+ public function test_get_itemname_mapping_for_component_does_not_exist(): void {
+ $mappings = component_gradeitems::get_itemname_mapping_for_component('invalid_component');
+ $this->assertIsArray($mappings);
+ $this->assertCount(1, $mappings);
+ $this->assertArrayHasKey(0, $mappings);
+ }
+
+ /**
+ * Ensure that a component which does not implement the mapping class correctly excepts.
+ */
+ public function test_get_itemname_mapping_for_valid_component_invalid_mapping(): void {
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_itemname_mapping_for_component('tests\core_grades\component_gradeitems\invalid');
+ }
+
+ /**
+ * Ensure that a component which implements the mapping class correctly eets the correct set of mappings.
+ */
+ public function test_get_itemname_mapping_for_valid_component_valid_mapping(): void {
+ $mapping = component_gradeitems::get_itemname_mapping_for_component('tests\core_grades\component_gradeitems\valid');
+ $this->assertIsArray($mapping);
+ $this->assertEquals([
+ 0 => 'rating',
+ 1 => 'someother',
+ ], $mapping);
+ }
+
+ /**
+ * Data provider for is_valid_itemname tests.
+ *
+ * @return array
+ */
+ public function is_valid_itemname_provider(): array {
+ return [
+ 'valid' => [
+ 'someother',
+ true,
+ ],
+ 'validnotadvanced' => [
+ 'rating',
+ true,
+ ],
+ 'invalid' => [
+ 'doesnotexist',
+ false,
+ ],
+ ];
+ }
+
+ /**
+ * Ensure that a component implementing advanced grading returns the correct areas.
+ *
+ * @dataProvider is_valid_itemname_provider
+ * @param string $itemname
+ * @param bool $isadvanced
+ */
+ public function test_is_valid_itemname(string $itemname, bool $isadvanced): void {
+ $this->assertEquals(
+ $isadvanced,
+ component_gradeitems::is_valid_itemname('tests\core_grades\component_gradeitems\valid_and_advanced', $itemname)
+ );
+ }
+
+
+ /**
+ * Ensure that a component which does not implement the advancedgrading interface returns this.
+ */
+ public function test_defines_advancedgrading_itemnames_for_component_does_not_exist(): void {
+ $this->assertFalse(component_gradeitems::defines_advancedgrading_itemnames_for_component('invalid_component'));
+ }
+
+ /**
+ * Ensure that a component which does not implement the advancedgrading interface returns this.
+ */
+ public function test_defines_advancedgrading_itemnames_for_component_no_interfaces(): void {
+ $this->assertFalse(component_gradeitems::defines_advancedgrading_itemnames_for_component('tests\core_grades\component_gradeitems\invalid'));
+ }
+
+ /**
+ * Ensure that a component which implements the item mapping but not implement the advancedgrading interface returns this.
+ */
+ public function test_defines_advancedgrading_itemnames_for_component_grading_no_interface(): void {
+ $this->assertFalse(component_gradeitems::defines_advancedgrading_itemnames_for_component('tests\core_grades\component_gradeitems\valid'));
+ }
+
+ /**
+ * Ensure that a component which implements the item mapping but not implement the advancedgrading interface returns this.
+ */
+ public function test_defines_advancedgrading_itemnames_for_component_grading_has_interface(): void {
+ $this->assertTrue(component_gradeitems::defines_advancedgrading_itemnames_for_component('tests\core_grades\component_gradeitems\valid_and_advanced'));
+ }
+
+ /**
+ * Ensure that a component which does not implement the advancedgrading interface returns this.
+ */
+ public function test_get_advancedgrading_itemnames_for_component_does_not_exist(): void {
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_advancedgrading_itemnames_for_component('invalid_component');
+ }
+
+ /**
+ * Ensure that a component which does not implement the advancedgrading interface returns this.
+ */
+ public function test_get_advancedgrading_itemnames_for_component_no_interfaces(): void {
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_advancedgrading_itemnames_for_component('tests\core_grades\component_gradeitems\invalid');
+ }
+
+ /**
+ * Ensure that a component which implements the item mapping but not implement the advancedgrading interface returns this.
+ */
+ public function test_get_advancedgrading_itemnames_for_component_grading_no_interface(): void {
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_advancedgrading_itemnames_for_component('tests\core_grades\component_gradeitems\valid');
+ }
+
+ /**
+ * Ensure that a component implementing advanced grading returns the correct areas.
+ */
+ public function test_get_advancedgrading_itemnames_for_component(): void {
+ $areas = component_gradeitems::get_advancedgrading_itemnames_for_component('tests\core_grades\component_gradeitems\valid_and_advanced');
+ $this->assertEquals(['someother'], $areas);
+ }
+
+ /**
+ * Data provider for is_advancedgrading_itemname tests.
+ *
+ * @return array
+ */
+ public function is_advancedgrading_itemname_provider(): array {
+ return [
+ 'valid' => [
+ 'someother',
+ true,
+ ],
+ 'validnotadvanced' => [
+ 'rating',
+ false,
+ ],
+ 'invalid' => [
+ 'doesnotexist',
+ false,
+ ],
+ ];
+ }
+
+ /**
+ * Ensure that a component implementing advanced grading returns the correct areas.
+ *
+ * @dataProvider is_advancedgrading_itemname_provider
+ * @param string $itemname
+ * @param bool $isadvanced
+ */
+ public function test_is_advancedgrading_itemname(string $itemname, bool $isadvanced): void {
+ $this->assertEquals(
+ $isadvanced,
+ component_gradeitems::is_advancedgrading_itemname('tests\core_grades\component_gradeitems\valid_and_advanced', $itemname)
+ );
+ }
+
+ /**
+ * Data provider for get_field_name_for_itemnumber.
+ *
+ * @return array
+ */
+ public function get_field_name_for_itemnumber_provider(): array {
+ return [
+ 'Valid itemnumber 0 case 1' => [
+ 0,
+ 'gradecat',
+ 'gradecat',
+ ],
+ 'Valid itemnumber 0 case 2' => [
+ 0,
+ 'melon',
+ 'melon',
+ ],
+ 'Valid itemnumber 1 case 1' => [
+ 1,
+ 'gradecat',
+ 'gradecat_someother',
+ ],
+ 'Valid itemnumber 1 case 2' => [
+ 1,
+ 'melon',
+ 'melon_someother',
+ ],
+ ];
+ }
+
+ /**
+ * Ensure that valid field names are correctly mapped for a valid component.
+ *
+ * @dataProvider get_field_name_for_itemnumber_provider
+ * @param int $itemnumber The item itemnumber to test
+ * @param string $fieldname The field name being translated
+ * @param string $expected The expected value
+ */
+ public function test_get_field_name_for_itemnumber(int $itemnumber, string $fieldname, string $expected): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+ $this->assertEquals($expected, component_gradeitems::get_field_name_for_itemnumber($component, $itemnumber, $fieldname));
+ }
+
+ /**
+ * Ensure that an invalid itemnumber does not provide any field name.
+ */
+ public function test_get_field_name_for_itemnumber_invalid_itemnumber(): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_field_name_for_itemnumber($component, 100, 'gradecat');
+ }
+
+ /**
+ * Ensure that a component which does not define a mapping can still get a mapping for itemnumber 0.
+ */
+ public function test_get_field_name_for_itemnumber_component_not_defining_mapping_itemnumber_zero(): void {
+ $component = 'tests\core_grades\othervalid';
+
+ $this->assertEquals('gradecat', component_gradeitems::get_field_name_for_itemnumber($component, 0, 'gradecat'));
+ }
+
+ /**
+ * Ensure that a component which does not define a mapping cannot get a mapping for itemnumber 1+.
+ */
+ public function test_get_field_name_for_itemnumber_component_not_defining_mapping_itemnumber_nonzero(): void {
+ $component = 'tests\core_grades\othervalid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_field_name_for_itemnumber($component, 100, 'gradecat');
+ }
+
+ /**
+ * Ensure that a component which incorrectly defines a mapping cannot get a mapping for itemnumber 1+.
+ */
+ public function test_get_field_name_for_itemnumber_component_invalid_mapping_itemnumber_nonzero(): void {
+ $component = 'tests\core_grades\component_gradeitems\invalid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_field_name_for_itemnumber($component, 100, 'gradecat');
+ }
+
+ /**
+ * Data provider for get_field_name_for_itemname.
+ *
+ * @return array
+ */
+ public function get_field_name_for_itemname_provider(): array {
+ return [
+ 'Empty itemname empty case 1' => [
+ '',
+ 'gradecat',
+ 'gradecat',
+ ],
+ 'Empty itemname empty case 2' => [
+ '',
+ 'melon',
+ 'melon',
+ ],
+ 'First itemname empty case 1' => [
+ 'rating',
+ 'gradecat',
+ 'gradecat',
+ ],
+ 'First itemname empty case 2' => [
+ 'rating',
+ 'melon',
+ 'melon',
+ ],
+ 'Other itemname empty case 1' => [
+ 'someother',
+ 'gradecat',
+ 'gradecat_someother',
+ ],
+ 'Other itemname empty case 2' => [
+ 'someother',
+ 'melon',
+ 'melon_someother',
+ ],
+ ];
+ }
+
+ /**
+ * Ensure that valid field names are correctly mapped for a valid component.
+ *
+ * @dataProvider get_field_name_for_itemname_provider
+ * @param string $itemname The item itemname to test
+ * @param string $fieldname The field name being translated
+ * @param string $expected The expected value
+ */
+ public function test_get_field_name_for_itemname(string $itemname, string $fieldname, string $expected): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+ $this->assertEquals($expected, component_gradeitems::get_field_name_for_itemname($component, $itemname, $fieldname));
+ }
+
+ /**
+ * Ensure that an invalid itemname does not provide any field name.
+ */
+ public function test_get_field_name_for_itemname_invalid_itemname(): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_field_name_for_itemname($component, 'typo', 'gradecat');
+ }
+
+ /**
+ * Ensure that an empty itemname provides a matching fieldname regardless of whether the component exists or
+ * not.
+ */
+ public function test_get_field_name_for_itemname_not_defining_mapping_empty_name(): void {
+ $component = 'tests\core_grades\othervalid';
+
+ $this->assertEquals('gradecat', component_gradeitems::get_field_name_for_itemname($component, '', 'gradecat'));
+ }
+
+ /**
+ * Ensure that an valid component with some itemname excepts.
+ */
+ public function test_get_field_name_for_itemname_not_defining_mapping_with_name(): void {
+ $component = 'tests\core_grades\othervalid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_field_name_for_itemname($component, 'example', 'gradecat');
+ }
+
+ /**
+ * Ensure that an empty itemname provides a matching fieldname even if the mapping is invalid.
+ */
+ public function test_get_field_name_for_itemname_invalid_mapping_empty_name(): void {
+ $component = 'tests\core_grades\component_gradeitems\invalid';
+
+ $this->assertEquals('gradecat', component_gradeitems::get_field_name_for_itemname($component, '', 'gradecat'));
+ }
+
+ /**
+ * Ensure that an invalid mapping with some itemname excepts.
+ */
+ public function test_get_field_name_for_itemname_invalid_mapping_with_name(): void {
+ $component = 'tests\core_grades\component_gradeitems\invalid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_field_name_for_itemname($component, 'example', 'gradecat');
+ }
+
+ /**
+ * Data provider for get_itemname_from_itemnumber.
+ *
+ * @return array
+ */
+ public function get_itemname_from_itemnumber_provider(): array {
+ return [
+ 'Valid itemnumber 0' => [
+ 0,
+ '',
+ ],
+ 'Valid itemnumber 1' => [
+ 1,
+ 'someother',
+ ],
+ ];
+ }
+
+ /**
+ * Ensure that item names are correctly mapped for a valid component.
+ *
+ * @dataProvider get_itemname_from_itemnumber_provider
+ * @param int $itemnumber The item itemnumber to test
+ * @param string $expected The expected value
+ */
+ public function test_get_itemname_from_itemnumber(int $itemnumber, string $expected): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+ $this->assertEquals($expected, component_gradeitems::get_itemname_from_itemnumber($component, $itemnumber));
+ }
+
+ /**
+ * Ensure that an itemnumber over 1000 is treated as itemnumber 0 for the purpose of outcomes.
+ */
+ public function test_get_itemname_from_itemnumber_outcome_itemnumber(): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+
+ $this->assertEquals('', component_gradeitems::get_itemname_from_itemnumber($component, 1000));
+ }
+
+ /**
+ * Ensure that an invalid itemnumber does not provide any field name.
+ */
+ public function test_get_itemname_from_itemnumber_invalid_itemnumber(): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_itemname_from_itemnumber($component, 100);
+ }
+
+ /**
+ * Ensure that a component which does not define a mapping can still get a mapping for itemnumber 0.
+ */
+ public function test_get_itemname_from_itemnumber_component_not_defining_mapping_itemnumber_zero(): void {
+ $component = 'tests\core_grades\othervalid';
+
+ $this->assertEquals('', component_gradeitems::get_itemname_from_itemnumber($component, 0));
+ }
+
+ /**
+ * Ensure that a component which does not define a mapping cannot get a mapping for itemnumber 1+.
+ */
+ public function test_get_itemname_from_itemnumber_component_not_defining_mapping_itemnumber_nonzero(): void {
+ $component = 'tests\core_grades\othervalid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_itemname_from_itemnumber($component, 100);
+ }
+
+ /**
+ * Ensure that a component which incorrectly defines a mapping cannot get a mapping for itemnumber 1+.
+ */
+ public function test_get_itemname_from_itemnumber_component_invalid_mapping_itemnumber_nonzero(): void {
+ $component = 'tests\core_grades\component_gradeitems\invalid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_itemname_from_itemnumber($component, 100);
+ }
+
+ /**
+ * Data provider for get_itemname_from_itemnumber.
+ *
+ * @return array
+ */
+ public function get_itemnumber_from_itemname_provider(): array {
+ return [
+ 'Empty itemname empty' => [
+ '',
+ 0,
+ ],
+ 'First itemname empty' => [
+ 'rating',
+ 0,
+ ],
+ 'Other itemname empty' => [
+ 'someother',
+ 1,
+ ],
+ ];
+ }
+
+ /**
+ * Ensure that valid item names are correctly mapped for a valid component.
+ *
+ * @dataProvider get_itemnumber_from_itemname_provider
+ * @param string $itemname The item itemname to test
+ * @param int $expected The expected value
+ */
+ public function test_get_itemnumber_from_itemname(string $itemname, int $expected): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+ $this->assertEquals($expected, component_gradeitems::get_itemnumber_from_itemname($component, $itemname));
+ }
+
+ /**
+ * Ensure that an invalid itemname excepts.
+ */
+ public function test_get_itemnumber_from_itemname_invalid_itemname(): void {
+ $component = 'tests\core_grades\component_gradeitems\valid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_itemnumber_from_itemname($component, 'typo');
+ }
+
+ /**
+ * Ensure that an empty itemname provides a correct itemnumber regardless of whether the component exists or
+ * not.
+ */
+ public function test_get_itemnumber_from_itemname_not_defining_mapping_empty_name(): void {
+ $component = 'tests\core_grades\component_gradeitems\othervalid';
+
+ $this->assertEquals(0, component_gradeitems::get_itemnumber_from_itemname($component, ''));
+ }
+
+ /**
+ * Ensure that an valid component with some itemname excepts.
+ */
+ public function test_get_itemnumber_from_itemname_not_defining_mapping_with_name(): void {
+ $component = 'tests\core_grades\component_gradeitems\othervalid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_itemnumber_from_itemname($component, 'example');
+ }
+
+ /**
+ * Ensure that an empty itemname provides a matching fieldname even if the mapping is invalid.
+ */
+ public function test_get_itemnumber_from_itemname_invalid_mapping_empty_name(): void {
+ $component = 'tests\core_grades\component_gradeitems\invalid';
+
+ $this->assertEquals(0, component_gradeitems::get_itemnumber_from_itemname($component, ''));
+ }
+
+ /**
+ * Ensure that an invalid mapping with some itemname excepts.
+ */
+ public function test_get_itemnumber_from_itemname_invalid_mapping_with_name(): void {
+ $component = 'tests\core_grades\component_gradeitems\invalid';
+
+ $this->expectException(coding_exception::class);
+ component_gradeitems::get_itemnumber_from_itemname($component, 'example');
+ }
+ }
+}
+
+namespace tests\core_grades\component_gradeitems\valid\grades {
+ use core_grades\local\gradeitem\itemnumber_mapping;
+
+ /**
+ * Valid class for testing mappings.
+ *
+ * @package core_grades
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+ class gradeitems implements itemnumber_mapping {
+ /**
+ * Get the grade item mapping of item number to item name.
+ *
+ * @return array
+ */
+ public static function get_itemname_mapping_for_component(): array {
+ return [
+ 0 => 'rating',
+ 1 => 'someother',
+ ];
+ }
+ }
+}
+
+namespace tests\core_grades\component_gradeitems\valid_and_advanced\grades {
+ use core_grades\local\gradeitem\itemnumber_mapping;
+ use core_grades\local\gradeitem\advancedgrading_mapping;
+
+ /**
+ * Valid class for testing mappings.
+ *
+ * @package core_grades
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+ class gradeitems implements itemnumber_mapping, advancedgrading_mapping {
+ /**
+ * Get the grade item mapping of item number to item name.
+ *
+ * @return array
+ */
+ public static function get_itemname_mapping_for_component(): array {
+ return [
+ 0 => 'rating',
+ 1 => 'someother',
+ ];
+ }
+
+ /**
+ * Get the list of items which define advanced grading.
+ *
+ * @return array
+ */
+ public static function get_advancedgrading_itemnames(): array {
+ return [
+ 'someother',
+ ];
+ }
+ }
+}
+
+namespace tests\core_grades\component_gradeitems\invalid\grades {
+ use core_grades\local\gradeitem\itemnumber_mapping;
+
+ /**
+ * Invalid class for testing mappings.
+ *
+ * @package core_grades
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+ class gradeitems {
+ /**
+ * Get the grade item mapping of item number to item name.
+ *
+ * @return array
+ */
+ public static function get_itemname_mapping_for_component(): array {
+ return [
+ 0 => 'rating',
+ 1 => 'someother',
+ ];
+ }
+
+ /**
+ * Get the list of items which define advanced grading.
+ *
+ * @return array
+ */
+ public static function get_advancedgrading_itemnames(): array {
+ return [
+ 1 => 'someother',
+ ];
+ }
+ }
+}
diff --git a/grade/tests/coverage.php b/grade/tests/coverage.php
new file mode 100644
index 00000000000..7480fcc88bb
--- /dev/null
+++ b/grade/tests/coverage.php
@@ -0,0 +1,40 @@
+.
+
+/**
+ * Coverage information for the grades component.
+ *
+ * @package core_grades
+ * @category test
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Coverage information for the core_grades subsystem.
+ *
+ * @package core_grades
+ * @copyright 2019 Andrew Nicols
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+return new class extends phpunit_coverage_info {
+ // Array The list of folders relative to the plugin root to whitelist in coverage generation.
+ protected $whitelistfolders = [
+ 'classes',
+ ];
+};
diff --git a/grade/tests/grades_grader_gradingpanel_point_external_fetch_test.php b/grade/tests/grades_grader_gradingpanel_point_external_fetch_test.php
new file mode 100644
index 00000000000..66db17e7d0a
--- /dev/null
+++ b/grade/tests/grades_grader_gradingpanel_point_external_fetch_test.php
@@ -0,0 +1,200 @@
+.
+
+/**
+ * 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\point\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' => -1,
+ ]);
+ $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 direct grading");
+ 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();
+
+ $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');
+
+ $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/point', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+ $this->assertArrayHasKey('grade', $result['grade']);
+ $this->assertEmpty($result['grade']['grade']);
+ $this->assertArrayHasKey('timecreated', $result['grade']);
+ $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();
+
+ $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');
+ $gradeitem->store_grade_from_formdata($student, $teacher, (object) [
+ 'grade' => 4,
+ ]);
+
+ $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/point', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+ $this->assertArrayHasKey('grade', $result['grade']);
+ $this->assertIsFloat($result['grade']['grade']);
+ $this->assertEquals(grade_floatval(unformat_float(4)), $result['grade']['grade']);
+ $this->assertArrayHasKey('timecreated', $result['grade']);
+ $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_point_external_store_test.php b/grade/tests/grades_grader_gradingpanel_point_external_store_test.php
new file mode 100644
index 00000000000..b9c47603479
--- /dev/null
+++ b/grade/tests/grades_grader_gradingpanel_point_external_store_test.php
@@ -0,0 +1,315 @@
+.
+
+/**
+ * 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\point\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' => -1,
+ ]);
+ $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 direct grading");
+ 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 {
+ $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);
+
+ $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/point', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+ $this->assertArrayHasKey('grade', $result['grade']);
+ $this->assertEmpty($result['grade']['grade']);
+ $this->assertArrayHasKey('timecreated', $result['grade']);
+ $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_graded(): 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);
+
+ $formdata = [
+ 'grade' => 4,
+ ];
+ $formattedvalue = grade_floatval(unformat_float(4));
+
+ $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/point', $result['templatename']);
+
+ $this->assertArrayHasKey('grade', $result);
+ $this->assertIsArray($result['grade']);
+ $this->assertArrayHasKey('grade', $result['grade']);
+ $this->assertEquals($formattedvalue, $result['grade']['grade']);
+ $this->assertArrayHasKey('timecreated', $result['grade']);
+ $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);
+ }
+
+ /**
+ * Ensure that an out-of-range value is rejected.
+ *
+ * @dataProvider execute_out_of_range_provider
+ * @param int $maxvalue The max value of the forum
+ * @param int $suppliedvalue The value that was submitted
+ */
+ public function test_execute_store_out_of__range(int $maxvalue, int $suppliedvalue): void {
+ $this->resetAfterTest();
+
+ $forum = $this->get_forum_instance([
+ // Negative numbers mean a scale.
+ 'grade_forum' => $maxvalue,
+ ]);
+ $course = $forum->get_course_record();
+ $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+ $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+ $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 {$maxvalue}.");
+ 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' => [
+ 'max' => 100,
+ 'supplied' => 101,
+ ],
+ 'above just' => [
+ 'max' => 100,
+ 'supplied' => 101.001,
+ ],
+ 'below' => [
+ 'max' => 100,
+ 'supplied' => -100,
+ ],
+ '-1' => [
+ 'max' => 100,
+ 'supplied' => -1,
+ ],
+ ];
+ }
+
+
+ /**
+ * 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_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/lang/en/grades.php b/lang/en/grades.php
index ff8fc09613e..8afc3ed4bc7 100644
--- a/lang/en/grades.php
+++ b/lang/en/grades.php
@@ -245,6 +245,7 @@ $string['full'] = 'Full';
$string['fullmode'] = 'Change to full view';
$string['generalsettings'] = 'General settings';
$string['grade'] = 'Grade';
+$string['grade_help'] = 'The grade to award this person for their work.';
$string['gradeadministration'] = 'Grade administration';
$string['gradealreadyupdated'] = '{$a} grades have not been imported because the grades in the import file are older than in the grader report. To proceed with the grade import anyway, use the force import option.';
$string['gradeanalysis'] = 'Grade analysis';
diff --git a/lang/en/grading.php b/lang/en/grading.php
index 46e3c27eda1..dd68d9100c2 100644
--- a/lang/en/grading.php
+++ b/lang/en/grading.php
@@ -94,3 +94,4 @@ $string['templatepickownform'] = 'Use this form as a template';
$string['templatetypeown'] = 'Own form';
$string['templatetypeshared'] = 'Shared template';
$string['templatesource'] = 'Location: {$a->component} ({$a->area})';
+$string['error:notinrange'] = 'Invalid grade \'{$a->grade}\' provided. Grades must be between 0 and {$a->maxgrade}.';
diff --git a/lang/en/moodle.php b/lang/en/moodle.php
index 4fd52e3589a..ef4c7cece3f 100644
--- a/lang/en/moodle.php
+++ b/lang/en/moodle.php
@@ -512,6 +512,7 @@ $string['digitalminor_desc'] = 'Please ask your parent/guardian to contact:';
$string['directory'] = 'Directory';
$string['disable'] = 'Disable';
$string['disabledcomments'] = 'Comments are disabled';
+$string['dismissnotification'] = 'Dismiss this notification';
$string['displayingfirst'] = 'Only the first {$a->count} {$a->things} are displayed';
$string['displayingrecords'] = 'Displaying {$a} records';
$string['displayingusers'] = 'Displaying users {$a->start} to {$a->end}';
diff --git a/lib/amd/build/loadingicon.min.js b/lib/amd/build/loadingicon.min.js
index d1ad15e616e..7b6cef913ce 100644
--- a/lib/amd/build/loadingicon.min.js
+++ b/lib/amd/build/loadingicon.min.js
@@ -1,2 +1,2 @@
-define ("core/loadingicon",["jquery","core/templates"],function(a,b){var c={LOADING:"core/loading"},d=function(){return b.render(c.LOADING,{})},e=function(b,c){return d().then(function(d){var e=a(d).hide();b.append(e);e.fadeIn(150);return a.when(e.promise(),c)}).then(function(a){return a.fadeOut(100).promise()}).then(function(a){a.remove()})};return{getIcon:d,addIconToContainer:function addIconToContainer(b){return d().then(function(c){var d=a(c).hide();b.append(d);d.fadeIn(150);return d})},addIconToContainerWithPromise:function addIconToContainerWithPromise(b){var c=a.Deferred();e(b,c);return c},addIconToContainerRemoveOnCompletion:e}});
+define ("core/loadingicon",["jquery","core/templates"],function(a,b){var c={LOADING:"core/loading"},d=function(){return b.render(c.LOADING,{})},e=function(b,c){return d().then(function(d){var e=a(d).hide();a(b).append(e);e.fadeIn(150);return a.when(e.promise(),c)}).then(function(a){return a.fadeOut(100).promise()}).then(function(a){a.remove()})};return{getIcon:d,addIconToContainer:function addIconToContainer(b){return d().then(function(c){var d=a(c).hide();a(b).append(d);d.fadeIn(150);return d})},addIconToContainerWithPromise:function addIconToContainerWithPromise(b){var c=a.Deferred();e(b,c);return c},addIconToContainerRemoveOnCompletion:e}});
//# sourceMappingURL=loadingicon.min.js.map
diff --git a/lib/amd/build/loadingicon.min.js.map b/lib/amd/build/loadingicon.min.js.map
index 24d3c453017..000d618230a 100644
--- a/lib/amd/build/loadingicon.min.js.map
+++ b/lib/amd/build/loadingicon.min.js.map
@@ -1 +1 @@
-{"version":3,"sources":["../src/loadingicon.js"],"names":["define","$","Templates","TEMPLATES","LOADING","getIcon","render","addIconToContainerRemoveOnCompletion","container","loadingIconPromise","then","html","loadingIcon","hide","append","fadeIn","when","promise","fadeOut","remove","addIconToContainer","addIconToContainerWithPromise","Deferred"],"mappings":"AAwBAA,OAAM,oBAAC,CAAC,QAAD,CAAW,gBAAX,CAAD,CAA+B,SAASC,CAAT,CAAYC,CAAZ,CAAuB,IACpDC,CAAAA,CAAS,CAAG,CACZC,OAAO,CAAE,cADG,CADwC,CAKpDC,CAAO,CAAG,UAAW,CACrB,MAAOH,CAAAA,CAAS,CAACI,MAAV,CAAiBH,CAAS,CAACC,OAA3B,CAAoC,EAApC,CACV,CAPuD,CAuCpDG,CAAoC,CAAG,SAASC,CAAT,CAAoBC,CAApB,CAAwC,CAC/E,MAAOJ,CAAAA,CAAO,GACbK,IADM,CACD,SAASC,CAAT,CAAe,CACjB,GAAIC,CAAAA,CAAW,CAAGX,CAAC,CAACU,CAAD,CAAD,CAAQE,IAAR,EAAlB,CACAL,CAAS,CAACM,MAAV,CAAiBF,CAAjB,EACAA,CAAW,CAACG,MAAZ,CAAmB,GAAnB,EAEA,MAAOd,CAAAA,CAAC,CAACe,IAAF,CAAOJ,CAAW,CAACK,OAAZ,EAAP,CAA8BR,CAA9B,CACV,CAPM,EAQNC,IARM,CAQD,SAASE,CAAT,CAAsB,CAIxB,MAAOA,CAAAA,CAAW,CAACM,OAAZ,CAAoB,GAApB,EAAyBD,OAAzB,EACV,CAbM,EAcNP,IAdM,CAcD,SAASE,CAAT,CAAsB,CACxBA,CAAW,CAACO,MAAZ,EAGH,CAlBM,CAmBV,CA3DuD,CA8ExD,MAAO,CACHd,OAAO,CAAEA,CADN,CAEHe,kBAAkB,CA9DG,QAArBA,CAAAA,kBAAqB,CAASZ,CAAT,CAAoB,CACzC,MAAOH,CAAAA,CAAO,GACbK,IADM,CACD,SAASC,CAAT,CAAe,CACjB,GAAIC,CAAAA,CAAW,CAAGX,CAAC,CAACU,CAAD,CAAD,CAAQE,IAAR,EAAlB,CACAL,CAAS,CAACM,MAAV,CAAiBF,CAAjB,EACAA,CAAW,CAACG,MAAZ,CAAmB,GAAnB,EAEA,MAAOH,CAAAA,CACV,CAPM,CAQV,CAmDM,CAGHS,6BAA6B,CAXG,QAAhCA,CAAAA,6BAAgC,CAASb,CAAT,CAAoB,CACpD,GAAIC,CAAAA,CAAkB,CAAGR,CAAC,CAACqB,QAAF,EAAzB,CAEAf,CAAoC,CAACC,CAAD,CAAYC,CAAZ,CAApC,CAEA,MAAOA,CAAAA,CACV,CAEM,CAIHF,oCAAoC,CAAEA,CAJnC,CAOV,CArFK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Contain the logic for the loading icon.\n *\n * @module core/loading_icon\n * @class loading_icon\n * @package core\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery', 'core/templates'], function($, Templates) {\n var TEMPLATES = {\n LOADING: 'core/loading',\n };\n\n var getIcon = function() {\n return Templates.render(TEMPLATES.LOADING, {});\n };\n\n /**\n * Add a loading icon to the end of the specified container and return an unresolved promise.\n *\n * Resolution of the returned promise causes the icon to be faded out and removed.\n *\n * @method addIconToContainer\n * @param {jQuery} container The element to add the spinner to\n * @return {Promise} The Promise used to create the icon.\n */\n var addIconToContainer = function(container) {\n return getIcon()\n .then(function(html) {\n var loadingIcon = $(html).hide();\n container.append(loadingIcon);\n loadingIcon.fadeIn(150);\n\n return loadingIcon;\n });\n };\n\n /**\n * Add a loading icon to the end of the specified container and return an unresolved promise.\n *\n * Resolution of the returned promise causes the icon to be faded out and removed.\n *\n * @method addIconToContainerWithPromise\n * @param {jQuery} container The element to add the spinner to\n * @param {Promise} loadingIconPromise The jQuery Promise which determines the removal of the icon\n * @return {jQuery} The Promise used to create and then remove the icon.\n */\n var addIconToContainerRemoveOnCompletion = function(container, loadingIconPromise) {\n return getIcon()\n .then(function(html) {\n var loadingIcon = $(html).hide();\n container.append(loadingIcon);\n loadingIcon.fadeIn(150);\n\n return $.when(loadingIcon.promise(), loadingIconPromise);\n })\n .then(function(loadingIcon) {\n // Once the content has finished loading and\n // the loading icon has been shown then we can\n // fade the icon away to reveal the content.\n return loadingIcon.fadeOut(100).promise();\n })\n .then(function(loadingIcon) {\n loadingIcon.remove();\n\n return;\n });\n };\n\n /**\n * Add a loading icon to the end of the specified container and return an unresolved promise.\n *\n * Resolution of the returned promise causes the icon to be faded out and removed.\n *\n * @method addIconToContainerWithPromise\n * @param {jQuery} container The element to add the spinner to\n * @return {Promise} A jQuery Promise to resolve when ready\n */\n var addIconToContainerWithPromise = function(container) {\n var loadingIconPromise = $.Deferred();\n\n addIconToContainerRemoveOnCompletion(container, loadingIconPromise);\n\n return loadingIconPromise;\n };\n\n return {\n getIcon: getIcon,\n addIconToContainer: addIconToContainer,\n addIconToContainerWithPromise: addIconToContainerWithPromise,\n addIconToContainerRemoveOnCompletion: addIconToContainerRemoveOnCompletion,\n };\n\n});\n"],"file":"loadingicon.min.js"}
\ No newline at end of file
+{"version":3,"sources":["../src/loadingicon.js"],"names":["define","$","Templates","TEMPLATES","LOADING","getIcon","render","addIconToContainerRemoveOnCompletion","container","loadingIconPromise","then","html","loadingIcon","hide","append","fadeIn","when","promise","fadeOut","remove","addIconToContainer","addIconToContainerWithPromise","Deferred"],"mappings":"AAwBAA,OAAM,oBAAC,CAAC,QAAD,CAAW,gBAAX,CAAD,CAA+B,SAASC,CAAT,CAAYC,CAAZ,CAAuB,IACpDC,CAAAA,CAAS,CAAG,CACZC,OAAO,CAAE,cADG,CADwC,CAKpDC,CAAO,CAAG,UAAW,CACrB,MAAOH,CAAAA,CAAS,CAACI,MAAV,CAAiBH,CAAS,CAACC,OAA3B,CAAoC,EAApC,CACV,CAPuD,CAuCpDG,CAAoC,CAAG,SAASC,CAAT,CAAoBC,CAApB,CAAwC,CAC/E,MAAOJ,CAAAA,CAAO,GACbK,IADM,CACD,SAASC,CAAT,CAAe,CACjB,GAAIC,CAAAA,CAAW,CAAGX,CAAC,CAACU,CAAD,CAAD,CAAQE,IAAR,EAAlB,CACAZ,CAAC,CAACO,CAAD,CAAD,CAAaM,MAAb,CAAoBF,CAApB,EACAA,CAAW,CAACG,MAAZ,CAAmB,GAAnB,EAEA,MAAOd,CAAAA,CAAC,CAACe,IAAF,CAAOJ,CAAW,CAACK,OAAZ,EAAP,CAA8BR,CAA9B,CACV,CAPM,EAQNC,IARM,CAQD,SAASE,CAAT,CAAsB,CAIxB,MAAOA,CAAAA,CAAW,CAACM,OAAZ,CAAoB,GAApB,EAAyBD,OAAzB,EACV,CAbM,EAcNP,IAdM,CAcD,SAASE,CAAT,CAAsB,CACxBA,CAAW,CAACO,MAAZ,EAGH,CAlBM,CAmBV,CA3DuD,CA8ExD,MAAO,CACHd,OAAO,CAAEA,CADN,CAEHe,kBAAkB,CA9DG,QAArBA,CAAAA,kBAAqB,CAASZ,CAAT,CAAoB,CACzC,MAAOH,CAAAA,CAAO,GACbK,IADM,CACD,SAASC,CAAT,CAAe,CACjB,GAAIC,CAAAA,CAAW,CAAGX,CAAC,CAACU,CAAD,CAAD,CAAQE,IAAR,EAAlB,CACAZ,CAAC,CAACO,CAAD,CAAD,CAAaM,MAAb,CAAoBF,CAApB,EACAA,CAAW,CAACG,MAAZ,CAAmB,GAAnB,EAEA,MAAOH,CAAAA,CACV,CAPM,CAQV,CAmDM,CAGHS,6BAA6B,CAXG,QAAhCA,CAAAA,6BAAgC,CAASb,CAAT,CAAoB,CACpD,GAAIC,CAAAA,CAAkB,CAAGR,CAAC,CAACqB,QAAF,EAAzB,CAEAf,CAAoC,CAACC,CAAD,CAAYC,CAAZ,CAApC,CAEA,MAAOA,CAAAA,CACV,CAEM,CAIHF,oCAAoC,CAAEA,CAJnC,CAOV,CArFK,CAAN","sourcesContent":["// This file is part of Moodle - http://moodle.org/\n//\n// Moodle is free software: you can redistribute it and/or modify\n// it under the terms of the GNU General Public License as published by\n// the Free Software Foundation, either version 3 of the License, or\n// (at your option) any later version.\n//\n// Moodle is distributed in the hope that it will be useful,\n// but WITHOUT ANY WARRANTY; without even the implied warranty of\n// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the\n// GNU General Public License for more details.\n//\n// You should have received a copy of the GNU General Public License\n// along with Moodle. If not, see .\n\n/**\n * Contain the logic for the loading icon.\n *\n * @module core/loading_icon\n * @class loading_icon\n * @package core\n * @copyright 2019 Andrew Nicols \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n */\ndefine(['jquery', 'core/templates'], function($, Templates) {\n var TEMPLATES = {\n LOADING: 'core/loading',\n };\n\n var getIcon = function() {\n return Templates.render(TEMPLATES.LOADING, {});\n };\n\n /**\n * Add a loading icon to the end of the specified container and return an unresolved promise.\n *\n * Resolution of the returned promise causes the icon to be faded out and removed.\n *\n * @method addIconToContainer\n * @param {jQuery} container The element to add the spinner to\n * @return {Promise} The Promise used to create the icon.\n */\n var addIconToContainer = function(container) {\n return getIcon()\n .then(function(html) {\n var loadingIcon = $(html).hide();\n $(container).append(loadingIcon);\n loadingIcon.fadeIn(150);\n\n return loadingIcon;\n });\n };\n\n /**\n * Add a loading icon to the end of the specified container and return an unresolved promise.\n *\n * Resolution of the returned promise causes the icon to be faded out and removed.\n *\n * @method addIconToContainerWithPromise\n * @param {jQuery} container The element to add the spinner to\n * @param {Promise} loadingIconPromise The jQuery Promise which determines the removal of the icon\n * @return {jQuery} The Promise used to create and then remove the icon.\n */\n var addIconToContainerRemoveOnCompletion = function(container, loadingIconPromise) {\n return getIcon()\n .then(function(html) {\n var loadingIcon = $(html).hide();\n $(container).append(loadingIcon);\n loadingIcon.fadeIn(150);\n\n return $.when(loadingIcon.promise(), loadingIconPromise);\n })\n .then(function(loadingIcon) {\n // Once the content has finished loading and\n // the loading icon has been shown then we can\n // fade the icon away to reveal the content.\n return loadingIcon.fadeOut(100).promise();\n })\n .then(function(loadingIcon) {\n loadingIcon.remove();\n\n return;\n });\n };\n\n /**\n * Add a loading icon to the end of the specified container and return an unresolved promise.\n *\n * Resolution of the returned promise causes the icon to be faded out and removed.\n *\n * @method addIconToContainerWithPromise\n * @param {jQuery} container The element to add the spinner to\n * @return {Promise} A jQuery Promise to resolve when ready\n */\n var addIconToContainerWithPromise = function(container) {\n var loadingIconPromise = $.Deferred();\n\n addIconToContainerRemoveOnCompletion(container, loadingIconPromise);\n\n return loadingIconPromise;\n };\n\n return {\n getIcon: getIcon,\n addIconToContainer: addIconToContainer,\n addIconToContainerWithPromise: addIconToContainerWithPromise,\n addIconToContainerRemoveOnCompletion: addIconToContainerRemoveOnCompletion,\n };\n\n});\n"],"file":"loadingicon.min.js"}
\ No newline at end of file
diff --git a/lib/amd/build/templates.min.js b/lib/amd/build/templates.min.js
index 15471dc7473..aefca081876 100644
--- a/lib/amd/build/templates.min.js
+++ b/lib/amd/build/templates.min.js
@@ -1,2 +1,2 @@
-define ("core/templates",["core/mustache","jquery","core/ajax","core/str","core/notification","core/url","core/config","core/localstorage","core/icon_system","core/event","core/yui","core/log","core/truncate","core/user_date","core/pending"],function(a,b,c,d,e,f,g,h,i,j,k,l,m,n,o){var p=0,q={},r={},s={},t={},u=[],v=!1,w=["js"],x=function(a){if(0>=M.cfg.templaterev){return null}if(a in r){return r[a]}if(a in q){r[a]=b.Deferred().resolve(q[a]).promise();return r[a]}var c=h.get("core_template/"+M.cfg.templaterev+":"+a);if(c){q[a]=c;r[a]=b.Deferred().resolve(c).promise();return r[a]}return null},y=function(){if(!u.length){return}if(v){return}v=!0;var a=u.slice(),e=b.Deferred(),f=[],g=a.map(function(a){var c=a.component,i=a.name,j=a.searchKey,k=a.theme,l=a.deferred,m=null,n=x(j);if(n){m=n}else{f.push({methodname:"core_output_load_template_with_dependencies",args:{component:c,template:i,themename:k,lang:b("html").attr("lang").replace(/-/g,"_")}});var o=f.length-1;m=e.promise().then(function(a){g[j]=a[o].then(function(a){var b=null;a.templates.forEach(function(a){var d=[k,a.component,a.name].join("/");q[d]=a.value;h.set("core_template/"+M.cfg.templaterev+":"+d,a.value);if(a.component==c&&a.name==i){b=a.value}});if(a.strings.length){d.cache_strings(a.strings.map(function(a){return{component:a.component,key:a.name,value:a.value}}))}return b});return g[j]})}return m.then(function(a){return l.resolve(a)}).catch(function(a){l.reject(a);throw a})});if(f.length){e.resolve(c.call(f,!0,!1,!1,0,M.cfg.templaterev))}else{e.resolve()}b.when.apply(null,g).then(function(){u.splice(0,a.length);v=!1;y()}).catch(function(){u.splice(0,a.length);v=!1;y()})},z=function(){this.requiredStrings=[];this.requiredJS=[];this.requiredDates=[];this.currentThemeName=""};z.prototype.requiredStrings=null;z.prototype.requiredDates=[];z.prototype.requiredJS=null;z.prototype.currentThemeName="";z.prototype.getTemplate=function(a){var c=this.currentThemeName,d=c+"/"+a,e=x(d);if(e){return e}var f=u.filter(function(a){return a.searchKey==d});if(f.length){return f[0].deferred.promise()}var g=a.split("/"),h=g.shift(),i=g.join("/"),j=b.Deferred();u.push({component:h,name:i,theme:c,searchKey:d,deferred:j});y();return j.promise()};z.prototype.partialHelper=function(a){var b=this.currentThemeName+"/"+a;if(!(b in q)){e.exception(new Error("Failed to pre-fetch the template: "+a))}return q[b]};z.prototype.renderIcon=function(a,c,d){var f=g.iconsystemmodule,h=b.Deferred();require([f],function(a){var b=new a;if(!(b instanceof i)){h.reject("Invalid icon system specified"+g.iconsystemmodule)}else{t=b;b.init().then(h.resolve).catch(e.exception)}});return h.then(function(a){return this.getTemplate(a.getTemplateName())}.bind(this)).then(function(b){return t.renderIcon(a,c,d,b)})};z.prototype.pixHelper=function(a,b,c){var d=b.split(","),e="",f="",g="";if(0=}}$1<%={{ }}=%>").replace(/(\r\n|\r|\n)/g,"
");return"\""+d+"\""};z.prototype.shortenTextHelper=function(a,b,c){var d=/(.*?),(.*)/,e=b.match(d),f=e[1].trim(),g=e[2].trim(),h=c(g,a);return m.truncate(h,{length:f,words:!0,ellipsis:"..."})};z.prototype.userDateHelper=function(a,b,c){var d=/(.*?),(.*)/,e=b.match(d),f=c(e[1].trim(),a),g=c(e[2].trim(),a),h=this.requiredDates.length;this.requiredDates.push({timestamp:f,format:g});return"[[_t_"+h+"]]"};z.prototype.addHelperFunction=function(a,b){return function(){return function(c,d){var e=w.reduce(function(a,c){if(b.hasOwnProperty(c)){a[c]=b[c]}return a},{});w.forEach(function(a){b[a]=function(){return""}});var f=a.apply(this,[b,c,d]);for(var g in e){b[g]=e[g]}return f}.bind(this)}.bind(this)};z.prototype.addHelpers=function(a,b){this.currentThemeName=b;this.requiredStrings=[];this.requiredJS=[];a.uniqid=p++;a.str=this.addHelperFunction(this.stringHelper,a);a.pix=this.addHelperFunction(this.pixHelper,a);a.js=this.addHelperFunction(this.jsHelper,a);a.quote=this.addHelperFunction(this.quoteHelper,a);a.shortentext=this.addHelperFunction(this.shortenTextHelper,a);a.userdate=this.addHelperFunction(this.userDateHelper,a);a.globals={config:g};a.currentTheme=b};z.prototype.getJS=function(){var a="";if(0").attr("type","text/javascript").html(a);b("head").append(c)}},B=function(a,c,d,e){var f=b(a);if(f.length){var g=b(c),h=null;if(e){h=new k.NodeList(f.children().get());h.destroy(!0);f.empty();f.append(g)}else{h=new k.NodeList(f.get());h.destroy(!0);f.replaceWith(g)}A(d);j.notifyFilterContentUpdated(g)}};z.prototype.scanForPartials=function(b){var c=a.parse(b),d=[],e=function(a,b){var c,d;for(c=0;c"==d[0]||"<"==d[0]){b.push(d[1])}if(4=M.cfg.templaterev){return null}if(a in r){return r[a]}if(a in q){r[a]=b.Deferred().resolve(q[a]).promise();return r[a]}var c=h.get("core_template/"+M.cfg.templaterev+":"+a);if(c){q[a]=c;r[a]=b.Deferred().resolve(c).promise();return r[a]}return null},y=function(){if(!u.length){return}if(v){return}v=!0;var a=u.slice(),e=b.Deferred(),f=[],g=a.map(function(a){var c=a.component,i=a.name,j=a.searchKey,k=a.theme,l=a.deferred,m=null,n=x(j);if(n){m=n}else{f.push({methodname:"core_output_load_template_with_dependencies",args:{component:c,template:i,themename:k,lang:b("html").attr("lang").replace(/-/g,"_")}});var o=f.length-1;m=e.promise().then(function(a){g[j]=a[o].then(function(a){var b=null;a.templates.forEach(function(a){var d=[k,a.component,a.name].join("/");q[d]=a.value;h.set("core_template/"+M.cfg.templaterev+":"+d,a.value);if(a.component==c&&a.name==i){b=a.value}});if(a.strings.length){d.cache_strings(a.strings.map(function(a){return{component:a.component,key:a.name,value:a.value}}))}return b});return g[j]})}return m.then(function(a){return l.resolve(a)}).catch(function(a){l.reject(a);throw a})});if(f.length){e.resolve(c.call(f,!0,!1,!1,0,M.cfg.templaterev))}else{e.resolve()}b.when.apply(null,g).then(function(){u.splice(0,a.length);v=!1;y()}).catch(function(){u.splice(0,a.length);v=!1;y()})},z=function(){this.requiredStrings=[];this.requiredJS=[];this.requiredDates=[];this.currentThemeName=""};z.prototype.requiredStrings=null;z.prototype.requiredDates=[];z.prototype.requiredJS=null;z.prototype.currentThemeName="";z.prototype.getTemplate=function(a){var c=this.currentThemeName,d=c+"/"+a,e=x(d);if(e){return e}var f=u.filter(function(a){return a.searchKey==d});if(f.length){return f[0].deferred.promise()}var g=a.split("/"),h=g.shift(),i=g.join("/"),j=b.Deferred();u.push({component:h,name:i,theme:c,searchKey:d,deferred:j});y();return j.promise()};z.prototype.partialHelper=function(a){var b=this.currentThemeName+"/"+a;if(!(b in q)){e.exception(new Error("Failed to pre-fetch the template: "+a))}return q[b]};z.prototype.renderIcon=function(a,c,d){var f=g.iconsystemmodule,h=b.Deferred();require([f],function(a){var b=new a;if(!(b instanceof i)){h.reject("Invalid icon system specified"+g.iconsystemmodule)}else{t=b;b.init().then(h.resolve).catch(e.exception)}});return h.then(function(a){return this.getTemplate(a.getTemplateName())}.bind(this)).then(function(b){return t.renderIcon(a,c,d,b)})};z.prototype.pixHelper=function(a,b,c){var d=b.split(","),e="",f="",g="";if(0=}}$1<%={{ }}=%>").replace(/(\r\n|\r|\n)/g,"
");return"\""+d+"\""};z.prototype.shortenTextHelper=function(a,b,c){var d=/(.*?),(.*)/,e=b.match(d),f=e[1].trim(),g=e[2].trim(),h=c(g,a);return m.truncate(h,{length:f,words:!0,ellipsis:"..."})};z.prototype.userDateHelper=function(a,b,c){var d=/(.*?),(.*)/,e=b.match(d),f=c(e[1].trim(),a),g=c(e[2].trim(),a),h=this.requiredDates.length;this.requiredDates.push({timestamp:f,format:g});return"[[_t_"+h+"]]"};z.prototype.addHelperFunction=function(a,b){return function(){return function(c,d){var e=w.reduce(function(a,c){if(b.hasOwnProperty(c)){a[c]=b[c]}return a},{});w.forEach(function(a){b[a]=function(){return""}});var f=a.apply(this,[b,c,d]);for(var g in e){b[g]=e[g]}return f}.bind(this)}.bind(this)};z.prototype.addHelpers=function(a,b){this.currentThemeName=b;this.requiredStrings=[];this.requiredJS=[];a.uniqid=p++;a.str=this.addHelperFunction(this.stringHelper,a);a.pix=this.addHelperFunction(this.pixHelper,a);a.js=this.addHelperFunction(this.jsHelper,a);a.quote=this.addHelperFunction(this.quoteHelper,a);a.shortentext=this.addHelperFunction(this.shortenTextHelper,a);a.userdate=this.addHelperFunction(this.userDateHelper,a);a.globals={config:g};a.currentTheme=b};z.prototype.getJS=function(){var a="";if(0").attr("type","text/javascript").html(a);b("head").append(c)}},B=function(a,c,d,e){var f=b(a);if(f.length){var g=b(c),h=null;if(e){h=new k.NodeList(f.children().get());h.destroy(!0);f.empty();f.append(g)}else{h=new k.NodeList(f.get());h.destroy(!0);f.replaceWith(g)}A(d);j.notifyFilterContentUpdated(g)}};z.prototype.scanForPartials=function(b){var c=a.parse(b),d=[],e=function(a,b){var c,d;for(c=0;c"==d[0]||"<"==d[0]){b.push(d[1])}if(4.\n\n/**\n * Template renderer for Moodle. Load and render Moodle templates with Mustache.\n *\n * @module core/templates\n * @package core\n * @class templates\n * @copyright 2015 Damyon Wiese \n * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later\n * @since 2.9\n */\ndefine([\n 'core/mustache',\n 'jquery',\n 'core/ajax',\n 'core/str',\n 'core/notification',\n 'core/url',\n 'core/config',\n 'core/localstorage',\n 'core/icon_system',\n 'core/event',\n 'core/yui',\n 'core/log',\n 'core/truncate',\n 'core/user_date',\n 'core/pending',\n ],\n function(mustache, $, ajax, str, notification, coreurl, config, storage, IconSystem, event, Y, Log, Truncate, UserDate,\n Pending) {\n\n // Module variables.\n /** @var {Number} uniqInstances Count of times this constructor has been called. */\n var uniqInstances = 0;\n\n /** @var {String[]} templateCache - Cache of already loaded template strings */\n var templateCache = {};\n\n /** @var {Promise[]} templatePromises - Cache of already loaded template promises */\n var templatePromises = {};\n\n /** @var {Promise[]} cachePartialPromises - Cache of already loaded template partial promises */\n var cachePartialPromises = {};\n\n /** @var {Object} iconSystem - Object extending core/iconsystem */\n var iconSystem = {};\n\n /** @var {Object[]} loadTemplateBuffer - List of templates to be loaded */\n var loadTemplateBuffer = [];\n\n /** @var {Bool} isLoadingTemplates - Whether templates are currently being loaded */\n var isLoadingTemplates = false;\n\n /** @var {Array} blacklistedNestedHelpers - List of helpers that can't be called within other helpers */\n var blacklistedNestedHelpers = ['js'];\n\n /**\n * Search the various caches for a template promise for the given search key.\n * The search key should be in the format // e.g. boost/core/modal.\n *\n * If the template is found in any of the caches it will populate the other caches with\n * the same data as well.\n *\n * @param {String} searchKey The template search key in the format // e.g. boost/core/modal\n * @return {Object} jQuery promise resolved with the template source\n */\n var getTemplatePromiseFromCache = function(searchKey) {\n // Do not cache anything if templaterev is not valid.\n if (M.cfg.templaterev <= 0) {\n return null;\n }\n\n // First try the cache of promises.\n if (searchKey in templatePromises) {\n return templatePromises[searchKey];\n }\n\n // Check the module cache.\n if (searchKey in templateCache) {\n // Add this to the promises cache for future.\n templatePromises[searchKey] = $.Deferred().resolve(templateCache[searchKey]).promise();\n return templatePromises[searchKey];\n }\n\n // Now try local storage.\n var cached = storage.get('core_template/' + M.cfg.templaterev + ':' + searchKey);\n if (cached) {\n // Add this to the module cache for future.\n templateCache[searchKey] = cached;\n // Add this to the promises cache for future.\n templatePromises[searchKey] = $.Deferred().resolve(cached).promise();\n return templatePromises[searchKey];\n }\n\n return null;\n };\n\n /**\n * Take all of the templates waiting in the buffer and load them from the server\n * or from the cache.\n *\n * All of the templates that need to be loaded from the server will be batched up\n * and sent in a single network request.\n */\n var processLoadTemplateBuffer = function() {\n if (!loadTemplateBuffer.length) {\n return;\n }\n\n if (isLoadingTemplates) {\n return;\n }\n\n isLoadingTemplates = true;\n // Grab any templates waiting in the buffer.\n var templatesToLoad = loadTemplateBuffer.slice();\n // This will be resolved with the list of promises for the server request.\n var serverRequestsDeferred = $.Deferred();\n var requests = [];\n // Get a list of promises for each of the templates we need to load.\n var templatePromises = templatesToLoad.map(function(templateData) {\n var component = templateData.component;\n var name = templateData.name;\n var searchKey = templateData.searchKey;\n var theme = templateData.theme;\n var templateDeferred = templateData.deferred;\n var promise = null;\n\n // Double check to see if this template happened to have landed in the\n // cache as a dependency of an earlier template.\n var cachedPromise = getTemplatePromiseFromCache(searchKey);\n if (cachedPromise) {\n // We've seen this template so immediately resolve the existing promise.\n promise = cachedPromise;\n } else {\n // We haven't seen this template yet so we need to request it from\n // the server.\n requests.push({\n methodname: 'core_output_load_template_with_dependencies',\n args: {\n component: component,\n template: name,\n themename: theme,\n lang: $('html').attr('lang').replace(/-/g, '_')\n }\n });\n // Remember the index in the requests list for this template so that\n // we can get the appropriate promise back.\n var index = requests.length - 1;\n\n // The server deferred will be resolved with a list of all of the promises\n // that were sent in the order that they were added to the requests array.\n promise = serverRequestsDeferred.promise()\n .then(function(promises) {\n // The promise for this template will be the one that matches the index\n // for it's entry in the requests array.\n //\n // Make sure the promise is added to the promises cache for this template\n // search key so that we don't request it again.\n templatePromises[searchKey] = promises[index].then(function(response) {\n var templateSource = null;\n\n // Process all of the template dependencies for this template and add\n // them to the caches so that we don't request them again later.\n response.templates.forEach(function(data) {\n // Generate the search key for this template in the response so that we\n // can add it to the caches.\n var tempSearchKey = [theme, data.component, data.name].join('/');\n // Cache all of the dependent templates because we'll need them to render\n // the requested template.\n templateCache[tempSearchKey] = data.value;\n storage.set('core_template/' + M.cfg.templaterev + ':' + tempSearchKey, data.value);\n\n if (data.component == component && data.name == name) {\n // This is the original template that was requested so remember it to return.\n templateSource = data.value;\n }\n });\n\n if (response.strings.length) {\n // If we have strings that the template needs then warm the string cache\n // with them now so that we don't need to re-fetch them.\n str.cache_strings(response.strings.map(function(data) {\n return {\n component: data.component,\n key: data.name,\n value: data.value\n };\n }));\n }\n\n // Return the original template source that the user requested.\n return templateSource;\n });\n\n return templatePromises[searchKey];\n });\n }\n\n return promise\n .then(function(source) {\n // When we've successfully loaded the template then resolve the deferred\n // in the buffer so that all of the calling code can proceed.\n return templateDeferred.resolve(source);\n })\n .catch(function(error) {\n // If there was an error loading the template then reject the deferred\n // in the buffer so that all of the calling code can proceed.\n templateDeferred.reject(error);\n // Rethrow for anyone else listening.\n throw error;\n });\n });\n\n if (requests.length) {\n // We have requests to send so resolve the deferred with the promises.\n serverRequestsDeferred.resolve(ajax.call(requests, true, false, false, 0, M.cfg.templaterev));\n } else {\n // Nothing to load so we can resolve our deferred.\n serverRequestsDeferred.resolve();\n }\n\n // Once we've finished loading all of the templates then recurse to process\n // any templates that may have been added to the buffer in the time that we\n // were fetching.\n $.when.apply(null, templatePromises)\n .then(function() {\n // Remove the templates we've loaded from the buffer.\n loadTemplateBuffer.splice(0, templatesToLoad.length);\n isLoadingTemplates = false;\n processLoadTemplateBuffer();\n return;\n })\n .catch(function() {\n // Remove the templates we've loaded from the buffer.\n loadTemplateBuffer.splice(0, templatesToLoad.length);\n isLoadingTemplates = false;\n processLoadTemplateBuffer();\n });\n };\n\n /**\n * Constructor\n *\n * Each call to templates.render gets it's own instance of this class.\n */\n var Renderer = function() {\n this.requiredStrings = [];\n this.requiredJS = [];\n this.requiredDates = [];\n this.currentThemeName = '';\n };\n // Class variables and functions.\n\n /** @var {string[]} requiredStrings - Collection of strings found during the rendering of one template */\n Renderer.prototype.requiredStrings = null;\n\n /** @var {object[]} requiredDates - Collection of dates found during the rendering of one template */\n Renderer.prototype.requiredDates = [];\n\n /** @var {string[]} requiredJS - Collection of js blocks found during the rendering of one template */\n Renderer.prototype.requiredJS = null;\n\n /** @var {String} themeName for the current render */\n Renderer.prototype.currentThemeName = '';\n\n /**\n * Load a template.\n *\n * @method getTemplate\n * @private\n * @param {string} templateName - should consist of the component and the name of the template like this:\n * core/menu (lib/templates/menu.mustache) or\n * tool_bananas/yellow (admin/tool/bananas/templates/yellow.mustache)\n * @return {Promise} JQuery promise object resolved when the template has been fetched.\n */\n Renderer.prototype.getTemplate = function(templateName) {\n var currentTheme = this.currentThemeName;\n var searchKey = currentTheme + '/' + templateName;\n\n // If we haven't already seen this template then buffer it.\n var cachedPromise = getTemplatePromiseFromCache(searchKey);\n if (cachedPromise) {\n return cachedPromise;\n }\n\n // Check the buffer to seee if this template has already been added.\n var existingBufferRecords = loadTemplateBuffer.filter(function(record) {\n return record.searchKey == searchKey;\n });\n if (existingBufferRecords.length) {\n // This template is already in the buffer so just return the existing\n // promise. No need to add it to the buffer again.\n return existingBufferRecords[0].deferred.promise();\n }\n\n // This is the first time this has been requested so let's add it to the buffer\n // to be loaded.\n var parts = templateName.split('/');\n var component = parts.shift();\n var name = parts.join('/');\n var deferred = $.Deferred();\n\n // Add this template to the buffer to be loaded.\n loadTemplateBuffer.push({\n component: component,\n name: name,\n theme: currentTheme,\n searchKey: searchKey,\n deferred: deferred\n });\n\n // We know there is at least one thing in the buffer so kick off a processing run.\n processLoadTemplateBuffer();\n return deferred.promise();\n };\n\n /**\n * Load a partial from the cache or ajax.\n *\n * @method partialHelper\n * @private\n * @param {string} name The partial name to load.\n * @return {string}\n */\n Renderer.prototype.partialHelper = function(name) {\n\n var searchKey = this.currentThemeName + '/' + name;\n\n if (!(searchKey in templateCache)) {\n notification.exception(new Error('Failed to pre-fetch the template: ' + name));\n }\n\n return templateCache[searchKey];\n };\n\n /**\n * Render a single image icon.\n *\n * @method renderIcon\n * @private\n * @param {string} key The icon key.\n * @param {string} component The component name.\n * @param {string} title The icon title\n * @return {Promise}\n */\n Renderer.prototype.renderIcon = function(key, component, title) {\n // Preload the module to do the icon rendering based on the theme iconsystem.\n var modulename = config.iconsystemmodule;\n\n // RequireJS does not return a promise.\n var ready = $.Deferred();\n require([modulename], function(System) {\n var system = new System();\n if (!(system instanceof IconSystem)) {\n ready.reject('Invalid icon system specified' + config.iconsystemmodule);\n } else {\n iconSystem = system;\n system.init().then(ready.resolve).catch(notification.exception);\n }\n });\n\n return ready.then(function(iconSystem) {\n return this.getTemplate(iconSystem.getTemplateName());\n }.bind(this)).then(function(template) {\n return iconSystem.renderIcon(key, component, title, template);\n });\n };\n\n /**\n * Render image icons.\n *\n * @method pixHelper\n * @private\n * @param {object} context The mustache context\n * @param {string} sectionText The text to parse arguments from.\n * @param {function} helper Used to render the alt attribute of the text.\n * @return {string}\n */\n Renderer.prototype.pixHelper = function(context, sectionText, helper) {\n var parts = sectionText.split(',');\n var key = '';\n var component = '';\n var text = '';\n\n if (parts.length > 0) {\n key = helper(parts.shift().trim(), context);\n }\n if (parts.length > 0) {\n component = helper(parts.shift().trim(), context);\n }\n if (parts.length > 0) {\n text = helper(parts.join(',').trim(), context);\n }\n\n var templateName = iconSystem.getTemplateName();\n\n var searchKey = this.currentThemeName + '/' + templateName;\n var template = templateCache[searchKey];\n\n // The key might have been escaped by the JS Mustache engine which\n // converts forward slashes to HTML entities. Let us undo that here.\n key = key.replace(///gi, '/');\n\n return iconSystem.renderIcon(key, component, text, template);\n };\n\n /**\n * Render blocks of javascript and save them in an array.\n *\n * @method jsHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to save as a js block.\n * @param {function} helper Used to render the block.\n * @return {string}\n */\n Renderer.prototype.jsHelper = function(context, sectionText, helper) {\n this.requiredJS.push(helper(sectionText, context));\n return '';\n };\n\n /**\n * String helper used to render {{#str}}abd component { a : 'fish'}{{/str}}\n * into a get_string call.\n *\n * @method stringHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @return {string}\n */\n Renderer.prototype.stringHelper = function(context, sectionText, helper) {\n var parts = sectionText.split(',');\n var key = '';\n var component = '';\n var param = '';\n if (parts.length > 0) {\n key = parts.shift().trim();\n }\n if (parts.length > 0) {\n component = parts.shift().trim();\n }\n if (parts.length > 0) {\n param = parts.join(',').trim();\n }\n\n if (param !== '') {\n // Allow variable expansion in the param part only.\n param = helper(param, context);\n }\n // Allow json formatted $a arguments.\n if ((param.indexOf('{') === 0) && (param.indexOf('{{') !== 0)) {\n param = JSON.parse(param);\n }\n\n var index = this.requiredStrings.length;\n this.requiredStrings.push({key: key, component: component, param: param});\n\n // The placeholder must not use {{}} as those can be misinterpreted by the engine.\n return '[[_s' + index + ']]';\n };\n\n /**\n * Quote helper used to wrap content in quotes, and escape all quotes present in the content.\n *\n * @method quoteHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @return {string}\n */\n Renderer.prototype.quoteHelper = function(context, sectionText, helper) {\n var content = helper(sectionText.trim(), context);\n\n // Escape the {{ and the \".\n // This involves wrapping {{, and }} in change delimeter tags.\n content = content\n .replace(/\"/g, '\\\\\"')\n .replace(/([\\{\\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')\n .replace(/(\\r\\n|\\r|\\n)/g, '
')\n ;\n return '\"' + content + '\"';\n };\n\n /**\n * Shorten text helper to truncate text and append a trailing ellipsis.\n *\n * @method shortenTextHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @return {string}\n */\n Renderer.prototype.shortenTextHelper = function(context, sectionText, helper) {\n // Non-greedy split on comma to grab section text into the length and\n // text parts.\n var regex = /(.*?),(.*)/;\n var parts = sectionText.match(regex);\n // The length is the part matched in the first set of parethesis.\n var length = parts[1].trim();\n // The length is the part matched in the second set of parethesis.\n var text = parts[2].trim();\n var content = helper(text, context);\n return Truncate.truncate(content, {\n length: length,\n words: true,\n ellipsis: '...'\n });\n };\n\n /**\n * User date helper to render user dates from timestamps.\n *\n * @method userDateHelper\n * @private\n * @param {object} context The current mustache context.\n * @param {string} sectionText The text to parse the arguments from.\n * @param {function} helper Used to render subsections of the text.\n * @return {string}\n */\n Renderer.prototype.userDateHelper = function(context, sectionText, helper) {\n // Non-greedy split on comma to grab the timestamp and format.\n var regex = /(.*?),(.*)/;\n var parts = sectionText.match(regex);\n var timestamp = helper(parts[1].trim(), context);\n var format = helper(parts[2].trim(), context);\n var index = this.requiredDates.length;\n\n this.requiredDates.push({\n timestamp: timestamp,\n format: format\n });\n\n return '[[_t_' + index + ']]';\n };\n\n /**\n * Return a helper function to be added to the context for rendering the a\n * template.\n *\n * This will parse the provided text before giving it to the helper function\n * in order to remove any blacklisted nested helpers to prevent one helper\n * from calling another.\n *\n * In particular to prevent the JS helper from being called from within another\n * helper because it can lead to security issues when the JS portion is user\n * provided.\n *\n * @param {function} helperFunction The helper function to add\n * @param {object} context The template context for the helper function\n * @return {Function} To be set in the context\n */\n Renderer.prototype.addHelperFunction = function(helperFunction, context) {\n return function() {\n return function(sectionText, helper) {\n // Override the blacklisted helpers in the template context with\n // a function that returns an empty string for use when executing\n // other helpers. This is to prevent these helpers from being\n // executed as part of the rendering of another helper in order to\n // prevent any potential security issues.\n var originalHelpers = blacklistedNestedHelpers.reduce(function(carry, name) {\n if (context.hasOwnProperty(name)) {\n carry[name] = context[name];\n }\n\n return carry;\n }, {});\n\n blacklistedNestedHelpers.forEach(function(helperName) {\n context[helperName] = function() {\n return '';\n };\n });\n\n // Execute the helper with the modified context that doesn't include\n // the blacklisted nested helpers. This prevents the blacklisted\n // helpers from being called from within other helpers.\n var result = helperFunction.apply(this, [context, sectionText, helper]);\n\n // Restore the original helper implementation in the context so that\n // any further rendering has access to them again.\n for (var name in originalHelpers) {\n context[name] = originalHelpers[name];\n }\n\n return result;\n }.bind(this);\n }.bind(this);\n };\n\n /**\n * Add some common helper functions to all context objects passed to templates.\n * These helpers match exactly the helpers available in php.\n *\n * @method addHelpers\n * @private\n * @param {Object} context Simple types used as the context for the template.\n * @param {String} themeName We set this multiple times, because there are async calls.\n */\n Renderer.prototype.addHelpers = function(context, themeName) {\n this.currentThemeName = themeName;\n this.requiredStrings = [];\n this.requiredJS = [];\n context.uniqid = (uniqInstances++);\n context.str = this.addHelperFunction(this.stringHelper, context);\n context.pix = this.addHelperFunction(this.pixHelper, context);\n context.js = this.addHelperFunction(this.jsHelper, context);\n context.quote = this.addHelperFunction(this.quoteHelper, context);\n context.shortentext = this.addHelperFunction(this.shortenTextHelper, context);\n context.userdate = this.addHelperFunction(this.userDateHelper, context);\n context.globals = {config: config};\n context.currentTheme = themeName;\n };\n\n /**\n * Get all the JS blocks from the last rendered template.\n *\n * @method getJS\n * @private\n * @return {string}\n */\n Renderer.prototype.getJS = function() {\n var js = '';\n if (this.requiredJS.length > 0) {\n js = this.requiredJS.join(\";\\n\");\n }\n\n return js;\n };\n\n /**\n * Treat strings in content.\n *\n * The purpose of this method is to replace the placeholders found in a string\n * with the their respective translated strings.\n *\n * Previously we were relying on String.replace() but the complexity increased with\n * the numbers of strings to replace. Now we manually walk the string and stop at each\n * placeholder we find, only then we replace it. Most of the time we will\n * replace all the placeholders in a single run, at times we will need a few\n * more runs when placeholders are replaced with strings that contain placeholders\n * themselves.\n *\n * @param {String} content The content in which string placeholders are to be found.\n * @param {Array} strings The strings to replace with.\n * @return {String} The treated content.\n */\n Renderer.prototype.treatStringsInContent = function(content, strings) {\n var pattern = /\\[\\[_s\\d+\\]\\]/,\n treated,\n index,\n strIndex,\n walker,\n char,\n strFinal;\n\n do {\n treated = '';\n index = content.search(pattern);\n while (index > -1) {\n\n // Copy the part prior to the placeholder to the treated string.\n treated += content.substring(0, index);\n content = content.substr(index);\n strIndex = '';\n walker = 4; // 4 is the length of '[[_s'.\n\n // Walk the characters to manually extract the index of the string from the placeholder.\n char = content.substr(walker, 1);\n do {\n strIndex += char;\n walker++;\n char = content.substr(walker, 1);\n } while (char != ']');\n\n // Get the string, add it to the treated result, and remove the placeholder from the content to treat.\n strFinal = strings[parseInt(strIndex, 10)];\n if (typeof strFinal === 'undefined') {\n Log.debug('Could not find string for pattern [[_s' + strIndex + ']].');\n strFinal = '';\n }\n treated += strFinal;\n content = content.substr(6 + strIndex.length); // 6 is the length of the placeholder without the index: '[[_s]]'.\n\n // Find the next placeholder.\n index = content.search(pattern);\n }\n\n // The content becomes the treated part with the rest of the content.\n content = treated + content;\n\n // Check if we need to walk the content again, in case strings contained placeholders.\n index = content.search(pattern);\n\n } while (index > -1);\n\n return content;\n };\n\n /**\n * Treat strings in content.\n *\n * The purpose of this method is to replace the date placeholders found in the\n * content with the their respective translated dates.\n *\n * @param {String} content The content in which string placeholders are to be found.\n * @param {Array} strings The strings to replace with.\n * @return {String} The treated content.\n */\n Renderer.prototype.treatDatesInContent = function(content, dates) {\n dates.forEach(function(date, index) {\n var key = '\\\\[\\\\[_t_' + index + '\\\\]\\\\]';\n var re = new RegExp(key, 'g');\n content = content.replace(re, date);\n });\n\n return content;\n };\n\n /**\n * Render a template and then call the callback with the result.\n *\n * @method doRender\n * @private\n * @param {string} templateSource The mustache template to render.\n * @param {Object} context Simple types used as the context for the template.\n * @param {String} themeName Name of the current theme.\n * @return {Promise} object\n */\n Renderer.prototype.doRender = function(templateSource, context, themeName) {\n this.currentThemeName = themeName;\n var iconTemplate = iconSystem.getTemplateName();\n\n var pendingPromise = new Pending('core/templates:doRender');\n return this.getTemplate(iconTemplate).then(function() {\n this.addHelpers(context, themeName);\n var result = mustache.render(templateSource, context, this.partialHelper.bind(this));\n return $.Deferred().resolve(result.trim(), this.getJS()).promise();\n }.bind(this))\n .then(function(html, js) {\n if (this.requiredStrings.length > 0) {\n return str.get_strings(this.requiredStrings).then(function(strings) {\n\n // Make sure string substitutions are done for the userdate\n // values as well.\n this.requiredDates = this.requiredDates.map(function(date) {\n return {\n timestamp: this.treatStringsInContent(date.timestamp, strings),\n format: this.treatStringsInContent(date.format, strings)\n };\n }.bind(this));\n\n // Why do we not do another call the render here?\n //\n // Because that would expose DOS holes. E.g.\n // I create an assignment called \"{{fish\" which\n // would get inserted in the template in the first pass\n // and cause the template to die on the second pass (unbalanced).\n html = this.treatStringsInContent(html, strings);\n js = this.treatStringsInContent(js, strings);\n return $.Deferred().resolve(html, js).promise();\n }.bind(this));\n }\n\n return $.Deferred().resolve(html, js).promise();\n }.bind(this))\n .then(function(html, js) {\n // This has to happen after the strings replacement because you can\n // use the string helper in content for the user date helper.\n if (this.requiredDates.length > 0) {\n return UserDate.get(this.requiredDates).then(function(dates) {\n html = this.treatDatesInContent(html, dates);\n js = this.treatDatesInContent(js, dates);\n return $.Deferred().resolve(html, js).promise();\n }.bind(this));\n }\n\n return $.Deferred().resolve(html, js).promise();\n }.bind(this))\n .then(function(html, js) {\n pendingPromise.resolve();\n return $.Deferred().resolve(html, js).promise();\n });\n };\n\n /**\n * Execute a block of JS returned from a template.\n * Call this AFTER adding the template HTML into the DOM so the nodes can be found.\n *\n * @method runTemplateJS\n * @param {string} source - A block of javascript.\n */\n var runTemplateJS = function(source) {\n if (source.trim() !== '') {\n var newscript = $('