Merge branch 'MDL-65335_master' of https://github.com/marxjohnson/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 1 Oct 2019 21:21:51 +0000 (23:21 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Tue, 1 Oct 2019 21:21:51 +0000 (23:21 +0200)
220 files changed:
.eslintignore
.stylelintignore
Gruntfile.js
admin/environment.xml
admin/renderer.php
admin/settings/server.php
admin/testoutgoingmailconf.php
admin/tool/analytics/amd/build/model.min.js
admin/tool/analytics/amd/build/model.min.js.map
admin/tool/analytics/amd/src/model.js
admin/tool/analytics/classes/output/models_list.php
admin/tool/dataprivacy/tests/behat/dataexport.feature
analytics/classes/insights_generator.php
analytics/classes/local/time_splitting/after_start.php [new file with mode: 0644]
analytics/classes/local/time_splitting/past_periodic.php [new file with mode: 0644]
analytics/classes/local/time_splitting/periodic.php
analytics/classes/local/time_splitting/upcoming_periodic.php
analytics/tests/fixtures/test_timesplitting_seconds.php
analytics/tests/manager_test.php
analytics/tests/stats_test.php
analytics/upgrade.txt
blocks/social_activities/tests/behat/behat_block_social_activities.php
calendar/amd/build/calendar_filter.min.js
calendar/amd/build/calendar_filter.min.js.map
calendar/amd/src/calendar_filter.js
calendar/lib.php
calendar/templates/event_icon.mustache
calendar/templates/month_mini.mustache
competency/lib.php
competency/tests/lib_test.php
config-dist.php
course/classes/analytics/target/no_access_since_course_start.php [new file with mode: 0644]
course/classes/analytics/target/no_recent_accesses.php [new file with mode: 0644]
course/editsection.php
course/format/social/format.php
enrol/externallib.php
enrol/locallib.php
enrol/tests/course_enrolment_manager_test.php
enrol/tests/externallib_test.php
files/classes/external/stored_file_exporter.php
grade/grading/form/guide/tests/generator/criterion.php [new file with mode: 0644]
grade/grading/form/guide/tests/generator/guide.php [new file with mode: 0644]
grade/grading/form/guide/tests/generator/lib.php [new file with mode: 0644]
grade/grading/form/guide/tests/generator_test.php [new file with mode: 0644]
grade/grading/form/guide/tests/privacy_test.php
grade/grading/form/rubric/tests/generator/criterion.php [new file with mode: 0644]
grade/grading/form/rubric/tests/generator/lib.php [new file with mode: 0644]
grade/grading/form/rubric/tests/generator/rubric.php [new file with mode: 0644]
grade/grading/form/rubric/tests/generator_test.php [new file with mode: 0644]
grade/grading/form/rubric/tests/privacy_test.php
grade/grading/tests/fixtures/marking_guide.php [deleted file]
grade/grading/tests/generator/lib.php [new file with mode: 0644]
grade/grading/tests/generator_test.php [new file with mode: 0644]
grade/grading/tests/privacy_test.php
install/lang/hr/admin.php
lang/en/admin.php
lang/en/course.php
lang/en/langconfig.php
lang/en/moodle.php
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/src/templates.js
lib/behat/classes/util.php
lib/classes/analytics/indicator/any_course_access.php [new file with mode: 0644]
lib/classes/analytics/time_splitting/one_month_after_start.php [new file with mode: 0644]
lib/classes/analytics/time_splitting/one_week_after_start.php [new file with mode: 0644]
lib/classes/analytics/time_splitting/past_3_days.php [new file with mode: 0644]
lib/classes/analytics/time_splitting/past_month.php [new file with mode: 0644]
lib/classes/analytics/time_splitting/past_week.php [moved from analytics/tests/fixtures/test_timesplitting_weekly.php with 89% similarity]
lib/classes/analytics/time_splitting/ten_percent_after_start.php [new file with mode: 0644]
lib/classes/plugin_manager.php
lib/classes/task/logmanager.php
lib/clilib.php
lib/db/analytics.php
lib/db/services.php
lib/editor/atto/db/upgrade.php
lib/editor/atto/plugins/h5p/classes/privacy/provider.php [new file with mode: 0644]
lib/editor/atto/plugins/h5p/db/access.php [new file with mode: 0644]
lib/editor/atto/plugins/h5p/js/h5p-resizer.js [new file with mode: 0644]
lib/editor/atto/plugins/h5p/js/readme_moodle.txt [new file with mode: 0644]
lib/editor/atto/plugins/h5p/lang/en/atto_h5p.php [new file with mode: 0644]
lib/editor/atto/plugins/h5p/lib.php [new file with mode: 0644]
lib/editor/atto/plugins/h5p/pix/icon.png [new file with mode: 0644]
lib/editor/atto/plugins/h5p/pix/icon.svg [new file with mode: 0644]
lib/editor/atto/plugins/h5p/styles.css [new file with mode: 0644]
lib/editor/atto/plugins/h5p/tests/behat/h5p.feature [new file with mode: 0644]
lib/editor/atto/plugins/h5p/thirdpartylibs.xml [new file with mode: 0644]
lib/editor/atto/plugins/h5p/version.php [new file with mode: 0644]
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js [new file with mode: 0644]
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js [new file with mode: 0644]
lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js [new file with mode: 0644]
lib/editor/atto/plugins/h5p/yui/src/button/build.json [new file with mode: 0644]
lib/editor/atto/plugins/h5p/yui/src/button/js/button.js [new file with mode: 0644]
lib/editor/atto/plugins/h5p/yui/src/button/meta/button.json [new file with mode: 0644]
lib/editor/atto/settings.php
lib/editor/atto/version.php
lib/environmentlib.php
lib/formslib.php
lib/grade/grade_category.php
lib/grade/grade_item.php
lib/grade/tests/grade_category_test.php
lib/gradelib.php
lib/moodlelib.php
lib/outputrenderers.php
lib/tests/behat/behat_general.php
lib/tests/behat/behat_hooks.php
lib/tests/gradelib_test.php
lib/tests/indicators_test.php
lib/tests/time_splittings_test.php
lib/upgradelib.php
message/classes/api.php
message/lib.php
message/output/airnotifier/message_output_airnotifier.php
mod/assign/locallib.php
mod/assign/tests/locallib_test.php
mod/data/lib.php
mod/feedback/lib.php
mod/forum/amd/build/discussion.min.js
mod/forum/amd/build/discussion.min.js.map
mod/forum/amd/build/discussion_list.min.js
mod/forum/amd/build/discussion_list.min.js.map
mod/forum/amd/build/discussion_modern.min.js [new file with mode: 0644]
mod/forum/amd/build/discussion_modern.min.js.map [new file with mode: 0644]
mod/forum/amd/build/form-user-selector.min.js [new file with mode: 0644]
mod/forum/amd/build/form-user-selector.min.js.map [new file with mode: 0644]
mod/forum/amd/build/forum_events.min.js [new file with mode: 0644]
mod/forum/amd/build/forum_events.min.js.map [new file with mode: 0644]
mod/forum/amd/build/inpage_reply.min.js
mod/forum/amd/build/inpage_reply.min.js.map
mod/forum/amd/build/selectors.min.js
mod/forum/amd/build/selectors.min.js.map
mod/forum/amd/build/subscription_toggle.min.js
mod/forum/amd/build/subscription_toggle.min.js.map
mod/forum/amd/src/discussion.js
mod/forum/amd/src/discussion_list.js
mod/forum/amd/src/discussion_modern.js [new file with mode: 0644]
mod/forum/amd/src/form-user-selector.js [new file with mode: 0644]
mod/forum/amd/src/forum_events.js [new file with mode: 0644]
mod/forum/amd/src/inpage_reply.js
mod/forum/amd/src/selectors.js
mod/forum/amd/src/subscription_toggle.js
mod/forum/classes/form/export_form.php [new file with mode: 0644]
mod/forum/classes/local/builders/exported_discussion_summaries.php
mod/forum/classes/local/data_mappers/legacy/author.php
mod/forum/classes/local/entities/author.php
mod/forum/classes/local/exporters/author.php
mod/forum/classes/local/exporters/discussion.php
mod/forum/classes/local/exporters/forum.php
mod/forum/classes/local/exporters/post.php
mod/forum/classes/local/factories/entity.php
mod/forum/classes/local/factories/renderer.php
mod/forum/classes/local/managers/capability.php
mod/forum/classes/local/renderers/discussion.php
mod/forum/classes/local/vaults/discussion.php
mod/forum/classes/local/vaults/discussion_list.php
mod/forum/classes/local/vaults/post.php
mod/forum/classes/local/vaults/preprocessors/extract_user.php
mod/forum/classes/task/send_user_notifications.php
mod/forum/db/access.php
mod/forum/discuss.php
mod/forum/export.php [new file with mode: 0644]
mod/forum/lang/en/forum.php
mod/forum/lib.php
mod/forum/renderer.php
mod/forum/styles.css
mod/forum/subscribe_ajax.php [deleted file]
mod/forum/templates/discussion_list.mustache
mod/forum/templates/discussion_subscription_toggle.mustache
mod/forum/templates/discussion_times.mustache [new file with mode: 0644]
mod/forum/templates/form-user-selector-suggestion.mustache [new file with mode: 0644]
mod/forum/templates/forum_action_menu.mustache
mod/forum/templates/forum_discussion.mustache
mod/forum/templates/forum_discussion_modern.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_modern_first_post.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_modern_post_reply.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_modern_posts.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion_nested_post.mustache
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/forum_discussion_threaded_posts.mustache
mod/forum/templates/inpage_reply_modern.mustache [new file with mode: 0644]
mod/forum/templates/mark_as_read.mustache [new file with mode: 0644]
mod/forum/tests/behat/split_forum_discussion.feature
mod/forum/tests/behat/timed_discussions.feature
mod/forum/tests/entities_author_test.php
mod/forum/tests/entities_discussion_summary_test.php
mod/forum/tests/exporters_author_test.php
mod/forum/tests/externallib_test.php
mod/forum/tests/vaults_post_test.php
mod/forum/version.php
mod/forum/view.php
mod/forum/yui/build/moodle-mod_forum-subscriptiontoggle/moodle-mod_forum-subscriptiontoggle-debug.js [deleted file]
mod/forum/yui/build/moodle-mod_forum-subscriptiontoggle/moodle-mod_forum-subscriptiontoggle-min.js [deleted file]
mod/forum/yui/build/moodle-mod_forum-subscriptiontoggle/moodle-mod_forum-subscriptiontoggle.js [deleted file]
mod/forum/yui/src/subscriptiontoggle/build.json [deleted file]
mod/forum/yui/src/subscriptiontoggle/js/toggle.js [deleted file]
mod/forum/yui/src/subscriptiontoggle/meta/subscriptiontoggle.json [deleted file]
mod/glossary/lib.php
mod/lesson/essay.php
mod/lesson/lib.php
mod/quiz/lib.php
mod/quiz/locallib.php
mod/scorm/lib.php
npm-shrinkwrap.json
package.json
privacy/templates/htmlpage.mustache
question/engine/datalib.php
question/engine/tests/datalib_test.php
theme/boost/amd/build/loader.min.js
theme/boost/amd/build/loader.min.js.map
theme/boost/amd/src/loader.js
theme/boost/scss/moodle/buttons.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/drawer.scss
theme/boost/scss/moodle/modules.scss
theme/boost/scss/moodle/undo.scss
theme/boost/style/moodle.css
theme/boost/templates/flat_navigation.mustache
theme/classic/scss/classic/pre.scss
theme/classic/style/moodle.css
version.php

index fe02a73..4689ff0 100644 (file)
@@ -9,6 +9,7 @@ cache/stores/mongodb/MongoDB/
 enrol/lti/ims-blti/
 filter/algebra/AlgParser.pm
 filter/tex/mimetex.*
+lib/editor/atto/plugins/h5p/js/h5p-resizer.js
 lib/editor/atto/plugins/html/yui/src/codemirror/
 lib/editor/atto/plugins/html/yui/src/beautify/
 lib/editor/atto/yui/src/rangy/js/*.*
index d3e4974..b8cd2a8 100644 (file)
@@ -10,6 +10,7 @@ cache/stores/mongodb/MongoDB/
 enrol/lti/ims-blti/
 filter/algebra/AlgParser.pm
 filter/tex/mimetex.*
+lib/editor/atto/plugins/h5p/js/h5p-resizer.js
 lib/editor/atto/plugins/html/yui/src/codemirror/
 lib/editor/atto/plugins/html/yui/src/beautify/
 lib/editor/atto/yui/src/rangy/js/*.*
index bd41cf3..f05bb19 100644 (file)
@@ -138,6 +138,7 @@ module.exports = function(grunt) {
                 comments: false,
                 plugins: [
                     'transform-es2015-modules-amd-lazy',
+                    'system-import-transformer',
                     // This plugin modifies the Babel transpiling for "export default"
                     // so that if it's used then only the exported value is returned
                     // by the generated AMD module.
index 78f6671..16b7402 100644 (file)
       <VENDOR name="oracle" version="11.2" />
     </DATABASE>
     <PHP version="7.0.0" level="required">
+      <RESTRICT function="restrict_php_version_74" message="unsupportedphpversion74" />
     </PHP>
     <PCREUNICODE level="optional">
       <FEEDBACK>
       <VENDOR name="oracle" version="11.2" />
     </DATABASE>
     <PHP version="7.1.0" level="required">
+      <RESTRICT function="restrict_php_version_74" message="unsupportedphpversion74" />
     </PHP>
     <PCREUNICODE level="optional">
       <FEEDBACK>
index 922617a..773ba4d 100644 (file)
@@ -1469,9 +1469,11 @@ class core_admin_renderer extends plugin_renderer_base {
                     $class = 'requires-failed';
                     $label = html_writer::span(get_string('dependencyfails', 'core_plugin'), 'badge badge-danger');
                 }
-                $requires[] = html_writer::tag('li',
-                    html_writer::span(get_string('moodleversion', 'core_plugin', $plugin->versionrequires), 'dep dep-core').
-                    ' '.$label, array('class' => $class));
+                if ($reqinfo->reqver != ANY_VERSION) {
+                    $requires[] = html_writer::tag('li',
+                        html_writer::span(get_string('moodleversion', 'core_plugin', $plugin->versionrequires), 'dep dep-core').
+                        ' '.$label, array('class' => $class));
+                }
 
             } else {
                 $actions = array();
index cfb2f21..4b0f427 100644 (file)
@@ -271,6 +271,14 @@ $temp->add(
         ]
     )
 );
+$temp->add(
+    new admin_setting_configcheckbox(
+        'task_logtostdout',
+        new lang_string('task_logtostdout', 'admin'),
+        new lang_string('task_logtostdout_desc', 'admin'),
+        1
+    )
+);
 
 if (\core\task\logmanager::uses_standard_settings()) {
     $temp->add(
index ce5857f..7d0db03 100644 (file)
@@ -52,7 +52,7 @@ if ($data) {
     // Manage Moodle debugging options.
     $debuglevel = $CFG->debug;
     $debugdisplay = $CFG->debugdisplay;
-    $debugsmtp = $CFG->debugsmtp;
+    $debugsmtp = $CFG->debugsmtp ?? null; // This might not be set as it's optional.
     $CFG->debugdisplay = true;
     $CFG->debugsmtp = true;
     $CFG->debug = 15;
@@ -66,7 +66,12 @@ if ($data) {
     // Restore Moodle debugging options.
     $CFG->debug = $debuglevel;
     $CFG->debugdisplay = $debugdisplay;
-    $CFG->debugsmtp = $debugsmtp;
+
+    // Restore the debugsmtp config, if it was set originally.
+    unset($CFG->debugsmtp);
+    if (!is_null($debugsmtp)) {
+        $CFG->debugsmtp = $debugsmtp;
+    }
 
     if ($success) {
         $msgparams = new stdClass();
index 9cdfe5e..05c3073 100644 (file)
Binary files a/admin/tool/analytics/amd/build/model.min.js and b/admin/tool/analytics/amd/build/model.min.js differ
index fab51a0..1ea039b 100644 (file)
Binary files a/admin/tool/analytics/amd/build/model.min.js.map and b/admin/tool/analytics/amd/build/model.min.js.map differ
index 919993c..966c6cc 100644 (file)
@@ -114,12 +114,14 @@ define(['jquery', 'core/str', 'core/log', 'core/notification', 'core/modal_facto
          * @param  {String}  actionId
          * @param  {Boolean} trainedOnlyExternally
          */
-        selectEvaluationOptions: function(actionId, trainedOnlyExternally, timeSplittingMethods) {
+        selectEvaluationOptions: function(actionId, trainedOnlyExternally) {
             $('[data-action-id="' + actionId + '"]').on('click', function(ev) {
                 ev.preventDefault();
 
                 var a = $(ev.currentTarget);
 
+                var timeSplittingMethods = $(this).attr('data-timesplitting-methods');
+
                 var stringsPromise = Str.get_strings([
                     {
                         key: 'evaluatemodel',
@@ -132,7 +134,7 @@ define(['jquery', 'core/str', 'core/log', 'core/notification', 'core/modal_facto
                 var modalPromise = ModalFactory.create({type: ModalFactory.types.SAVE_CANCEL});
                 var bodyPromise = Templates.render('tool_analytics/evaluation_options', {
                     trainedexternally: trainedOnlyExternally,
-                    timesplittingmethods: timeSplittingMethods
+                    timesplittingmethods: JSON.parse(timeSplittingMethods)
                 });
 
                 $.when(stringsPromise, modalPromise).then(function(strings, modal) {
index 47cd54a..be1c7ba 100644 (file)
@@ -222,12 +222,13 @@ class models_list implements \renderable, \templatable {
                     array_unshift($modeltimesplittingmethods, $currenttimesplitting);
                 }
 
-                $evaluateparams = [$actionid, $trainedonlyexternally, $modeltimesplittingmethods];
+                $evaluateparams = [$actionid, $trainedonlyexternally];
                 $PAGE->requires->js_call_amd('tool_analytics/model', 'selectEvaluationOptions', $evaluateparams);
                 $urlparams['action'] = 'evaluate';
                 $url = new \moodle_url('/admin/tool/analytics/model.php', $urlparams);
                 $icon = new \action_menu_link_secondary($url, new \pix_icon('i/calc', get_string('evaluate', 'tool_analytics')),
-                    get_string('evaluate', 'tool_analytics'), ['data-action-id' => $actionid]);
+                    get_string('evaluate', 'tool_analytics'), ['data-action-id' => $actionid,
+                    'data-timesplitting-methods' => json_encode($modeltimesplittingmethods)]);
                 $actionsmenu->add($icon);
             }
 
index 78751cc..948a728 100644 (file)
@@ -54,7 +54,7 @@ Feature: Data export from the privacy API
     And I reload the page
     And I should see "Download ready" in the "Victim User 1" "table_row"
     And I open the action menu in "Victim User 1" "table_row"
-    And following "Download" should download between "1" and "135000" bytes
+    And following "Download" should download between "1" and "140000" bytes
     And the following config values are set as admin:
       | privacyrequestexpiry | 1 | tool_dataprivacy |
     And I wait "1" seconds
@@ -89,7 +89,7 @@ Feature: Data export from the privacy API
     And I reload the page
     And I should see "Download ready" in the "Export all of my personal data" "table_row"
     And I open the action menu in "Victim User 1" "table_row"
-    And following "Download" should download between "1" and "135000" bytes
+    And following "Download" should download between "1" and "140000" bytes
 
     And the following config values are set as admin:
       | privacyrequestexpiry | 1 | tool_dataprivacy |
@@ -126,7 +126,7 @@ Feature: Data export from the privacy API
     And I reload the page
     And I should see "Download ready" in the "Victim User 1" "table_row"
     And I open the action menu in "Victim User 1" "table_row"
-    And following "Download" should download between "1" and "135000" bytes
+    And following "Download" should download between "1" and "140000" bytes
 
     And the following config values are set as admin:
       | privacyrequestexpiry | 1 | tool_dataprivacy |
index f3e8c0c..2fd2320 100644 (file)
@@ -136,7 +136,7 @@ class insights_generator {
         $message->component = 'moodle';
         $message->name = 'insights';
 
-        $message->userfrom = \core_user::get_noreply_user();
+        $message->userfrom = \core_user::get_support_user();
         $message->userto = $user;
 
         $message->subject = $this->target->get_insight_subject($this->modelid, $context);
diff --git a/analytics/classes/local/time_splitting/after_start.php b/analytics/classes/local/time_splitting/after_start.php
new file mode 100644 (file)
index 0000000..ad3ebb8
--- /dev/null
@@ -0,0 +1,111 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates predictions X days/weeks/months after the analysable start.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_analytics\local\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates predictions X days/weeks/months after the analysable start.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class after_start extends \core_analytics\local\time_splitting\base implements before_now {
+
+    /**
+     * The period we should wait until we generate predictions for this.
+     *
+     * @param  \core_analytics\analysable $analysable
+     * @return \DateInterval
+     */
+    abstract protected function wait_period(\core_analytics\analysable $analysable);
+
+    /**
+     * Returns whether the course can be processed by this time splitting method or not.
+     *
+     * @param \core_analytics\analysable $analysable
+     * @return bool
+     */
+    public function is_valid_analysable(\core_analytics\analysable $analysable) {
+
+        if (!$analysable->get_start()) {
+            return false;
+        }
+
+        $predictionstart = $this->get_prediction_interval_start($analysable);
+        if ($analysable->get_start() > $predictionstart) {
+            // We still need to wait.
+            return false;
+        }
+
+        return true;
+    }
+
+    /**
+     * This time-splitting method returns one single range, the start to two days before the end.
+     *
+     * @return array The list of ranges, each of them including 'start', 'end' and 'time'
+     */
+    protected function define_ranges() {
+
+        $now = time();
+        $ranges = [
+            [
+                'start' => $this->analysable->get_start(),
+                'end' => $now,
+                'time' => $now,
+            ]
+        ];
+
+        return $ranges;
+    }
+
+    /**
+     * Whether to cache or not the indicator calculations.
+     *
+     * @return bool
+     */
+    public function cache_indicator_calculations(): bool {
+        return false;
+    }
+
+    /**
+     * Calculates the interval start time backwards, from now.
+     *
+     * @param  \core_analytics\analysable $analysable
+     * @return int
+     */
+    protected function get_prediction_interval_start(\core_analytics\analysable $analysable) {
+
+        // The prediction time is always time(). We don't want to reuse the firstanalysis time
+        // because otherwise samples (e.g. students) which start after the analysable (e.g. course)
+        // start would use an incorrect analysis interval.
+        $predictionstart = new \DateTime('now');
+        $predictionstart->sub($this->wait_period($analysable));
+
+        return $predictionstart->getTimestamp();
+    }
+}
diff --git a/analytics/classes/local/time_splitting/past_periodic.php b/analytics/classes/local/time_splitting/past_periodic.php
new file mode 100644 (file)
index 0000000..ee7b7d8
--- /dev/null
@@ -0,0 +1,97 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates predictions regularly.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_analytics\local\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates predictions periodically.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class past_periodic extends periodic implements before_now {
+
+    /**
+     * Gets the next range with start on the provided time.
+     *
+     * The next range is based on the past period so we substract this
+     * range's periodicity from $time.
+     *
+     * @param  \DateTimeImmutable $time
+     * @return array
+     */
+    protected function get_next_range(\DateTimeImmutable $time) {
+
+        $end = $time->getTimestamp();
+        $start = $time->sub($this->periodicity())->getTimestamp();
+
+        if ($start < $this->analysable->get_start()) {
+            // We skip the first range generated as its start is prior to the analysable start.
+            return false;
+        }
+
+        return [
+            'start' => $start,
+            'end' => $end,
+            'time' => $end
+        ];
+    }
+
+    /**
+     * Get the start of the first time range.
+     *
+     * @return int A timestamp.
+     */
+    protected function get_first_start() {
+        return $this->analysable->get_start();
+    }
+
+    /**
+     * Guarantees that the last range dates end right now.
+     *
+     * @param  array  $ranges
+     * @return array
+     */
+    protected function update_last_range(array $ranges) {
+        $lastrange = end($ranges);
+
+        if ($lastrange['time'] > time()) {
+            // We just need to wait in this case.
+            return $lastrange;
+        }
+
+        $timetoenddiff = time() - $lastrange['time'];
+
+        $ranges[count($ranges) - 1] = [
+            'start' => $lastrange['start'] + $timetoenddiff,
+            'end' => $lastrange['end'] + $timetoenddiff,
+            'time' => $lastrange['time'] + $timetoenddiff,
+        ];
+
+        return $ranges;
+    }
+}
index 891820e..2fe0ef2 100644 (file)
@@ -42,6 +42,21 @@ abstract class periodic extends base {
      */
     abstract protected function periodicity();
 
+    /**
+     * Gets the next range with start on the provided time.
+     *
+     * @param  \DateTimeImmutable $time
+     * @return array
+     */
+    abstract protected function get_next_range(\DateTimeImmutable $time);
+
+    /**
+     * Get the start of the first time range.
+     *
+     * @return int A timestamp.
+     */
+    abstract protected function get_first_start();
+
     /**
      * Returns whether the analysable can be processed by this time splitting method or not.
      *
@@ -67,25 +82,42 @@ abstract class periodic extends base {
         if ($this->analysable->get_end()) {
             $end = (new \DateTimeImmutable())->setTimestamp($this->analysable->get_end());
         }
-        $next = (new \DateTimeImmutable())->setTimestamp($this->get_first_start());
+        $nexttime = (new \DateTimeImmutable())->setTimestamp($this->get_first_start());
 
         $now = new \DateTimeImmutable('now', \core_date::get_server_timezone_object());
 
-        $ranges = [];
-        while ($next < $now &&
-                (empty($end) || $next < $end)) {
-            $range = $this->get_next_range($next);
-            if ($range) {
-                $ranges[] = $range;
+        $range = $this->get_next_range($nexttime);
+        if (!$range) {
+            $nexttime = $nexttime->add($periodicity);
+            $range = $this->get_next_range($nexttime);
+
+            if (!$range) {
+                throw new \coding_exception('The get_next_range implementation is broken. The difference between two consecutive
+                    ranges can not be more than the periodicity.');
             }
-            $next = $next->add($periodicity);
         }
 
-        $nextrange = $this->get_next_range($next);
-        if ($this->ready_to_predict($nextrange) && (empty($end) || $next < $end)) {
-            // Add the next one if we have not reached the analysable end yet.
-            // It will be used to get predictions.
-            $ranges[] = $nextrange;
+        $ranges = [];
+        $endreached = false;
+        while (($this->ready_to_predict($range) || $this->ready_to_train($range)) && !$endreached) {
+            $ranges[] = $range;
+            $nexttime = $nexttime->add($periodicity);
+            $range = $this->get_next_range($nexttime);
+
+            $endreached = (!empty($end) && $nexttime > $end);
+        }
+
+        if ($ranges && !$endreached) {
+            // If this analysable is not finished we adjust the start and end of the last element in $ranges
+            // so that it ends in time().The reason is that the start of these ranges is based on the analysable
+            // start and the end is calculated based on the start. This is to prevent the same issue we had in MDL-65348.
+            //
+            // An example of the situation we want to avoid is:
+            // A course started on a Monday, in 2015. It has no end date. Now the system is upgraded to Moodle 3.8, which
+            // includes this code. This happens on Wednesday. Periodic ranges (e.g. weekly) will be calculated from a Monday
+            // so the data provided by the time-splitting method would be from Monday to Monday, when we really want to
+            // provide data from Wednesday to the past Wednesday.
+            $ranges = $this->update_last_range($ranges);
         }
 
         return $ranges;
@@ -119,34 +151,12 @@ abstract class periodic extends base {
     }
 
     /**
-     * The next range is based on the past period.
+     * Allows child classes to update the last range provided.
      *
-     * @param  \DateTimeImmutable $next
+     * @param  array  $ranges
      * @return array
      */
-    protected function get_next_range(\DateTimeImmutable $next) {
-
-        $end = $next->getTimestamp();
-        $start = $next->sub($this->periodicity())->getTimestamp();
-
-        if ($start < $this->analysable->get_start()) {
-            // We skip the first range generated as its start is prior to the analysable start.
-            return false;
-        }
-
-        return [
-            'start' => $start,
-            'end' => $end,
-            'time' => $end
-        ];
-    }
-
-    /**
-     * Get the start of the first time range.
-     *
-     * @return int A timestamp.
-     */
-    protected function get_first_start() {
-        return $this->analysable->get_start();
+    protected function update_last_range(array $ranges) {
+        return $ranges;
     }
 }
index 7b3c9c6..9d4e1da 100644 (file)
@@ -36,15 +36,18 @@ defined('MOODLE_INTERNAL') || die();
 abstract class upcoming_periodic extends periodic implements after_now {
 
     /**
-     * The next range indicator calculations should be based on upcoming dates.
+     * Gets the next range with start on the provided time.
      *
-     * @param  \DateTimeImmutable $next
+     * The next range is based on the upcoming period so we add this
+     * range's periodicity to $time.
+     *
+     * @param  \DateTimeImmutable $time
      * @return array
      */
-    protected function get_next_range(\DateTimeImmutable $next) {
+    protected function get_next_range(\DateTimeImmutable $time) {
 
-        $start = $next->getTimestamp();
-        $end = $next->add($this->periodicity())->getTimestamp();
+        $start = $time->getTimestamp();
+        $end = $time->add($this->periodicity())->getTimestamp();
         return [
             'start' => $start,
             'end' => $end,
@@ -87,7 +90,7 @@ abstract class upcoming_periodic extends periodic implements after_now {
             return $firstanalysis;
         }
 
-        // This analysable has not yet been analysed, the start is therefore now (-1 so ready_to_predict can be executed).
-        return time() - 1;
+        // This analysable has not yet been analysed, the start is therefore now.
+        return time();
     }
 }
index e4b4f70..289474b 100644 (file)
@@ -31,7 +31,7 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class test_timesplitting_seconds extends \core_analytics\local\time_splitting\periodic {
+class test_timesplitting_seconds extends \core_analytics\local\time_splitting\past_periodic {
 
     /**
      * Every second.
index 74e6721..c092491 100644 (file)
@@ -365,10 +365,14 @@ class analytics_manager_testcase extends advanced_testcase {
         $noteaching = \core_analytics\manager::get_target('\core_course\analytics\target\no_teaching');
         $dropout = \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout');
         $upcomingactivities = \core_analytics\manager::get_target('\core_user\analytics\target\upcoming_activities_due');
+        $norecentaccesses = \core_analytics\manager::get_target('\core_course\analytics\target\no_recent_accesses');
+        $noaccesssincestart = \core_analytics\manager::get_target('\core_course\analytics\target\no_access_since_course_start');
 
         $this->assertTrue(\core_analytics\model::exists($noteaching));
         $this->assertTrue(\core_analytics\model::exists($dropout));
         $this->assertTrue(\core_analytics\model::exists($upcomingactivities));
+        $this->assertTrue(\core_analytics\model::exists($norecentaccesses));
+        $this->assertTrue(\core_analytics\model::exists($noaccesssincestart));
 
         foreach (\core_analytics\manager::get_all_models() as $model) {
             $model->delete();
@@ -377,16 +381,22 @@ class analytics_manager_testcase extends advanced_testcase {
         $this->assertFalse(\core_analytics\model::exists($noteaching));
         $this->assertFalse(\core_analytics\model::exists($dropout));
         $this->assertFalse(\core_analytics\model::exists($upcomingactivities));
+        $this->assertFalse(\core_analytics\model::exists($norecentaccesses));
+        $this->assertFalse(\core_analytics\model::exists($noaccesssincestart));
 
         $updated = \core_analytics\manager::update_default_models_for_component('moodle');
 
-        $this->assertEquals(3, count($updated));
+        $this->assertEquals(5, count($updated));
+        $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
+        $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
         $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
         $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
         $this->assertTrue(array_pop($updated) instanceof \core_analytics\model);
         $this->assertTrue(\core_analytics\model::exists($noteaching));
         $this->assertTrue(\core_analytics\model::exists($dropout));
         $this->assertTrue(\core_analytics\model::exists($upcomingactivities));
+        $this->assertTrue(\core_analytics\model::exists($norecentaccesses));
+        $this->assertTrue(\core_analytics\model::exists($noaccesssincestart));
 
         $repeated = \core_analytics\manager::update_default_models_for_component('moodle');
 
index 01bc9fd..d543fa1 100644 (file)
@@ -53,7 +53,7 @@ class analytics_stats_testcase extends advanced_testcase {
 
         // By default, sites have {@link \core_course\analytics\target\no_teaching} and
         // {@link \core_user\analytics\target\upcoming_activities_due} enabled.
-        $this->assertEquals(2, \core_analytics\stats::enabled_models());
+        $this->assertEquals(4, \core_analytics\stats::enabled_models());
 
         $model = \core_analytics\model::create(
             \core_analytics\manager::get_target('\core_course\analytics\target\course_dropout'),
@@ -63,11 +63,11 @@ class analytics_stats_testcase extends advanced_testcase {
         );
 
         // Purely adding a new model does not make it included in the stats.
-        $this->assertEquals(2, \core_analytics\stats::enabled_models());
+        $this->assertEquals(4, \core_analytics\stats::enabled_models());
 
         // New models must be enabled to have them counted.
         $model->enable('\core\analytics\time_splitting\quarters');
-        $this->assertEquals(3, \core_analytics\stats::enabled_models());
+        $this->assertEquals(5, \core_analytics\stats::enabled_models());
     }
 
     /**
index 774b4fc..adb4b9c 100644 (file)
@@ -12,6 +12,9 @@ information provided here is intended especially for developers.
 * Indicators can add information about calculated values by calling add_shared_calculation_info(). This
   data is later available for targets in get_insight_body_for_prediction(), it can be accessed
   appending ':extradata' to the indicator name (e.g. $sampledata['\mod_yeah\analytics\indicator\ou:extradata')
+* A new \core_analytics\local\time_splitting\past_periodic abstract class has been added. Time-splitting
+  methods extending \core_analytics\local\time_splitting\periodic directly should be extending past_periodic
+  now. 'periodic' can still be directly extended by implementing get_next_range and get_first_start methods.
 
 === 3.7 ===
 
index 8c4f836..167b24d 100644 (file)
@@ -56,7 +56,11 @@ class behat_block_social_activities extends behat_base {
     }
 
     /**
-     * Checks that the specified activity's action menu contains an item.
+     * Checks that the specified activity in the social activities block should have the specified editing icon.
+     *
+     * This includes items in the action menu for the item (does not require it to be open)
+     *
+     * You should be in the course page with editing mode turned on.
      *
      * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block should have "(?P<icon_name_string>(?:[^"]|\\")*)" editing icon$/
      * @param string $activityname
@@ -71,7 +75,11 @@ class behat_block_social_activities extends behat_base {
     }
 
     /**
-     * Checks that the specified activity's action menu contains an item.
+     * Checks that the specified activity in the social activities block should not have the specified editing icon.
+     *
+     * This includes items in the action menu for the item (does not require it to be open)
+     *
+     * You should be in the course page with editing mode turned on.
      *
      * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block should not have "(?P<icon_name_string>(?:[^"]|\\")*)" editing icon$/
      * @param string $activityname
@@ -103,7 +111,7 @@ class behat_block_social_activities extends behat_base {
     }
 
     /**
-     * Clicks on the specified element inside the activity container.
+     * Finds the element containing a specific activity in the social activity block.
      *
      * @throws ElementNotFoundException
      * @param string $element
@@ -119,7 +127,7 @@ class behat_block_social_activities extends behat_base {
     }
 
     /**
-     * Checks that the specified activity is hidden.
+     * Checks that the specified activity is hidden in the social activities block.
      *
      * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block should be hidden$/
      * @param string $activityname
@@ -129,7 +137,7 @@ class behat_block_social_activities extends behat_base {
     }
 
     /**
-     * Checks that the specified activity is hidden.
+     * Checks that the specified activity is hidden in the social activities block.
      *
      * @Then /^"(?P<activity_name_string>(?:[^"]|\\")*)" activity in social activities block should be available but hidden from course page$/
      * @param string $activityname
@@ -139,7 +147,7 @@ class behat_block_social_activities extends behat_base {
     }
 
     /**
-     * Opens an activity actions menu if it is not already opened.
+     * Opens an activity actions menu in the social activities block if it is not already opened.
      *
      * @Given /^I open "(?P<activity_name_string>(?:[^"]|\\")*)" actions menu in social activities block$/
      * @throws DriverException The step is not available when Javascript is disabled
index 6a9f95d..49ead88 100644 (file)
Binary files a/calendar/amd/build/calendar_filter.min.js and b/calendar/amd/build/calendar_filter.min.js differ
index 68ce74d..91dd103 100644 (file)
Binary files a/calendar/amd/build/calendar_filter.min.js.map and b/calendar/amd/build/calendar_filter.min.js.map differ
index 622dafd..41dd448 100644 (file)
@@ -67,6 +67,9 @@ function(
         return Str.get_string('eventtype' + data.eventtype, 'calendar')
         .then(function(nameStr) {
             data.name = nameStr;
+            data.icon = true;
+            data.key = 'i/' + data.eventtype + 'event';
+            data.component = 'core';
 
             return data;
         })
index 608bb6e..831c0ce 100644 (file)
@@ -3672,6 +3672,9 @@ function calendar_get_filter_types() {
         return [
             'eventtype' => $type,
             'name' => get_string("eventtype{$type}", "calendar"),
+            'icon' => true,
+            'key' => 'i/' . $type . 'event',
+            'component' => 'core'
         ];
     }, $types);
 }
index fb88bd6..ea61b32 100644 (file)
@@ -37,5 +37,5 @@
     {{#pix}} icon, {{modulename}} {{/pix}}
 {{/modulename}}
 {{^modulename}}
-    {{#pix}} i/{{eventtype}}event, core {{/pix}}
+    {{#icon}}{{#pix}} {{key}}, {{component}}, {{alttext}} {{/pix}}{{/icon}}
 {{/modulename}}
index b1aa06f..cb3e229 100644 (file)
                                 {{$content}}
                                     {{#events}}
                                         <div data-popover-eventtype-{{normalisedeventtype}}="1">
-                                            {{#modulename}}
-                                                {{#pix}} icon, {{modulename}} {{/pix}}
-                                            {{/modulename}}
-                                            {{^modulename}}
-                                                {{#pix}} i/{{eventtype}}event, core {{/pix}}
-                                            {{/modulename}}
+                                            {{> core_calendar/event_icon}}
                                             {{{popupname}}}
                                         </div>
                                     {{/events}}
index 2c61be4..849d8d8 100644 (file)
@@ -133,6 +133,7 @@ function core_competency_comment_add($comment, $params) {
         $message->contexturlname = $urlname;
 
         $userpicture = new \user_picture($user);
+        $userpicture->size = 1; // Use f1 size.
         // Message each recipient.
         foreach ($recipients as $recipient) {
             $msgcopy = clone($message);
@@ -208,6 +209,7 @@ function core_competency_comment_add($comment, $params) {
         $message->contexturlname = $urlname;
 
         $userpicture = new \user_picture($user);
+        $userpicture->size = 1; // Use f1 size.
         // Message each recipient.
         foreach ($recipients as $recipient) {
             $msgcopy = clone($message);
index dcfe80e..0575f35 100644 (file)
@@ -101,6 +101,7 @@ class core_competency_lib_testcase extends advanced_testcase {
         $this->assertObjectHasAttribute('notificationiconurl', $customdata);
         $this->assertContains('tokenpluginfile.php', $customdata->notificationiconurl);
         $userpicture = new \user_picture($u1);
+        $userpicture->size = 1; // Use f1 size.
         $userpicture->includetoken = $u2->id;
         $this->assertEquals($userpicture->get_url($PAGE)->out(false), $customdata->notificationiconurl);
 
index cf3118a..56b3803 100644 (file)
@@ -847,6 +847,10 @@ $CFG->admin = 'admin';
 // Example:
 //   $CFG->behat_faildump_path = '/my/path/to/save/failure/dumps';
 //
+// You can make behat pause upon failure to help you diagnose and debug problems with your tests.
+//
+//   $CFG->behat_pause_on_fail = true;
+//
 // You can specify db, selenium wd_host etc. for behat parallel run by setting following variable.
 // Example:
 //   $CFG->behat_parallel_run = array (
diff --git a/course/classes/analytics/target/no_access_since_course_start.php b/course/classes/analytics/target/no_access_since_course_start.php
new file mode 100644 (file)
index 0000000..32c12af
--- /dev/null
@@ -0,0 +1,80 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * No accesses since the start of the course.
+ *
+ * @package   core_course
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\analytics\target;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * No accesses since the start of the course.
+ *
+ * @package   core_course
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class no_access_since_course_start extends no_recent_accesses {
+
+    /**
+     * Only past stuff whose start matches the course start.
+     *
+     * @param  \core_analytics\local\time_splitting\base $timesplitting
+     * @return bool
+     */
+    public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool {
+        return ($timesplitting instanceof \core_analytics\local\time_splitting\after_start);
+    }
+
+    /**
+     * Returns the name.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('target:noaccesssincecoursestart', 'course');
+    }
+
+    /**
+     * Returns the body message for the insight.
+     *
+     * @param  \context     $context
+     * @param  string       $contextname
+     * @param  \stdClass    $user
+     * @param  \moodle_url  $insighturl
+     * @return array                        The plain text message and the HTML message
+     */
+    public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array {
+        global $OUTPUT;
+
+        $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname];
+        $fullmessage = get_string('noaccesssincestartinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false);
+        $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message',
+            ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('noaccesssincestartinfomessage', 'course', $a)]
+        );
+
+        return [$fullmessage, $fullmessagehtml];
+    }
+
+}
diff --git a/course/classes/analytics/target/no_recent_accesses.php b/course/classes/analytics/target/no_recent_accesses.php
new file mode 100644 (file)
index 0000000..4f7626b
--- /dev/null
@@ -0,0 +1,135 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * No recent accesses.
+ *
+ * @package   core_course
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_course\analytics\target;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * No recent accesses.
+ *
+ * @package   core_course
+ * @copyright 2019 David Monllaó {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class no_recent_accesses extends course_enrolments {
+
+    /**
+     * Machine learning backends are not required to predict.
+     *
+     * @return bool
+     */
+    public static function based_on_assumptions() {
+        return true;
+    }
+
+    /**
+     * Returns the name.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('target:norecentaccesses', 'course');
+    }
+
+    /**
+     * Returns the body message for the insight.
+     *
+     * @param  \context     $context
+     * @param  string       $contextname
+     * @param  \stdClass    $user
+     * @param  \moodle_url  $insighturl
+     * @return array                        The plain text message and the HTML message
+     */
+    public function get_insight_body(\context $context, string $contextname, \stdClass $user, \moodle_url $insighturl): array {
+        global $OUTPUT;
+
+        $a = (object)['coursename' => $contextname, 'userfirstname' => $user->firstname];
+        $fullmessage = get_string('norecentaccessesinfomessage', 'course', $a) . PHP_EOL . PHP_EOL . $insighturl->out(false);
+        $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message',
+            ['url' => $insighturl->out(false), 'insightinfomessage' => get_string('norecentaccessesinfomessage', 'course', $a)]
+        );
+
+        return [$fullmessage, $fullmessagehtml];
+    }
+
+    /**
+     * Only past stuff whose start matches the course start.
+     *
+     * @param  \core_analytics\local\time_splitting\base $timesplitting
+     * @return bool
+     */
+    public function can_use_timesplitting(\core_analytics\local\time_splitting\base $timesplitting): bool {
+        return ($timesplitting instanceof \core_analytics\local\time_splitting\past_periodic);
+    }
+
+    /**
+     * Discards courses that are not yet ready to be used for prediction.
+     *
+     * @param \core_analytics\analysable $course
+     * @param bool $fortraining
+     * @return true|string
+     */
+    public function is_valid_analysable(\core_analytics\analysable $course, $fortraining = true) {
+
+        if (!$course->was_started()) {
+            return get_string('coursenotyetstarted', 'course');
+        }
+
+        if (!$this->students = $course->get_students()) {
+            return get_string('nocoursestudents', 'course');
+        }
+
+        if ($course->get_end() && $course->get_end() < $course->get_start()) {
+            return get_string('errorendbeforestart', 'course');
+        }
+
+        // Finished courses can not be used to get predictions.
+        if (!$fortraining && $course->is_finished()) {
+            return get_string('coursealreadyfinished', 'course');
+        }
+
+        return true;
+    }
+
+    /**
+     * Do the user has any read action in the course?
+     *
+     * @param int $sampleid
+     * @param \core_analytics\analysable $analysable
+     * @param int $starttime
+     * @param int $endtime
+     * @return float 0 -> accesses, 1 -> no accesses.
+     */
+    protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
+
+        $readactions = $this->retrieve('\core\analytics\indicator\any_course_access', $sampleid);
+        if ($readactions == \core\analytics\indicator\any_course_access::get_min_value()) {
+            return 1;
+        }
+        return 0;
+    }
+}
index b04440b..04ece14 100644 (file)
@@ -84,7 +84,14 @@ if ($deletesection) {
     }
 }
 
-$editoroptions = array('context'=>$context ,'maxfiles' => EDITOR_UNLIMITED_FILES, 'maxbytes'=>$CFG->maxbytes, 'trusttext'=>false, 'noclean'=>true);
+$editoroptions = array(
+    'context'   => $context,
+    'maxfiles'  => EDITOR_UNLIMITED_FILES,
+    'maxbytes'  => $CFG->maxbytes,
+    'trusttext' => false,
+    'noclean'   => true,
+    'subdirs'   => true
+);
 
 $courseformat = course_get_format($course);
 $defaultsectionname = $courseformat->get_default_section_name($section);
index bde9709..44cf6ca 100644 (file)
@@ -50,7 +50,7 @@ if (trim($forum->intro) != '') {
         $streditsummary  = get_string('editsummary');
         $introcontent .= html_writer::start_div('editinglink');
         $introcontent .= html_writer::link(
-            new moodle_url('/modedit.php', [
+            new moodle_url('/course/modedit.php', [
                 'update' => $coursemodule->id,
                 'sesskey' => sesskey(),
             ]),
index 9bf1e5d..3d1f03f 100644 (file)
@@ -584,6 +584,94 @@ class core_enrol_external extends external_api {
         return new external_multiple_structure(core_user_external::user_description());
     }
 
+    /**
+     * Returns description of method parameters
+     *
+     * @return external_function_parameters
+     */
+    public static function search_users_parameters(): external_function_parameters {
+        return new external_function_parameters(
+            [
+                'courseid' => new external_value(PARAM_INT, 'course id'),
+                'search' => new external_value(PARAM_RAW, 'query'),
+                'searchanywhere' => new external_value(PARAM_BOOL, 'find a match anywhere, or only at the beginning'),
+                'page' => new external_value(PARAM_INT, 'Page number'),
+                'perpage' => new external_value(PARAM_INT, 'Number per page'),
+            ]
+        );
+    }
+
+    /**
+     * Search course participants.
+     *
+     * @param int $courseid Course id
+     * @param string $search The query
+     * @param bool $searchanywhere Match anywhere in the string
+     * @param int $page Page number
+     * @param int $perpage Max per page
+     * @return array An array of users
+     * @throws moodle_exception
+     */
+    public static function search_users(int $courseid, string $search, bool $searchanywhere, int $page, int $perpage): array {
+        global $PAGE, $DB, $CFG;
+
+        require_once($CFG->dirroot.'/enrol/locallib.php');
+        require_once($CFG->dirroot.'/user/lib.php');
+
+        $params = self::validate_parameters(
+                self::search_users_parameters(),
+                [
+                    'courseid'       => $courseid,
+                    'search'         => $search,
+                    'searchanywhere' => $searchanywhere,
+                    'page'           => $page,
+                    'perpage'        => $perpage
+                ]
+        );
+        $context = context_course::instance($params['courseid']);
+        try {
+            self::validate_context($context);
+        } catch (Exception $e) {
+            $exceptionparam = new stdClass();
+            $exceptionparam->message = $e->getMessage();
+            $exceptionparam->courseid = $params['courseid'];
+            throw new moodle_exception('errorcoursecontextnotvalid' , 'webservice', '', $exceptionparam);
+        }
+        course_require_view_participants($context);
+
+        $course = get_course($params['courseid']);
+        $manager = new course_enrolment_manager($PAGE, $course);
+
+        $users = $manager->search_users($params['search'],
+                                        $params['searchanywhere'],
+                                        $params['page'],
+                                        $params['perpage']);
+
+        $results = [];
+        // Add also extra user fields.
+        $requiredfields = array_merge(
+                ['id', 'fullname', 'profileimageurl', 'profileimageurlsmall'],
+                get_extra_user_fields($context)
+        );
+        foreach ($users['users'] as $user) {
+            if ($userdetails = user_get_user_details($user, $course, $requiredfields)) {
+                $results[] = $userdetails;
+            }
+        }
+        return $results;
+    }
+
+    /**
+     * Returns description of method result value
+     *
+     * @return external_multiple_structure
+     */
+    public static function search_users_returns(): external_multiple_structure {
+        global $CFG;
+        require_once($CFG->dirroot . '/user/externallib.php');
+        return new external_multiple_structure(core_user_external::user_description());
+    }
+
     /**
      * Returns description of method parameters
      *
index c77e151..bb4c858 100644 (file)
@@ -531,6 +531,35 @@ class course_enrolment_manager {
         return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, 0, $returnexactcount);
     }
 
+    /**
+     * Searches through the enrolled users in this course.
+     *
+     * @param string $search The search term.
+     * @param bool $searchanywhere Can the search term be anywhere, or must it be at the start.
+     * @param int $page Starting at 0.
+     * @param int $perpage Number of users returned per page.
+     * @param bool $returnexactcount Return the exact total users using count_record or not.
+     * @return array with two or three elements:
+     *      int totalusers Number users matching the search. (This element only exist if $returnexactcount was set to true)
+     *      array users List of user objects returned by the query.
+     *      boolean moreusers True if there are still more users, otherwise is False.
+     */
+    public function search_users(string $search = '', bool $searchanywhere = false, int $page = 0, int $perpage = 25,
+            bool $returnexactcount = false) {
+        list($ufields, $params, $wherecondition) = $this->get_basic_search_conditions($search, $searchanywhere);
+
+        $fields      = 'SELECT ' . $ufields;
+        $countfields = 'SELECT COUNT(u.id)';
+        $sql = " FROM {user} u
+                 JOIN {user_enrolments} ue ON ue.userid = u.id
+                 JOIN {enrol} e ON ue.enrolid = e.id
+                WHERE $wherecondition
+                  AND e.courseid = :courseid";
+        $params['courseid'] = $this->course->id;
+
+        return $this->execute_search_queries($search, $fields, $countfields, $sql, $params, $page, $perpage, 0, $returnexactcount);
+    }
+
     /**
      * Gets an array containing some SQL to user for when selecting, params for
      * that SQL, and the filter that was used in constructing the sql.
index 9e9f0ed..6954ff3 100644 (file)
@@ -325,7 +325,7 @@ class core_course_enrolment_manager_testcase extends advanced_testcase {
     }
 
     /**
-     * Test case for test_get_potential_users and test_search_other_users tests.
+     * Test case for test_get_potential_users, test_search_other_users and test_search_users tests.
      *
      * @return array Dataset
      */
@@ -337,4 +337,42 @@ class core_course_enrolment_manager_testcase extends advanced_testcase {
                 [5, true, 3, 3, false]
         ];
     }
+
+    /**
+     * Test search_users function.
+     *
+     * @dataProvider search_users_provider
+     *
+     * @param int $perpage Number of users per page.
+     * @param bool $returnexactcount Return the exact count or not.
+     * @param int $expectedusers Expected number of users return.
+     * @param int $expectedtotalusers Expected total of users in database.
+     * @param bool $expectedmoreusers Expected for more users return or not.
+     */
+    public function test_search_users($perpage, $returnexactcount, $expectedusers, $expectedtotalusers, $expectedmoreusers) {
+        global $PAGE;
+        $this->resetAfterTest();
+
+        $this->getDataGenerator()->create_and_enrol($this->course, 'student', ['firstname' => 'sutest 1']);
+        $this->getDataGenerator()->create_and_enrol($this->course, 'student', ['firstname' => 'sutest 2']);
+        $this->getDataGenerator()->create_and_enrol($this->course, 'student', ['firstname' => 'sutest 3']);
+
+        $manager = new course_enrolment_manager($PAGE, $this->course);
+        $users = $manager->search_users(
+            'sutest',
+            true,
+            0,
+            $perpage,
+            $returnexactcount
+        );
+
+        $this->assertCount($expectedusers, $users['users']);
+        $this->assertEquals($expectedmoreusers, $users['moreusers']);
+        if ($returnexactcount) {
+            $this->assertArrayHasKey('totalusers', $users);
+            $this->assertEquals($expectedtotalusers, $users['totalusers']);
+        } else {
+            $this->assertArrayNotHasKey('totalusers', $users);
+        }
+    }
 }
index 8ae3b1b..baf1d70 100644 (file)
@@ -1171,4 +1171,92 @@ class core_enrol_externallib_testcase extends externallib_advanced_testcase {
         $ue = $DB->count_records('user_enrolments', ['id' => $ueid]);
         $this->assertEquals(0, $ue);
     }
+
+    /**
+     * Test for core_enrol_external::test_search_users().
+     */
+    public function test_search_users() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+        $datagen = $this->getDataGenerator();
+
+        /** @var enrol_manual_plugin $manualplugin */
+        $manualplugin = enrol_get_plugin('manual');
+        $this->assertNotNull($manualplugin);
+
+        $studentroleid = $DB->get_field('role', 'id', ['shortname' => 'student'], MUST_EXIST);
+        $teacherroleid = $DB->get_field('role', 'id', ['shortname' => 'editingteacher'], MUST_EXIST);
+
+        $course1 = $datagen->create_course();
+        $course2 = $datagen->create_course();
+
+        $user1 = $datagen->create_user(['firstname' => 'user 1']);
+        $user2 = $datagen->create_user(['firstname' => 'user 2']);
+        $user3 = $datagen->create_user(['firstname' => 'user 3']);
+        $teacher = $datagen->create_user(['firstname' => 'user 4']);
+
+        $instanceid = null;
+        $instances = enrol_get_instances($course1->id, true);
+        foreach ($instances as $inst) {
+            if ($inst->enrol == 'manual') {
+                $instanceid = (int)$inst->id;
+                break;
+            }
+        }
+        if (empty($instanceid)) {
+            $instanceid = $manualplugin->add_default_instance($course1);
+            if (empty($instanceid)) {
+                $instanceid = $manualplugin->add_instance($course1);
+            }
+        }
+        $this->assertNotNull($instanceid);
+
+        $instance = $DB->get_record('enrol', ['id' => $instanceid], '*', MUST_EXIST);
+        $manualplugin->enrol_user($instance, $user1->id, $studentroleid, 0, 0, ENROL_USER_ACTIVE);
+        $manualplugin->enrol_user($instance, $user2->id, $studentroleid, 0, 0, ENROL_USER_ACTIVE);
+        $manualplugin->enrol_user($instance, $user3->id, $studentroleid, 0, 0, ENROL_USER_ACTIVE);
+        $manualplugin->enrol_user($instance, $teacher->id, $teacherroleid, 0, 0, ENROL_USER_ACTIVE);
+
+        $this->setUser($teacher);
+
+        // Search for users in a course with enrolled users.
+        $result = core_enrol_external::search_users($course1->id, 'user', true, 0, 30);
+        $this->assertCount(4, $result);
+
+        $this->expectException('moodle_exception');
+        // Search for users in a course without any enrolled users, shouldn't return anything.
+        $result = core_enrol_external::search_users($course2->id, 'user', true, 0, 30);
+        $this->assertCount(0, $result);
+
+        // Search for invalid first name.
+        $result = core_enrol_external::search_users($course1->id, 'yada yada', true, 0, 30);
+        $this->assertCount(0, $result);
+
+        // Test pagination, it should return only 3 users.
+        $result = core_enrol_external::search_users($course1->id, 'user', true, 0, 3);
+        $this->assertCount(3, $result);
+
+        // Test pagination, it should return only 3 users.
+        $result = core_enrol_external::search_users($course1->id, 'user 1', true, 0, 1);
+        $result = $result[0];
+        $this->assertEquals($user1->id, $result['id']);
+        $this->assertEquals($user1->email, $result['email']);
+        $this->assertEquals(fullname($user1), $result['fullname']);
+
+        $this->setUser($user1);
+
+        // Search for users in a course with enrolled users.
+        $result = core_enrol_external::search_users($course1->id, 'user', true, 0, 30);
+        $this->assertCount(4, $result);
+
+        $this->expectException('moodle_exception');
+        // Search for users in a course without any enrolled users, shouldn't return anything.
+        $result = core_enrol_external::search_users($course2->id, 'user', true, 0, 30);
+        $this->assertCount(0, $result);
+
+        // Search for invalid first name.
+        $result = core_enrol_external::search_users($course1->id, 'yada yada', true, 0, 30);
+        $this->assertCount(0, $result);
+    }
 }
index c491170..4a664d1 100644 (file)
@@ -147,7 +147,7 @@ class stored_file_exporter extends \core\external\exporter {
             $filenameshort .= substr($filename, -4);
         }
 
-        $icon = $this->file->is_directory() ? file_folder_icon() : file_file_icon($this->file);
+        $icon = $this->file->is_directory() ? file_folder_icon(128) : file_file_icon($this->file, 128);
 
         $url = moodle_url::make_pluginfile_url(
             $this->file->get_contextid(),
diff --git a/grade/grading/form/guide/tests/generator/criterion.php b/grade/grading/form/guide/tests/generator/criterion.php
new file mode 100644 (file)
index 0000000..7921013
--- /dev/null
@@ -0,0 +1,114 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator for the gradingforum_guide plugin.
+ *
+ * @package    gradingform_guide
+ * @category   test
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\gradingform_guide\generator;
+
+/**
+ * Convenience class to create guide criterion.
+ *
+ * @package    gradingform_guide
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class criterion {
+    /** @var string $shortname A shortened name of the criterion. */
+    private $shortname;
+
+    /** @var string $description A description of the criterion. */
+    private $description;
+
+    /** @var string $descriptionmarkers A description of the criterion for markers. */
+    private $descriptionmarkers;
+
+    /** @var float Maximum score */
+    private $maxscore = 0;
+
+    /**
+     * Constructor for this test_criterion object
+     *
+     * @param string $shortname The shortname for the criterion
+     * @param string $description The description for the criterion
+     * @param string $descriptionmarkers The description for the marker for this criterion
+     * @param float $maxscore The maximum score possible for this criterion
+     */
+    public function __construct(string $shortname, string $description, string $descriptionmarkers, float $maxscore) {
+        $this->shortname = $shortname;
+        $this->description = $description;
+        $this->descriptionmarkers = $descriptionmarkers;
+        $this->maxscore = $maxscore;
+    }
+
+    /**
+     * Get the description for this criterion.
+     *
+     * @return string
+     */
+    public function get_description(): string {
+        return $this->description;
+    }
+
+    /**
+     * Get the description for markers of this criterion.
+     *
+     * @return string
+     */
+    public function get_descriptionmarkers(): string {
+        return $this->descriptionmarkers;
+    }
+
+    /**
+     * Get the shortname for this criterion.
+     *
+     * @return string
+     */
+    public function get_shortname(): string {
+        return $this->shortname;
+    }
+
+    /**
+     * Get the maxscore for this criterion.
+     *
+     * @return float
+     */
+    public function get_maxscore(): float {
+        return $this->maxscore;
+    }
+
+    /**
+     * Get all values in an array for use when creating a new guide.
+     *
+     * @param int $sortorder
+     * @return array
+     */
+    public function get_all_values(int $sortorder): array {
+        return [
+            'sortorder' => $sortorder,
+            'shortname' => $this->get_shortname(),
+            'description' => $this->get_description(),
+            'descriptionmarkers' => $this->get_descriptionmarkers(),
+            'maxscore' => $this->get_maxscore(),
+        ];
+    }
+}
diff --git a/grade/grading/form/guide/tests/generator/guide.php b/grade/grading/form/guide/tests/generator/guide.php
new file mode 100644 (file)
index 0000000..471dd08
--- /dev/null
@@ -0,0 +1,125 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator for the gradingforum_guide plugin.
+ *
+ * @package    gradingform_guide
+ * @category   test
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\gradingform_guide\generator;
+
+use gradingform_controller;
+use stdClass;
+
+/**
+ * Test guide.
+ *
+ * @package    gradingform_guide
+ * @category   test
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class guide {
+
+    /** @var array $criteria The criteria for this guide. */
+    protected $criteria = [];
+
+    /** @var string The name of this guide. */
+    protected $name;
+
+    /** @var string A description for this guide. */
+    protected $description;
+
+    /** @var array The guide options. */
+    protected $options = [];
+
+    /**
+     * Create a new gradingform_guide_generator_criterion.
+     *
+     * @param string $name
+     * @param string $description
+     */
+    public function __construct(string $name, string $description) {
+        $this->name = $name;
+        $this->description = $description;
+
+        $this->set_option('alwaysshowdefinition', 1);
+        $this->set_option('showmarkspercriterionstudents', 1);
+    }
+
+    /**
+     * Creates the guide using the appropriate APIs.
+     */
+    public function get_definition(): stdClass {
+        return (object) [
+            'name' => $this->name,
+            'description_editor' => [
+                'text' => $this->description,
+                'format' => FORMAT_HTML,
+                'itemid' => 1
+            ],
+            'guide' => [
+                'criteria' => $this->get_critiera_as_array(),
+                'options' => $this->options,
+                'comments' => [],
+            ],
+            'saveguide' => 'Continue',
+            'status' => gradingform_controller::DEFINITION_STATUS_READY,
+        ];
+    }
+
+    /**
+     * Set an option for the rubric.
+     *
+     * @param string $key
+     * @param mixed $value
+     * @return self
+     */
+    public function set_option(string $key, $value): self {
+        $this->options[$key] = $value;
+        return $this;
+    }
+
+    /**
+     * Adds a criterion to the guide.
+     *
+     * @param criterion $criterion The criterion object (class below).
+     * @return self
+     */
+    public function add_criteria(criterion $criterion): self {
+        $this->criteria[] = $criterion;
+
+        return $this;
+    }
+
+    /**
+     * Get the criteria as an array for use in creation.
+     *
+     * @return array
+     */
+    protected function get_critiera_as_array(): array {
+        $return = [];
+        foreach ($this->criteria as $index => $criterion) {
+            $return["NEWID{$index}"] = $criterion->get_all_values($index + 1);
+        }
+
+        return $return;
+    }
+}
diff --git a/grade/grading/form/guide/tests/generator/lib.php b/grade/grading/form/guide/tests/generator/lib.php
new file mode 100644 (file)
index 0000000..faf0cb1
--- /dev/null
@@ -0,0 +1,235 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator for the gradingforum_guide plugin.
+ *
+ * @package    gradingform_guide
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/guide.php');
+require_once(__DIR__ . '/criterion.php');
+
+use tests\gradingform_guide\generator\guide;
+use tests\gradingform_guide\generator\criterion;
+
+/**
+ * Generator for the gradingforum_guide plugintype.
+ *
+ * @package    gradingform_guide
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class gradingform_guide_generator extends component_generator_base {
+
+    /**
+     * Create an instance of a marking guide.
+     *
+     * @param context $context
+     * @param string $component
+     * @param string $area
+     * @param string $name
+     * @param string $description
+     * @param array $criteria The list of criteria to add to the generated guide
+     * @return gradingform_guide_controller
+     */
+    public function create_instance(
+        context $context,
+        string $component,
+        string $area,
+        string $name,
+        string $description,
+        array $criteria
+    ): gradingform_guide_controller {
+        global $USER;
+
+        if ($USER->id === 0) {
+            throw new \coding_exception('Creation of a guide must currently be run as a user.');
+        }
+
+        // Fetch the controller for this context/component/area.
+        $generator = \testing_util::get_data_generator();
+        $gradinggenerator = $generator->get_plugin_generator('core_grading');
+        $controller = $gradinggenerator->create_instance($context, $component, $area, 'guide');
+
+        // Generate a definition for the supplied guide.
+        $guide = $this->get_guide($name, $description);
+        foreach ($criteria as $name => $options) {
+            $guide->add_criteria($this->get_criterion(
+                $name,
+                $options['description'],
+                $options['descriptionmarkers'],
+                $options['maxscore']
+            ));
+        }
+
+        // Update the controller wih the guide definition.
+        $controller->update_definition($guide->get_definition());
+
+        return $controller;
+    }
+
+    /**
+     * Get a new guide for use with the guide controller.
+     *
+     * Note: This is just a helper class used to build a new definition. It does not persist the data.
+     *
+     * @param string $name
+     * @param string $description
+     * @return generator_guide
+     */
+    protected function get_guide(string $name, string $description): guide {
+        return new \tests\gradingform_guide\generator\guide($name, $description);
+    }
+
+    /**
+     * Get a new criterion for use with a guide.
+     *
+     * Note: This is just a helper class used to build a new definition. It does not persist the data.
+     *
+     * @param string $shortname The shortname for the criterion
+     * @param string $description The description for the criterion
+     * @param string $descriptionmarkers The description for the marker for this criterion
+     * @param float $maxscore The maximum score possible for this criterion
+     * @return criterion
+     */
+    protected function get_criterion(
+        string $shortname,
+        string $description,
+        string $descriptionmarkers,
+        float $maxscore
+    ): criterion {
+        return new criterion($shortname, $description, $descriptionmarkers, $maxscore);
+    }
+
+    /**
+     * Given a controller instance, fetch the level and criterion information for the specified values.
+     *
+     * @param gradingform_controller $controller
+     * @param string $shortname The shortname to match the criterion on
+     * @return stdClass
+     */
+    public function get_criterion_for_values(gradingform_controller $controller, string $shortname): ?stdClass {
+        $definition = $controller->get_definition();
+        $criteria = $definition->guide_criteria;
+
+        $criterion = array_reduce($criteria, function($carry, $criterion) use ($shortname) {
+            if ($criterion['shortname'] === $shortname) {
+                $carry = (object) $criterion;
+            }
+
+            return $carry;
+        }, null);
+
+        return $criterion;
+    }
+
+    /**
+     * Get submitted form data
+     *
+     * @param gradingform_guide_controller $controller
+     * @param int $itemid
+     * @param array $values A set of array values where the array key is the name of the criterion, and the value is an
+     * array with the desired score, and any remark.
+     */
+    public function get_submitted_form_data(gradingform_guide_controller $controller, int $itemid, array $values): array {
+        $result = [
+            'itemid' => $itemid,
+            'criteria' => [],
+        ];
+        foreach ($values as $criterionname => ['score' => $score, 'remark' => $remark]) {
+            $criterion = $this->get_criterion_for_values($controller, $criterionname);
+            $result['criteria'][$criterion->id] = [
+                'score' => $score,
+                'remark' => $remark,
+            ];
+        }
+
+        return $result;
+    }
+
+    /**
+     * Generate a guide controller with sample data required for testing of this class.
+     *
+     * @param context_module $context
+     * @return gradingform_guide_controller
+     */
+    public function get_test_guide(context_module $context): 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');
+
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        $guide = $guidegenerator->get_guide('testguide', 'Description text');
+
+        $guide->add_criteria($guidegenerator->get_criterion(
+            'Spelling mistakes',
+            'Full marks will be given for no spelling mistakes.',
+            'Deduct 5 points per spelling mistake made.',
+            25
+        ));
+        $guide->add_criteria($guidegenerator->get_criterion(
+            'Pictures',
+            'Full marks will be given for including 3 pictures.',
+            'Give 5 points for each picture present',
+            15
+        ));
+        $controller->update_definition($guide->get_definition());
+
+        return $controller;
+    }
+
+    /**
+     * Fetch a set of sample data.
+     *
+     * @param gradingform_guide_controller $controller
+     * @param int $itemid
+     * @param float $spellingscore
+     * @param string $spellingremark
+     * @param float $picturescore
+     * @param string $pictureremark
+     * @return array
+     */
+    public function get_test_form_data(
+        gradingform_guide_controller $controller,
+        int $itemid,
+        float $spellingscore,
+        string $spellingremark,
+        float $picturescore,
+        string $pictureremark
+    ): array {
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+        return $guidegenerator->get_submitted_form_data($controller, $itemid, [
+            'Spelling mistakes' => [
+                'score' => $spellingscore,
+                'remark' => $spellingremark,
+            ],
+            'Pictures' => [
+                'score' => $picturescore,
+                'remark' => $pictureremark,
+            ],
+        ]);
+    }
+}
diff --git a/grade/grading/form/guide/tests/generator_test.php b/grade/grading/form/guide/tests/generator_test.php
new file mode 100644 (file)
index 0000000..6e07c12
--- /dev/null
@@ -0,0 +1,282 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator testcase for the gradingforum_guide generator.
+ *
+ * @package    gradingform_guide
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\gradingform_guide;
+
+use context_module;
+use gradingform_controller;
+use gradingform_guide_controller;
+
+/**
+ * Generator testcase for the gradingforum_guide generator.
+ *
+ * @package    gradingform_guide
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class generator_testcase extends \advanced_testcase {
+
+    /**
+     * Test guide creation.
+     */
+    public function test_guide_creation(): void {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        // Data for testing.
+        $name = 'myfirstguide';
+        $description = 'My first guide';
+        $criteria = [
+            'Alphabet' => [
+                'description' => 'How well you know your alphabet',
+                'descriptionmarkers' => 'Basic literacy: Alphabet',
+                'maxscore' => 5,
+            ],
+            'Times tables' => [
+                'description' => 'How well you know your times-tables',
+                'descriptionmarkers' => 'Basic numeracy: Multiplication',
+                'maxscore' => 10,
+            ],
+        ];
+
+        // Unit under test.
+        $this->setUser($user);
+        $controller = $guidegenerator->create_instance($context, 'mod_assign', 'submission', $name, $description, $criteria);
+
+        $this->assertInstanceOf(gradingform_guide_controller::class, $controller);
+
+        $definition = $controller->get_definition();
+        $this->assertEquals('guide', $definition->method);
+        $this->assertNotEmpty($definition->id);
+        $this->assertEquals($name, $definition->name);
+        $this->assertEquals($description, $definition->description);
+        $this->assertEquals(gradingform_controller::DEFINITION_STATUS_READY, $definition->status);
+        $this->assertNotEmpty($definition->timecreated);
+        $this->assertNotEmpty($definition->timemodified);
+        $this->assertEquals($user->id, $definition->usercreated);
+
+        $this->assertNotEmpty($definition->guide_criteria);
+        $this->assertCount(2, $definition->guide_criteria);
+
+        // Check the alphabet criteria.
+        $criteriaids = array_keys($definition->guide_criteria);
+
+        $alphabet = $definition->guide_criteria[$criteriaids[0]];
+        $this->assertNotEmpty($alphabet['id']);
+        $this->assertEquals(1, $alphabet['sortorder']);
+        $this->assertEquals('How well you know your alphabet', $alphabet['description']);
+        $this->assertEquals('Basic literacy: Alphabet', $alphabet['descriptionmarkers']);
+        $this->assertEquals(5, $alphabet['maxscore']);
+
+        // Check the times tables criteria.
+        $tables = $definition->guide_criteria[$criteriaids[1]];
+        $this->assertNotEmpty($tables['id']);
+        $this->assertEquals(2, $tables['sortorder']);
+        $this->assertEquals('How well you know your times-tables', $tables['description']);
+        $this->assertEquals('Basic numeracy: Multiplication', $tables['descriptionmarkers']);
+        $this->assertEquals(10, $tables['maxscore']);
+    }
+
+    /**
+     * Test the get_criterion_for_values function.
+     * This is used for finding criterion and level information within a guide.
+     */
+    public function test_get_criterion_for_values(): void {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        // Data for testing.
+        $name = 'myfirstguide';
+        $description = 'My first guide';
+        $criteria = [
+            'Alphabet' => [
+                'description' => 'How well you know your alphabet',
+                'descriptionmarkers' => 'Basic literacy: Alphabet',
+                'maxscore' => 5,
+            ],
+            'Times tables' => [
+                'description' => 'How well you know your times-tables',
+                'descriptionmarkers' => 'Basic numeracy: Multiplication',
+                'maxscore' => 10,
+            ],
+        ];
+
+        $this->setUser($user);
+        $controller = $guidegenerator->create_instance($context, 'mod_assign', 'submission', $name, $description, $criteria);
+
+        // Valid criterion.
+        $result = $guidegenerator->get_criterion_for_values($controller, 'Alphabet', 2);
+        $this->assertEquals('Alphabet', $result->shortname);
+        $this->assertEquals('How well you know your alphabet', $result->description);
+        $this->assertEquals('Basic literacy: Alphabet', $result->descriptionmarkers);
+        $this->assertEquals(5, $result->maxscore);
+
+        // Invalid criterion.
+        $result = $guidegenerator->get_criterion_for_values($controller, 'Foo', 0);
+        $this->assertNull($result);
+    }
+
+    /**
+     * Tests for the get_test_guide function.
+     */
+    public function test_get_test_guide(): void {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        $this->setUser($user);
+        $guide = $guidegenerator->get_test_guide($context, 'assign', 'submissions');
+        $definition = $guide->get_definition();
+
+        $this->assertEquals('testguide', $definition->name);
+        $this->assertEquals('Description text', $definition->description);
+        $this->assertEquals(gradingform_controller::DEFINITION_STATUS_READY, $definition->status);
+
+        // Should create a guide with 2 criterion.
+        $this->assertCount(2, $definition->guide_criteria);
+    }
+
+    /**
+     * Test the get_submitted_form_data function.
+     */
+    public function test_get_submitted_form_data(): void {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        $this->setUser($user);
+        $controller = $guidegenerator->get_test_guide($context, 'assign', 'submissions');
+
+        $result = $guidegenerator->get_submitted_form_data($controller, 93, [
+            'Spelling mistakes' => [
+                'score' => 10,
+                'remark' => 'Pretty good but you had a couple of errors',
+            ],
+            'Pictures' => [
+                'score' => 15,
+                'remark' => 'Lots of nice pictures!',
+            ]
+        ]);
+
+        $this->assertIsArray($result);
+        $this->assertEquals(93, $result['itemid']);
+        $this->assertIsArray($result['criteria']);
+        $this->assertCount(2, $result['criteria']);
+
+        $spelling = $guidegenerator->get_criterion_for_values($controller, 'Spelling mistakes');
+        $this->assertIsArray($result['criteria'][$spelling->id]);
+
+        $this->assertEquals(10, $result['criteria'][$spelling->id]['score']);
+        $this->assertEquals('Pretty good but you had a couple of errors', $result['criteria'][$spelling->id]['remark']);
+
+        $pictures = $guidegenerator->get_criterion_for_values($controller, 'Pictures', 2);
+        $this->assertIsArray($result['criteria'][$pictures->id]);
+        $this->assertEquals(15, $result['criteria'][$pictures->id]['score']);
+        $this->assertEquals('Lots of nice pictures!', $result['criteria'][$pictures->id]['remark']);
+    }
+
+    /**
+     * Test the get_test_form_data function.
+     */
+    public function test_get_test_form_data(): void {
+        global $DB;
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        $this->setUser($user);
+        $controller = $guidegenerator->get_test_guide($context, 'assign', 'submissions');
+
+        // Unit under test.
+        $result = $guidegenerator->get_test_form_data(
+            $controller,
+            1839,
+            10, 'Propper good speling',
+            0, 'ASCII art is not a picture'
+        );
+
+        $this->assertIsArray($result);
+        $this->assertEquals(1839, $result['itemid']);
+        $this->assertIsArray($result['criteria']);
+        $this->assertCount(2, $result['criteria']);
+
+        $spelling = $guidegenerator->get_criterion_for_values($controller, 'Spelling mistakes');
+        $this->assertIsArray($result['criteria'][$spelling->id]);
+        $this->assertEquals(10, $result['criteria'][$spelling->id]['score']);
+        $this->assertEquals('Propper good speling', $result['criteria'][$spelling->id]['remark']);
+
+        $pictures = $guidegenerator->get_criterion_for_values($controller, 'Pictures');
+        $this->assertIsArray($result['criteria'][$pictures->id]);
+        $this->assertEquals(0, $result['criteria'][$pictures->id]['score']);
+        $this->assertEquals('ASCII art is not a picture', $result['criteria'][$pictures->id]['remark']);
+    }
+}
index a2122eb..74f7c0c 100644 (file)
@@ -26,7 +26,6 @@
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
-require_once($CFG->dirroot . '/grade/grading/tests/fixtures/marking_guide.php');
 
 use \core_privacy\tests\provider_testcase;
 use \core_privacy\local\request\writer;
@@ -35,6 +34,7 @@ use \gradingform_guide\privacy\provider;
 /**
  * Privacy tests for gradingform_guide.
  *
+ * @package    gradingform_guide
  * @copyright  2018 Sara Arjona <sara@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -79,7 +79,7 @@ class gradingform_guide_privacy_testcase extends provider_testcase {
     }
 
     /**
-     * Test the export of rubric data.
+     * Test the export of guide data.
      */
     public function test_get_gradingform_export_data() {
         global $DB;
@@ -91,28 +91,19 @@ class gradingform_guide_privacy_testcase extends provider_testcase {
         $this->setUser($user);
 
         $modulecontext = context_module::instance($module->cmid);
-        $guide = new test_guide($modulecontext, 'testrubrib', 'Description text');
-        $guide->add_criteria(
-            'Spelling mistakes',
-            'Full marks will be given for no spelling mistakes.',
-            'Deduct 5 points per spelling mistake made.',
-            25
-        );
-        $guide->add_criteria(
-            'Pictures',
-            'Full marks will be given for including 3 pictures.',
-            'Give 5 points for each picture present',
-            15
-        );
-        $guide->create_guide();
+        $controller = $this->get_test_guide($modulecontext);
 
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
-        $gradedata = [
-            ['remark' => 'This user made several mistakes.', 'score' => 5],
-            ['remark' => 'This user has two pictures.', 'score' => 10]
-        ];
-        $instance = $guide->grade_item($user->id, $itemid, $gradedata);
+        $instance = $controller->create_instance($user->id, $itemid);
+        $data = $this->get_test_form_data(
+            $controller,
+            $itemid,
+            5, 'This user made several mistakes.',
+            10, 'This user has two pictures.'
+        );
+
+        $instance->update($data);
         $instanceid = $instance->get_data('id');
 
         // Let's try the method we are testing.
@@ -126,7 +117,7 @@ class gradingform_guide_privacy_testcase extends provider_testcase {
     }
 
     /**
-     * Test the deletion of rubric user information via the instance ID.
+     * Test the deletion of guide user information via the instance ID.
      */
     public function test_delete_gradingform_for_instances() {
         global $DB;
@@ -138,64 +129,32 @@ class gradingform_guide_privacy_testcase extends provider_testcase {
         $this->setUser($user);
 
         $modulecontext = context_module::instance($module->cmid);
-        $guide = new test_guide($modulecontext, 'testrubrib', 'Description text');
-        $guide->add_criteria(
-            'Spelling mistakes',
-            'Full marks will be given for no spelling mistakes.',
-            'Deduct 5 points per spelling mistake made.',
-            25
-        );
-        $guide->add_criteria(
-            'Pictures',
-            'Full marks will be given for including 3 pictures.',
-            'Give 5 points for each picture present',
-            15
-        );
-        $guide->create_guide();
+        $controller = $this->get_test_guide($modulecontext);
 
-        $controller = $guide->manager->get_controller('guide');
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
         $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_guide_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->shortname == 'Spelling mistakes') {
-                $data['criteria'][$key]['remark'] = 'This user made several mistakes.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 5;
-            } else {
-                $data['criteria'][$key]['remark'] = 'This user has two pictures.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 10;
-            }
-        }
-        $data['itemid'] = $itemid;
+        $data = $this->get_test_form_data(
+            $controller,
+            $itemid,
+            5, 'This user made several mistakes.',
+            10, 'This user has two pictures.'
+        );
 
-        // Update this instance with data.
         $instance->update($data);
+        $instanceid = $instance->get_data('id');
 
         $itemid = 2;
         $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_guide_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->shortname == 'Spelling mistakes') {
-                $data['criteria'][$key]['remark'] = 'This user made no mistakes.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 25;
-            } else {
-                $data['criteria'][$key]['remark'] = 'This user has one pictures.';
-                $data['criteria'][$key]['remarkformat'] = 0;
-                $data['criteria'][$key]['score'] = 5;
-            }
-        }
-        $data['itemid'] = $itemid;
+        $data = $this->get_test_form_data(
+            $controller,
+            $itemid,
+            25, 'This user made no mistakes.',
+            5, 'This user has one pictures.'
+        );
 
-        // Update this instance with data.
         $instance->update($data);
+        $instanceid = $instance->get_data('id');
 
         // Check how many records we have in the fillings table.
         $records = $DB->get_records('gradingform_guide_fillings');
@@ -208,4 +167,49 @@ class gradingform_guide_privacy_testcase extends provider_testcase {
             $this->assertNotEquals($instance->get_id(), $record->instanceid);
         }
     }
+
+    /**
+     * Generate a guide controller with sample data required for testing of this class.
+     *
+     * @param context_module $context
+     * @return gradingform_guide_controller
+     */
+    protected function get_test_guide(context_module $context): gradingform_guide_controller {
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        return $guidegenerator->get_test_guide($context);
+    }
+
+    /**
+     * Fetch a set of sample data.
+     *
+     * @param gradingform_guide_controller $controller
+     * @param int $itemid
+     * @param float $spellingscore
+     * @param string $spellingremark
+     * @param float $picturescore
+     * @param string $pictureremark
+     * @return array
+     */
+    protected function get_test_form_data(
+        gradingform_guide_controller $controller,
+        int $itemid,
+        float $spellingscore,
+        string $spellingremark,
+        float $picturescore,
+        string $pictureremark
+    ): array {
+        $generator = \testing_util::get_data_generator();
+        $guidegenerator = $generator->get_plugin_generator('gradingform_guide');
+
+        return $guidegenerator->get_test_form_data(
+            $controller,
+            $itemid,
+            $spellingscore,
+            $spellingremark,
+            $picturescore,
+            $pictureremark
+        );
+    }
 }
diff --git a/grade/grading/form/rubric/tests/generator/criterion.php b/grade/grading/form/rubric/tests/generator/criterion.php
new file mode 100644 (file)
index 0000000..75f5cf9
--- /dev/null
@@ -0,0 +1,121 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator for the gradingforum_rubric plugin.
+ *
+ * @package    gradingform_rubric
+ * @category   test
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\gradingform_rubric\generator;
+
+/**
+ * Convenience class to create rubric criterion.
+ *
+ * @package    gradingform_rubric
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class criterion {
+    /** @var string $description A description of the criterion. */
+    public $description;
+
+    /** @var integer $sortorder sort order of the criterion. */
+    public $sortorder = 0;
+
+    /** @var array $levels The levels for this criterion. */
+    public $levels = [];
+
+    /**
+     * Constructor for this test_criterion object
+     *
+     * @param string $description A description of this criterion.
+     * @param array $levels
+     */
+    public function __construct(string $description, array $levels = []) {
+        $this->description = $description;
+        foreach ($levels as $definition => $score) {
+            $this->add_level($definition, $score);
+        }
+    }
+
+    /**
+     * Adds levels to the criterion.
+     *
+     * @param string $definition The definition for this level.
+     * @param int $score The score received if this level is selected.
+     * @return self
+     */
+    public function add_level(string $definition, int $score): self {
+        $this->levels[] = [
+            'definition' => $definition,
+            'score' => $score
+        ];
+
+        return $this;
+    }
+
+    /**
+     * Get the description for this criterion.
+     *
+     * @return string
+     */
+    public function get_description(): string {
+        return $this->description;
+    }
+
+    /**
+     * Get the levels for this criterion.
+     *
+     * @return array
+     */
+    public function get_levels(): array {
+        return $this->levels;
+    }
+
+    /**
+     * Get all values in an array for use when creating a new guide.
+     *
+     * @param int $sortorder
+     * @return array
+     */
+    public function get_all_values(int $sortorder): array {
+        return [
+            'sortorder' => $sortorder,
+            'description' => $this->get_description(),
+            'levels' => $this->get_all_level_values(),
+        ];
+    }
+
+    /**
+     * Get all level values.
+     *
+     * @return array
+     */
+    public function get_all_level_values(): array {
+        $result = [];
+
+        foreach ($this->get_levels() as $index => $level) {
+            $id = $index + 1;
+            $result["NEWID{$id}"] = $level;
+        }
+
+        return $result;
+    }
+}
diff --git a/grade/grading/form/rubric/tests/generator/lib.php b/grade/grading/form/rubric/tests/generator/lib.php
new file mode 100644 (file)
index 0000000..bed21f8
--- /dev/null
@@ -0,0 +1,240 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator for the gradingforum_rubric plugin.
+ *
+ * @package    gradingform_rubric
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/rubric.php');
+require_once(__DIR__ . '/criterion.php');
+
+use tests\gradingform_rubric\generator\rubric;
+use tests\gradingform_rubric\generator\criterion;
+
+/**
+ * Generator for the gradingforum_rubric plugintype.
+ *
+ * @package    gradingform_rubric
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class gradingform_rubric_generator extends component_generator_base {
+
+    /**
+     * Create an instance of a rubric.
+     *
+     * @param context $context
+     * @param string $component
+     * @param string $area
+     * @param string $name
+     * @param string $description
+     * @param array $criteria The list of criteria to add to the generated rubric
+     * @return gradingform_rubric_controller
+     */
+    public function create_instance(
+        context $context,
+        string $component,
+        string $area,
+        string $name,
+        string $description,
+        array $criteria
+    ): gradingform_rubric_controller {
+        global $USER;
+
+        if ($USER->id === 0) {
+            throw new \coding_exception('Creation of a rubric must currently be run as a user.');
+        }
+
+        // Fetch the controller for this context/component/area.
+        $generator = \testing_util::get_data_generator();
+        $gradinggenerator = $generator->get_plugin_generator('core_grading');
+        $controller = $gradinggenerator->create_instance($context, $component, $area, 'rubric');
+
+        // Generate a definition for the supplied rubric.
+        $rubric = $this->get_rubric($name, $description);
+        foreach ($criteria as $name => $criterion) {
+            $rubric->add_criteria($this->get_criterion($name, $criterion));
+        }
+
+        // Update the controller wih the rubric definition.
+        $controller->update_definition($rubric->get_definition());
+
+        return $controller;
+    }
+
+    /**
+     * Get a new rubric for use with the rubric controller.
+     *
+     * Note: This is just a helper class used to build a new definition. It does not persist the data.
+     *
+     * @param string $name
+     * @param string $description
+     * @return rubric
+     */
+    protected function get_rubric(string $name, string $description): rubric {
+        return new rubric($name, $description);
+    }
+
+    /**
+     * Get a new rubric for use with a gradingform_rubric_generator_rubric.
+     *
+     * Note: This is just a helper class used to build a new definition. It does not persist the data.
+     *
+     * @param string $description
+     * @param array $levels Set of levels in the form definition => score
+     * @return gradingform_rubric_generator_criterion
+     */
+    protected function get_criterion(string $description, array $levels = []): criterion {
+        return new criterion($description, $levels);
+    }
+
+    /**
+     * Given a controller instance, fetch the level and criterion information for the specified values.
+     *
+     * @param gradingform_controller $controller
+     * @param string $description The description to match the criterion on
+     * @param float $score The value to match the level on
+     * @return array
+     */
+    public function get_level_and_criterion_for_values(
+        gradingform_controller $controller,
+        string $description,
+        float $score
+    ): array {
+        $definition = $controller->get_definition();
+        $criteria = $definition->rubric_criteria;
+
+        $criterion = $level = null;
+
+        $criterion = array_reduce($criteria, function($carry, $criterion) use ($description) {
+            if ($criterion['description'] === $description) {
+                $carry = $criterion;
+            }
+
+            return $carry;
+        }, null);
+
+        if ($criterion) {
+            $criterion = (object) $criterion;
+            $level = array_reduce($criterion->levels, function($carry, $level) use ($score) {
+                if ($level['score'] == $score) {
+                    $carry = $level;
+                }
+                return $carry;
+            });
+            $level = $level ? (object) $level : null;
+        }
+
+        return [
+            'criterion' => $criterion,
+            'level' => $level,
+        ];
+    }
+
+    /**
+     * Get submitted form data for the supplied controller, itemid, and values.
+     * The returned data is in the format used by rubric when handling form submission.
+     *
+     * @param gradingform_rubric_controller $controller
+     * @param int $itemid
+     * @param array $values A set of array values where the array key is the name of the criterion, and the value is an
+     * array with the desired score, and any remark.
+     */
+    public function get_submitted_form_data(gradingform_rubric_controller $controller, int $itemid, array $values): array {
+        $result = [
+            'itemid' => $itemid,
+            'criteria' => [],
+        ];
+        foreach ($values as $criterionname => ['score' => $score, 'remark' => $remark]) {
+            [
+                'criterion' => $criterion,
+                'level' => $level,
+            ] = $this->get_level_and_criterion_for_values($controller, $criterionname, $score);
+            $result['criteria'][$criterion->id] = [
+                'levelid' => $level->id,
+                'remark' => $remark,
+            ];
+        }
+
+        return $result;
+    }
+
+    /**
+     * Generate a rubric controller with sample data required for testing of this class.
+     *
+     * @param context $context
+     * @param string $component
+     * @param string $area
+     * @return gradingform_rubric_controller
+     */
+    public function get_test_rubric(context $context, string $component, string $area): gradingform_rubric_controller {
+        $criteria = [
+            'Spelling is important' => [
+                'Nothing but mistakes' => 0,
+                'Several mistakes' => 1,
+                'No mistakes' => 2,
+            ],
+            'Pictures' => [
+                'No pictures' => 0,
+                'One picture' => 1,
+                'More than one picture' => 2,
+            ],
+        ];
+
+        return $this->create_instance($context, $component, $area, 'testrubric', 'Description text', $criteria);
+    }
+
+    /**
+     * Fetch a set of sample data.
+     *
+     * @param gradingform_rubric_controller $controller
+     * @param int $itemid
+     * @param float $spellingscore
+     * @param string $spellingremark
+     * @param float $picturescore
+     * @param string $pictureremark
+     * @return array
+     */
+    public function get_test_form_data(
+        gradingform_rubric_controller $controller,
+        int $itemid,
+        float $spellingscore,
+        string $spellingremark,
+        float $picturescore,
+        string $pictureremark
+    ): array {
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+        return $rubricgenerator->get_submitted_form_data($controller, $itemid, [
+            'Spelling is important' => [
+                'score' => $spellingscore,
+                'remark' => $spellingremark,
+            ],
+            'Pictures' => [
+                'score' => $picturescore,
+                'remark' => $pictureremark,
+            ],
+        ]);
+    }
+}
diff --git a/grade/grading/form/rubric/tests/generator/rubric.php b/grade/grading/form/rubric/tests/generator/rubric.php
new file mode 100644 (file)
index 0000000..c388c51
--- /dev/null
@@ -0,0 +1,133 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator for the gradingforum_rubric plugin.
+ *
+ * @package    gradingform_rubric
+ * @category   test
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\gradingform_rubric\generator;
+
+use gradingform_controller;
+use stdClass;
+
+/**
+ * Test rubric.
+ *
+ * @package    gradingform_rubric
+ * @category   test
+ * @copyright  2018 Adrian Greeve <adriangreeve.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class rubric {
+
+    /** @var array $criteria The criteria for this rubric. */
+    protected $criteria = [];
+
+    /** @var string The name of this rubric. */
+    protected $name;
+
+    /** @var string A description for this rubric. */
+    protected $description;
+
+    /** @var array The rubric options. */
+    protected $options = [];
+
+    /**
+     * Create a new gradingform_rubric_generator_rubric.
+     *
+     * @param string $name
+     * @param string $description
+     */
+    public function __construct(string $name, string $description) {
+        $this->name = $name;
+        $this->description = $description;
+
+        $this->set_option('sortlevelsasc', 1);
+        $this->set_option('lockzeropoints', 1);
+        $this->set_option('showdescriptionteacher', 1);
+        $this->set_option('showdescriptionstudent', 1);
+        $this->set_option('showscoreteacher', 1);
+        $this->set_option('showscorestudent', 1);
+        $this->set_option('enableremarks', 1);
+        $this->set_option('showremarksstudent', 1);
+    }
+
+    /**
+     * Creates the rubric using the appropriate APIs.
+     */
+    public function get_definition(): stdClass {
+        return (object) [
+            'name' => $this->name,
+            'description_editor' => [
+                'text' => $this->description,
+                'format' => FORMAT_HTML,
+                'itemid' => 1
+            ],
+            'rubric' => [
+                'criteria' => $this->get_all_criterion_values(),
+                'options' => $this->options,
+            ],
+            'saverubric' => 'Save rubric and make it ready',
+            'status' => gradingform_controller::DEFINITION_STATUS_READY,
+        ];
+    }
+
+    /**
+     * Set an option for the rubric.
+     *
+     * @param string $key
+     * @param mixed $value
+     * @return self
+     */
+    public function set_option(string $key, $value): self {
+        $this->options[$key] = $value;
+        return $this;
+    }
+
+    /**
+     * Adds a criterion to the rubric.
+     *
+     * @param criterion $criterion The criterion object (class below).
+     * @return self
+     */
+    public function add_criteria(criterion $criterion): self {
+        $this->criteria[] = $criterion;
+
+        return $this;
+    }
+
+    /**
+     * Get all criterion values.
+     *
+     * @return array
+     */
+    protected function get_all_criterion_values(): array {
+        $result = [];
+
+        foreach ($this->criteria as $index => $criterion) {
+            $id = $index + 1;
+            $result["NEWID{$id}"] = $criterion->get_all_values($id);
+        }
+
+        return $result;
+
+    }
+}
diff --git a/grade/grading/form/rubric/tests/generator_test.php b/grade/grading/form/rubric/tests/generator_test.php
new file mode 100644 (file)
index 0000000..798633f
--- /dev/null
@@ -0,0 +1,319 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator testcase for the gradingforum_rubric generator.
+ *
+ * @package    gradingform_rubric
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\gradingform_rubric;
+
+use advanced_testcase;
+use context_module;
+use gradingform_rubric_controller;
+use gradingform_controller;
+
+/**
+ * Generator testcase for the gradingforum_rubric generator.
+ *
+ * @package    gradingform_rubric
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class generator_testcase extends advanced_testcase {
+
+    /**
+     * Test rubric creation.
+     */
+    public function test_rubric_creation(): void {
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        // Data for testing.
+        $name = 'myfirstrubric';
+        $description = 'My first rubric';
+        $criteria = [
+            'Alphabet' => [
+                'Not known' => 0,
+                'Letters known but out of order' => 1,
+                'Letters known in order ascending' => 2,
+                'Letters known and can recite forwards and backwards' => 4,
+            ],
+            'Times tables' => [
+                'Not known' => 0,
+                '2 times table known' => 2,
+                '2 and 5 times table known' => 4,
+                '2, 5, and 10 times table known' => 8,
+            ],
+        ];
+
+        // Unit under test.
+        $this->setUser($user);
+        $controller = $rubricgenerator->create_instance($context, 'mod_assign', 'submission', $name, $description, $criteria);
+
+        $this->assertInstanceOf(gradingform_rubric_controller::class, $controller);
+
+        $definition = $controller->get_definition();
+        $this->assertNotEmpty($definition->id);
+        $this->assertEquals($name, $definition->name);
+        $this->assertEquals($description, $definition->description);
+        $this->assertEquals(gradingform_controller::DEFINITION_STATUS_READY, $definition->status);
+        $this->assertNotEmpty($definition->timecreated);
+        $this->assertNotEmpty($definition->timemodified);
+        $this->assertEquals($user->id, $definition->usercreated);
+
+        $this->assertNotEmpty($definition->rubric_criteria);
+        $this->assertCount(2, $definition->rubric_criteria);
+
+        // Check the alphabet criteria.
+        $criteriaids = array_keys($definition->rubric_criteria);
+
+        $alphabet = $definition->rubric_criteria[$criteriaids[0]];
+        $this->assertNotEmpty($alphabet['id']);
+        $this->assertEquals(1, $alphabet['sortorder']);
+        $this->assertEquals('Alphabet', $alphabet['description']);
+
+        $this->assertNotEmpty($alphabet['levels']);
+        $levels = $alphabet['levels'];
+        $levelids = array_keys($levels);
+
+        $level = $levels[$levelids[0]];
+        $this->assertEquals(0, $level['score']);
+        $this->assertEquals('Not known', $level['definition']);
+
+        $level = $levels[$levelids[1]];
+        $this->assertEquals(1, $level['score']);
+        $this->assertEquals('Letters known but out of order', $level['definition']);
+
+        $level = $levels[$levelids[2]];
+        $this->assertEquals(2, $level['score']);
+        $this->assertEquals('Letters known in order ascending', $level['definition']);
+
+        $level = $levels[$levelids[3]];
+        $this->assertEquals(4, $level['score']);
+        $this->assertEquals('Letters known and can recite forwards and backwards', $level['definition']);
+
+        // Check the times tables criteria.
+        $tables = $definition->rubric_criteria[$criteriaids[1]];
+        $this->assertNotEmpty($tables['id']);
+        $this->assertEquals(2, $tables['sortorder']);
+        $this->assertEquals('Times tables', $tables['description']);
+
+        $this->assertNotEmpty($tables['levels']);
+        $levels = $tables['levels'];
+        $levelids = array_keys($levels);
+
+        $level = $levels[$levelids[0]];
+        $this->assertEquals(0, $level['score']);
+        $this->assertEquals('Not known', $level['definition']);
+
+        $level = $levels[$levelids[1]];
+        $this->assertEquals(2, $level['score']);
+        $this->assertEquals('2 times table known', $level['definition']);
+
+        $level = $levels[$levelids[2]];
+        $this->assertEquals(4, $level['score']);
+        $this->assertEquals('2 and 5 times table known', $level['definition']);
+
+        $level = $levels[$levelids[3]];
+        $this->assertEquals(8, $level['score']);
+        $this->assertEquals('2, 5, and 10 times table known', $level['definition']);
+    }
+
+    /**
+     * Test the get_level_and_criterion_for_values function.
+     * This is used for finding criterion and level information within a rubric.
+     */
+    public function test_get_level_and_criterion_for_values(): void {
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        // Data for testing.
+        $description = 'My first rubric';
+        $criteria = [
+            'Alphabet' => [
+                'Not known' => 0,
+                'Letters known but out of order' => 1,
+                'Letters known in order ascending' => 2,
+                'Letters known and can recite forwards and backwards' => 4,
+            ],
+            'Times tables' => [
+                'Not known' => 0,
+                '2 times table known' => 2,
+                '2 and 5 times table known' => 4,
+                '2, 5, and 10 times table known' => 8,
+            ],
+        ];
+
+        $this->setUser($user);
+        $controller = $rubricgenerator->create_instance($context, 'mod_assign', 'submission', 'rubric', $description, $criteria);
+
+        // Valid criterion and level.
+        $result = $rubricgenerator->get_level_and_criterion_for_values($controller, 'Alphabet', 2);
+        $this->assertEquals('Alphabet', $result['criterion']->description);
+        $this->assertEquals('2', $result['level']->score);
+        $this->assertEquals('Letters known in order ascending', $result['level']->definition);
+
+        // Valid criterion. Invalid level.
+        $result = $rubricgenerator->get_level_and_criterion_for_values($controller, 'Alphabet', 3);
+        $this->assertEquals('Alphabet', $result['criterion']->description);
+        $this->assertNull($result['level']);
+
+        // Invalid criterion.
+        $result = $rubricgenerator->get_level_and_criterion_for_values($controller, 'Foo', 0);
+        $this->assertNull($result['criterion']);
+    }
+
+    /**
+     * Tests for the get_test_rubric function.
+     */
+    public function test_get_test_rubric(): void {
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        $this->setUser($user);
+        $rubric = $rubricgenerator->get_test_rubric($context, 'assign', 'submissions');
+        $definition = $rubric->get_definition();
+
+        $this->assertEquals('testrubric', $definition->name);
+        $this->assertEquals('Description text', $definition->description);
+        $this->assertEquals(gradingform_controller::DEFINITION_STATUS_READY, $definition->status);
+
+        // Should create a rubric with 2 criterion.
+        $this->assertCount(2, $definition->rubric_criteria);
+    }
+
+    /**
+     * Test the get_submitted_form_data function.
+     */
+    public function test_get_submitted_form_data(): void {
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        $this->setUser($user);
+        $controller = $rubricgenerator->get_test_rubric($context, 'assign', 'submissions');
+
+        $result = $rubricgenerator->get_submitted_form_data($controller, 93, [
+            'Spelling is important' => [
+                'score' => 1,
+                'remark' => 'Good speeling',
+            ],
+            'Pictures' => [
+                'score' => 2,
+                'remark' => 'Lots of nice pictures!',
+            ]
+        ]);
+
+        $this->assertIsArray($result);
+        $this->assertEquals(93, $result['itemid']);
+        $this->assertIsArray($result['criteria']);
+        $this->assertCount(2, $result['criteria']);
+
+        $spelling = $rubricgenerator->get_level_and_criterion_for_values($controller, 'Spelling is important', 1);
+        $this->assertIsArray($result['criteria'][$spelling['criterion']->id]);
+        $this->assertEquals($spelling['level']->id, $result['criteria'][$spelling['criterion']->id]['levelid']);
+        $this->assertEquals('Good speeling', $result['criteria'][$spelling['criterion']->id]['remark']);
+
+        $pictures = $rubricgenerator->get_level_and_criterion_for_values($controller, 'Pictures', 2);
+        $this->assertIsArray($result['criteria'][$pictures['criterion']->id]);
+        $this->assertEquals($pictures['level']->id, $result['criteria'][$pictures['criterion']->id]['levelid']);
+        $this->assertEquals('Lots of nice pictures!', $result['criteria'][$pictures['criterion']->id]['remark']);
+    }
+
+    /**
+     * Test the get_test_form_data function.
+     */
+    public function test_get_test_form_data(): void {
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        $this->setUser($user);
+        $controller = $rubricgenerator->get_test_rubric($context, 'assign', 'submissions');
+
+        // Unit under test.
+        $result = $rubricgenerator->get_test_form_data(
+            $controller,
+            1839,
+            1, 'Propper good speling',
+            0, 'ASCII art is not a picture'
+        );
+
+        $this->assertIsArray($result);
+        $this->assertEquals(1839, $result['itemid']);
+        $this->assertIsArray($result['criteria']);
+        $this->assertCount(2, $result['criteria']);
+
+        $spelling = $rubricgenerator->get_level_and_criterion_for_values($controller, 'Spelling is important', 1);
+        $this->assertIsArray($result['criteria'][$spelling['criterion']->id]);
+        $this->assertEquals($spelling['level']->id, $result['criteria'][$spelling['criterion']->id]['levelid']);
+        $this->assertEquals('Propper good speling', $result['criteria'][$spelling['criterion']->id]['remark']);
+
+        $pictures = $rubricgenerator->get_level_and_criterion_for_values($controller, 'Pictures', 0);
+        $this->assertIsArray($result['criteria'][$pictures['criterion']->id]);
+        $this->assertEquals($pictures['level']->id, $result['criteria'][$pictures['criterion']->id]['levelid']);
+        $this->assertEquals('ASCII art is not a picture', $result['criteria'][$pictures['criterion']->id]['remark']);
+    }
+}
index 9f9ab83..4167dfe 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-defined('MOODLE_INTERNAL') || die();
+namespace tests\gradingform_rubric;
 
-use \core_privacy\tests\provider_testcase;
-use \core_privacy\local\request\writer;
-use \gradingform_rubric\privacy\provider;
+use core_privacy\tests\provider_testcase;
+use core_privacy\local\request\writer;
+use gradingform_rubric\privacy\provider;
+use gradingform_rubric_controller;
+use context_module;
 
 /**
  * Privacy tests for gradingform_rubric
@@ -35,7 +37,7 @@ use \gradingform_rubric\privacy\provider;
  * @copyright  2018 Adrian Greeve <adriangreeve.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class gradingform_rubric_privacy_testcase extends provider_testcase {
+class privacy_testcase extends provider_testcase {
 
     /**
      * Test the export of rubric data.
@@ -45,43 +47,23 @@ class gradingform_rubric_privacy_testcase extends provider_testcase {
         $this->resetAfterTest();
         $course = $this->getDataGenerator()->create_course();
         $module = $this->getDataGenerator()->create_module('assign', ['course' => $course]);
+        $modulecontext = context_module::instance($module->cmid);
         $user = $this->getDataGenerator()->create_user();
-
         $this->setUser($user);
 
-        $modulecontext = context_module::instance($module->cmid);
-        $rubric = new test_rubric($modulecontext, 'testrubrib', 'Description text');
-        $criterion = new test_criterion('Spelling is important');
-        $criterion->add_level('Nothing but mistakes', 0);
-        $criterion->add_level('Several mistakes', 1);
-        $criterion->add_level('No mistakes', 2);
-        $rubric->add_criteria($criterion);
-        $criterion = new test_criterion('Pictures');
-        $criterion->add_level('No pictures', 0);
-        $criterion->add_level('One picture', 1);
-        $criterion->add_level('More than one picture', 2);
-        $rubric->add_criteria($criterion);
-        $rubric->create_rubric();
+        // Generate a test rubric and get its controller.
+        $controller = $this->get_test_rubric($modulecontext, 'assign', 'submissions');
 
-        $controller = $rubric->manager->get_controller('rubric');
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
         $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_rubric_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->description == 'Spelling is important') {
-                $level = $DB->get_record('gradingform_rubric_levels', ['criterionid' => $key, 'score' => 1]);
-                $data['criteria'][$key]['levelid'] = $level->id;
-                $data['criteria'][$key]['remark'] = 'This user made several mistakes.';
-            } else {
-                $level = $DB->get_record('gradingform_rubric_levels', ['criterionid' => $key, 'score' => 0]);
-                $data['criteria'][$key]['levelid'] = $level->id;
-                $data['criteria'][$key]['remark'] = 'Please add more pictures.';
-            }
-        }
-        $data['itemid'] = $itemid;
+
+        $data = $this->get_test_form_data(
+            $controller,
+            $itemid,
+            1, 'This user made several mistakes.',
+            0, 'Please add more pictures.'
+        );
 
         // Update this instance with data.
         $instance->update($data);
@@ -105,43 +87,23 @@ class gradingform_rubric_privacy_testcase extends provider_testcase {
         $this->resetAfterTest();
         $course = $this->getDataGenerator()->create_course();
         $module = $this->getDataGenerator()->create_module('assign', ['course' => $course]);
+        $modulecontext = context_module::instance($module->cmid);
         $user = $this->getDataGenerator()->create_user();
-
         $this->setUser($user);
 
-        $modulecontext = context_module::instance($module->cmid);
-        $rubric = new test_rubric($modulecontext, 'testrubrib', 'Description text');
-        $criterion = new test_criterion('Spelling is important');
-        $criterion->add_level('Nothing but mistakes', 0);
-        $criterion->add_level('Several mistakes', 1);
-        $criterion->add_level('No mistakes', 2);
-        $rubric->add_criteria($criterion);
-        $criterion = new test_criterion('Pictures');
-        $criterion->add_level('No pictures', 0);
-        $criterion->add_level('One picture', 1);
-        $criterion->add_level('More than one picture', 2);
-        $rubric->add_criteria($criterion);
-        $rubric->create_rubric();
+        // Generate a test rubric and get its controller.
+        $controller = $this->get_test_rubric($modulecontext, 'assign', 'submissions');
 
-        $controller = $rubric->manager->get_controller('rubric');
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
         $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_rubric_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->description == 'Spelling is important') {
-                $level = $DB->get_record('gradingform_rubric_levels', ['criterionid' => $key, 'score' => 1]);
-                $data['criteria'][$key]['levelid'] = $level->id;
-                $data['criteria'][$key]['remark'] = 'This user made several mistakes.';
-            } else {
-                $level = $DB->get_record('gradingform_rubric_levels', ['criterionid' => $key, 'score' => 0]);
-                $data['criteria'][$key]['levelid'] = $level->id;
-                $data['criteria'][$key]['remark'] = 'Please add more pictures.';
-            }
-        }
-        $data['itemid'] = $itemid;
+
+        $data = $this->get_test_form_data(
+            $controller,
+            $itemid,
+            1, 'This user made several mistakes.',
+            0, 'Please add more pictures.'
+        );
 
         // Update this instance with data.
         $instance->update($data);
@@ -149,21 +111,13 @@ class gradingform_rubric_privacy_testcase extends provider_testcase {
         // Second instance.
         $itemid = 2;
         $instance = $controller->create_instance($user->id, $itemid);
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_rubric_criteria');
-        $data = ['criteria' => []];
-        foreach ($criteria as $key => $value) {
-            if ($value->description == 'Spelling is important') {
-                $level = $DB->get_record('gradingform_rubric_levels', ['criterionid' => $key, 'score' => 0]);
-                $data['criteria'][$key]['levelid'] = $level->id;
-                $data['criteria'][$key]['remark'] = 'Too many mistakes. Please try again.';
-            } else {
-                $level = $DB->get_record('gradingform_rubric_levels', ['criterionid' => $key, 'score' => 2]);
-                $data['criteria'][$key]['levelid'] = $level->id;
-                $data['criteria'][$key]['remark'] = 'Great number of pictures. Well done.';
-            }
-        }
-        $data['itemid'] = $itemid;
+
+        $data = $this->get_test_form_data(
+            $controller,
+            $itemid,
+            0, 'Too many mistakes. Please try again.',
+            2, 'Great number of pictures. Well done.'
+        );
 
         // Update this instance with data.
         $instance->update($data);
@@ -179,135 +133,51 @@ class gradingform_rubric_privacy_testcase extends provider_testcase {
             $this->assertNotEquals($instance->get_id(), $record->instanceid);
         }
     }
-}
-
-/**
- * Convenience class to create rubrics.
- *
- * @copyright  2018 Adrian Greeve <adriangreeve.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class test_rubric {
-
-    /** @var array $criteria The criteria for this rubric. */
-    protected $criteria = [];
-    /** @var context The context that this rubric is in. */
-    protected $context;
-    /** @var string The name of this rubric. */
-    protected $name;
-    /** @var string A description for this rubric. */
-    protected $text;
-    /** @var integer The current criterion ID. This is incremented when a new criterion is added. */
-    protected $criterionid = 0;
-    /** @var grading_manager An object for managing the rubric. */
-    public $manager;
 
     /**
-     * Constuctor for this rubric.
+     * Generate a rubric controller with sample data required for testing of this class.
      *
-     * @param context $context The context that this rubric is being used in.
-     * @param string $name Name of the rubric.
-     * @param string $text Description of the rubric.
+     * @param context_module $context
+     * @param string $component
+     * @param string $area
+     * @return gradingform_rubric_controller
      */
-    public function __construct($context, $name, $text) {
-        $this->context = $context;
-        $this->name = $name;
-        $this->text = $text;
-        $this->manager = get_grading_manager();
-        $this->manager->set_context($context);
-        $this->manager->set_component('mod_assign');
-        $this->manager->set_area('submission');
-    }
+    protected function get_test_rubric(context_module $context, string $component, string $area): gradingform_rubric_controller {
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
 
-    /**
-     * Creates the rubric using the appropriate APIs.
-     */
-    public function create_rubric() {
-
-        $data = (object) [
-            'areaid' => $this->context->id,
-            'returnurl' => '',
-            'name' => $this->name,
-            'description_editor' => [
-                'text' => $this->text,
-                'format' => 1,
-                'itemid' => 1
-            ],
-            'rubric' => [
-                'criteria' => $this->criteria,
-                'options' => [
-                    'sortlevelsasc' => 1,
-                    'lockzeropoints' => 1,
-                    'showdescriptionteacher' => 1,
-                    'showdescriptionstudent' => 1,
-                    'showscoreteacher' => 1,
-                    'showscorestudent' => 1,
-                    'enableremarks' => 1,
-                    'showremarksstudent' => 1
-                ]
-            ],
-            'saverubric' => 'Save rubric and make it ready',
-            'status' => 20
-        ];
-
-        $controller = $this->manager->get_controller('rubric');
-        $controller->update_definition($data);
-    }
-
-    /**
-     * Adds a criterion to the rubric.
-     *
-     * @param test_criterion $criterion The criterion object (class below).
-     */
-    public function add_criteria($criterion) {
-
-        $this->criterionid++;
-        $this->criteria['NEWID' . $this->criterionid] = [
-            'description' => $criterion->description,
-            'sortorder' => $this->criterionid,
-            'levels' => $criterion->levels
-        ];
-    }
-
-}
-
-/**
- * Convenience class to create rubric criterion.
- *
- * @copyright  2018 Adrian Greeve <adriangreeve.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class test_criterion {
-
-    /** @var string $description A description of the criterion. */
-    public $description;
-    /** @var integer $sortorder sort order of the criterion. */
-    public $sortorder = 0;
-    /** @var integer $levelid The current level id  for this level*/
-    public $levelid = 0;
-    /** @var array $levels The levels for this criterion. */
-    public $levels = [];
-
-    /**
-     * Constructor for this test_criterion object
-     *
-     * @param string $description A description of this criterion.
-     */
-    public function __construct($description) {
-        $this->description = $description;
+        return $rubricgenerator->get_test_rubric($context, $component, $area);
     }
 
     /**
-     * Adds levels to the criterion.
+     * Fetch a set of sample data.
      *
-     * @param string $definition The definition for this level.
-     * @param int $score      The score received if this level is selected.
+     * @param gradingform_rubric_controller $controller
+     * @param int $itemid
+     * @param float $spellingscore
+     * @param string $spellingremark
+     * @param float $picturescore
+     * @param string $pictureremark
+     * @return array
      */
-    public function add_level($definition, $score) {
-        $this->levelid++;
-        $this->levels['NEWID' . $this->levelid] = [
-            'definition' => $definition,
-            'score' => $score
-        ];
+    protected function get_test_form_data(
+        gradingform_rubric_controller $controller,
+        int $itemid,
+        float $spellingscore,
+        string $spellingremark,
+        float $picturescore,
+        string $pictureremark
+    ): array {
+        $generator = \testing_util::get_data_generator();
+        $rubricgenerator = $generator->get_plugin_generator('gradingform_rubric');
+
+        return $rubricgenerator->get_test_form_data(
+            $controller,
+            $itemid,
+            $spellingscore,
+            $spellingremark,
+            $picturescore,
+            $pictureremark
+        );
     }
 }
diff --git a/grade/grading/tests/fixtures/marking_guide.php b/grade/grading/tests/fixtures/marking_guide.php
deleted file mode 100644 (file)
index d3b31ab..0000000
+++ /dev/null
@@ -1,158 +0,0 @@
-<?php
-// This file is part of Moodle - http://moodle.org/
-//
-// Moodle is free software: you can redistribute it and/or modify
-// it under the terms of the GNU General Public License as published by
-// the Free Software Foundation, either version 3 of the License, or
-// (at your option) any later version.
-//
-// Moodle is distributed in the hope that it will be useful,
-// but WITHOUT ANY WARRANTY; without even the implied warranty of
-// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
-// GNU General Public License for more details.
-//
-// You should have received a copy of the GNU General Public License
-// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
-
-/**
- * A test guide class fixture.
- *
- * @package    core_grading
- * @category   test
- * @copyright  2018 Adrian Greeve <adriangreeve.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
-defined('MOODLE_INTERNAL') || die();
-
-/**
- * Convenience class to create marking guides.
- *
- * @copyright  2018 Adrian Greeve <adriangreeve.com>
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-class test_guide {
-
-    /** @var array $criteria Criteria for this marking guide. */
-    protected $criteria = [];
-    /** @var context $context The context that this marking guide is used in. */
-    protected $context;
-    /** @var string $name The name of this marking guide. */
-    protected $name;
-    /** @var string $text A description of this marking guide. */
-    protected $text;
-    /** @var integer $criterionid The current id for the criterion. */
-    protected $criterionid = 0;
-    /** @var integer $sortorder The current id for the sort order. */
-    protected $sortorder = 0;
-    /** @var gradingform_controller The grading form controller. */
-    protected $controller;
-
-    /** @var grading_manager $manager The grading manager to handle creating the real marking guide. */
-    public $manager;
-
-    /**
-     * The constuctor for this test_guide object.
-     *
-     * @param context $context The context that this marking guide is used in.
-     * @param string $name The name of the marking guide.
-     * @param string $text The description of the marking guide.
-     */
-    public function __construct($context, $name, $text) {
-        $this->context = $context;
-        $this->name = $name;
-        $this->text = $text;
-        $this->manager = get_grading_manager();
-        $this->manager->set_context($context);
-        $this->manager->set_component('mod_assign');
-        $this->manager->set_area('submission');
-    }
-
-    /**
-     * Uses the appropriate data and APIs to create a marking guide.
-     */
-    public function create_guide() {
-
-        $data = (object) [
-            'areaid' => $this->context->id,
-            'returnurl' => '',
-            'name' => $this->name,
-            'description_editor' => [
-                'text' => $this->text,
-                'format' => 1,
-                'itemid' => 1
-            ],
-            'guide' => [
-                'criteria' => $this->criteria,
-                'options' => [
-                    'alwaysshowdefinition' => 1,
-                    'showmarkspercriterionstudents' => 1
-                ],
-                'comments' => []
-            ],
-            'saveguide' => 'Continue',
-            'status' => 20
-        ];
-
-        $this->controller = $this->manager->get_controller('guide');
-        $this->controller->update_definition($data);
-    }
-
-    /**
-     * Adds criteria to the marking guide.
-     *
-     * @param string $shortname The shortname for the criterion.
-     * @param string $description The description for the criterion.
-     * @param string $descriptionmarkers The description for the marker for this criterion.
-     * @param int $maxscore The maximum score possible for this criterion.
-     */
-    public function add_criteria($shortname, $description, $descriptionmarkers, $maxscore) {
-        $this->criterionid++;
-        $this->sortorder++;
-        $this->criteria['NEWID' . $this->criterionid] = [
-            'sortorder' => $this->sortorder,
-            'shortname' => $shortname,
-            'description' => $description,
-            'descriptionmarkers' => $descriptionmarkers,
-            'maxscore' => $maxscore
-        ];
-    }
-
-    /**
-     * Update the grade for the item provided.
-     * Keep the gradeinfo array in the same order as the definition of the criteria.
-     * The array should be [['remark' => remark, 'score' => intvalue],['remark' => remark, 'score' => intvalue]]
-     * for a guide that has two criteria.
-     *
-     * @param  int $userid The user we are updating.
-     * @param  int $itemid The itemid that the grade will be for
-     * @param  array $gradeinfo Comments and grades for the grade.
-     * @return gradingform_guide_instance The created instance associated with the grade created.
-     */
-    public function grade_item(int $userid, int $itemid, array $gradeinfo) : gradingform_guide_instance {
-        global $DB;
-
-        if (!isset($this->controller)) {
-            throw new Exception("Please call create_guide before calling this method", 1);
-        }
-
-        $instance = $this->controller->create_instance($userid, $itemid);
-
-        // I need the ids for the criteria and there doesn't seem to be a nice method to get it.
-        $criteria = $DB->get_records('gradingform_guide_criteria');
-        $data = ['criteria' => []];
-        $i = 0;
-        // The sort order should keep everything here in order.
-        foreach ($criteria as $key => $value) {
-            $data['criteria'][$key]['remark'] = $gradeinfo[$i]['remark'];
-            $data['criteria'][$key]['remarkformat'] = 0;
-            $data['criteria'][$key]['score'] = $gradeinfo[$i]['score'];
-            $i++;
-        }
-        $data['itemid'] = $itemid;
-
-        // Update this instance with data.
-        $instance->update($data);
-        return $instance;
-    }
-}
diff --git a/grade/grading/tests/generator/lib.php b/grade/grading/tests/generator/lib.php
new file mode 100644 (file)
index 0000000..8df2564
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator for the core_grading subsystem generator.
+ *
+ * @package    core_grading
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * Generator for the core_grading subsystem generator.
+ *
+ * @package    core_grading
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_grading_generator extends component_generator_base {
+
+    /**
+     * Create an instance of an advanced grading area.
+     *
+     * @param context $context
+     * @param string $component
+     * @param string $areaname An area belonging to the specified component
+     * @param string $method An available gradingform type
+     * @return gradingform_controller The controller for the created instance
+     */
+    public function create_instance(context $context, string $component, string $areaname, string $method): gradingform_controller {
+        require_once(__DIR__ . '/../../lib.php');
+
+        $manager = get_grading_manager($context, $component, $areaname);
+        $manager->set_active_method($method);
+
+        return $manager->get_controller($method);
+    }
+}
diff --git a/grade/grading/tests/generator_test.php b/grade/grading/tests/generator_test.php
new file mode 100644 (file)
index 0000000..8754e3e
--- /dev/null
@@ -0,0 +1,72 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Generator testcase for the gradingforum_rubric generator.
+ *
+ * @package    core_grading
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tests\core_grading;
+
+use advanced_testcase;
+use context_module;
+use gradingform_controller;
+use gradingform_rubric_controller;
+
+/**
+ * Generator testcase for the core_grading generator.
+ *
+ * @package    core_grading
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class generator_testcase extends advanced_testcase {
+
+    /**
+     * Test gradingform controller creation.
+     */
+    public function test_create_instance(): void {
+        $this->resetAfterTest(true);
+
+        // Fetch generators.
+        $generator = \testing_util::get_data_generator();
+        $gradinggenerator = $generator->get_plugin_generator('core_grading');
+
+        // Create items required for testing.
+        $course = $generator->create_course();
+        $module = $generator->create_module('assign', ['course' => $course]);
+        $user = $generator->create_user();
+        $context = context_module::instance($module->cmid);
+
+        // The assignment module has an itemumber 0 which is an advanced grading area called 'submissions'.
+        $component = 'mod_assign';
+        $area = 'submissions';
+        $controller = $gradinggenerator->create_instance($context, $component, $area, 'rubric');
+
+        // This should be a rubric.
+        $this->assertInstanceOf(gradingform_controller::class, $controller);
+        $this->assertInstanceOf(gradingform_rubric_controller::class, $controller);
+
+        $this->assertEquals($context, $controller->get_context());
+        $this->assertEquals($component, $controller->get_component());
+        $this->assertEquals($area, $controller->get_area());
+    }
+}
index d2a0feb..0a84f5b 100644 (file)
@@ -26,7 +26,6 @@
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
-require_once($CFG->dirroot . '/grade/grading/tests/fixtures/marking_guide.php');
 
 use \core_privacy\tests\provider_testcase;
 use \core_privacy\local\request\approved_contextlist;
@@ -268,32 +267,25 @@ class core_grading_privacy_testcase extends provider_testcase {
         $course = $this->getDataGenerator()->create_course();
         $module = $this->getDataGenerator()->create_module('assign', ['course' => $course]);
         $user = $this->getDataGenerator()->create_user();
+        $guidegenerator = \testing_util::get_data_generator()->get_plugin_generator('gradingform_guide');
 
         $this->setUser($user);
 
         $modulecontext = context_module::instance($module->cmid);
-        $guide = new test_guide($modulecontext, 'testrubrib', 'Description text');
-        $guide->add_criteria(
-            'Spelling mistakes',
-            'Full marks will be given for no spelling mistakes.',
-            'Deduct 5 points per spelling mistake made.',
-            25
-        );
-        $guide->add_criteria(
-            'Pictures',
-            'Full marks will be given for including 3 pictures.',
-            'Give 5 points for each picture present',
-            15
-        );
-        $guide->create_guide();
+        $controller = $guidegenerator->get_test_guide($modulecontext);
 
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
-        $gradedata = [
-            ['remark' => 'This user made several mistakes.', 'score' => 5],
-            ['remark' => 'This user has two pictures.', 'score' => 10]
-        ];
-        $instance = $guide->grade_item($user->id, $itemid, $gradedata);
+        $instance = $controller->create_instance($user->id, $itemid);
+        $data = $guidegenerator->get_test_form_data(
+            $controller,
+            $itemid,
+            5, 'This user made several mistakes.',
+            10, 'This user has two pictures.'
+        );
+
+        $instance->update($data);
+        $instanceid = $instance->get_data('id');
 
         provider::export_item_data($modulecontext, $itemid, ['Test']);
         $data = (array) writer::with_context($modulecontext)->get_data(['Test', 'Marking guide', $instance->get_data('id')]);
@@ -313,39 +305,33 @@ class core_grading_privacy_testcase extends provider_testcase {
         $course = $this->getDataGenerator()->create_course();
         $module = $this->getDataGenerator()->create_module('assign', ['course' => $course]);
         $user = $this->getDataGenerator()->create_user();
+        $guidegenerator = \testing_util::get_data_generator()->get_plugin_generator('gradingform_guide');
 
         $this->setUser($user);
 
         $modulecontext = context_module::instance($module->cmid);
-        $guide = new test_guide($modulecontext, 'testrubrib', 'Description text');
-        $guide->add_criteria(
-            'Spelling mistakes',
-            'Full marks will be given for no spelling mistakes.',
-            'Deduct 5 points per spelling mistake made.',
-            25
-        );
-        $guide->add_criteria(
-            'Pictures',
-            'Full marks will be given for including 3 pictures.',
-            'Give 5 points for each picture present',
-            15
-        );
-        $guide->create_guide();
+        $controller = $guidegenerator->get_test_guide($modulecontext);
 
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid = 1;
-        $gradedata = [
-            ['remark' => 'This user made several mistakes.', 'score' => 5],
-            ['remark' => 'This user has two pictures.', 'score' => 10]
-        ];
-        $instance = $guide->grade_item($user->id, $itemid, $gradedata);
+        $instance = $controller->create_instance($user->id, $itemid);
+        $data = $guidegenerator->get_test_form_data(
+            $controller,
+            $itemid,
+            5, 'This user made several mistakes.',
+            10, 'This user has two pictures.'
+        );
+        $instance->update($data);
 
         $itemid = 2;
-        $gradedata = [
-            ['remark' => 'This user made no mistakes.', 'score' => 25],
-            ['remark' => 'This user has one picture.', 'score' => 5]
-        ];
-        $instance = $guide->grade_item($user->id, $itemid, $gradedata);
+        $instance = $controller->create_instance($user->id, $itemid);
+        $data = $guidegenerator->get_test_form_data(
+            $controller,
+            $itemid,
+            25, 'This user made no mistakes.',
+            5, 'This user has one picture.'
+        );
+        $instance->update($data);
 
         // Check how many records we have in the fillings table.
         $records = $DB->get_records('gradingform_guide_fillings');
@@ -374,52 +360,50 @@ class core_grading_privacy_testcase extends provider_testcase {
         $user1 = $this->getDataGenerator()->create_user();
         $user2 = $this->getDataGenerator()->create_user();
         $user3 = $this->getDataGenerator()->create_user();
+        $guidegenerator = \testing_util::get_data_generator()->get_plugin_generator('gradingform_guide');
 
         $this->setUser($user1);
 
         $modulecontext = context_module::instance($module->cmid);
-        $guide = new test_guide($modulecontext, 'testrubrib', 'Description text');
-        $guide->add_criteria(
-            'Spelling mistakes',
-            'Full marks will be given for no spelling mistakes.',
-            'Deduct 5 points per spelling mistake made.',
-            25
-        );
-        $guide->add_criteria(
-            'Pictures',
-            'Full marks will be given for including 3 pictures.',
-            'Give 5 points for each picture present',
-            15
-        );
-        $guide->create_guide();
+        $controller = $guidegenerator->get_test_guide($modulecontext);
 
         // In the situation of mod_assign this would be the id from assign_grades.
         $itemid1 = 1;
-        $gradedata = [
-            ['remark' => 'This user made several mistakes.', 'score' => 5],
-            ['remark' => 'This user has two pictures.', 'score' => 10]
-        ];
-        $instance1 = $guide->grade_item($user1->id, $itemid1, $gradedata);
+        $instance1 = $controller->create_instance($user1->id, $itemid1);
+        $data = $guidegenerator->get_test_form_data(
+            $controller,
+            $itemid1,
+            5, 'This user made several mistakes.',
+            10, 'This user has two pictures.'
+        );
+        $instance1->update($data);
 
         $itemid2 = 2;
-        $gradedata = [
-            ['remark' => 'This user made a couple of mistakes.', 'score' => 15],
-            ['remark' => 'This user has one picture.', 'score' => 10]
-        ];
-        $instance2 = $guide->grade_item($user2->id, $itemid2, $gradedata);
+        $instance2 = $controller->create_instance($user2->id, $itemid2);
+        $data = $guidegenerator->get_test_form_data(
+            $controller,
+            $itemid2,
+            15, 'This user made a couple of mistakes.',
+            10, 'This user has one picture.'
+        );
+        $instance2->update($data);
 
         $itemid3 = 3;
-        $gradedata = [
-            ['remark' => 'This user made one mistakes.', 'score' => 20],
-            ['remark' => 'This user has one picture.', 'score' => 10]
-        ];
-        $instance3 = $guide->grade_item($user3->id, $itemid3, $gradedata);
+        $instance3 = $controller->create_instance($user3->id, $itemid3);
+        $data = $guidegenerator->get_test_form_data(
+            $controller,
+            $itemid3,
+            20, 'This user made one mistakes.',
+            10, 'This user has one picture.'
+        );
+        $instance3->update($data);
 
         $records = $DB->get_records('gradingform_guide_fillings');
         $this->assertCount(6, $records);
 
         // Delete all user data for items 1 and 3.
         provider::delete_data_for_instances($modulecontext, [$itemid1, $itemid3]);
+
         $records = $DB->get_records('gradingform_guide_fillings');
         $this->assertCount(2, $records);
         $instanceid = $instance2->get_data('id');
index bffba6d..1177582 100644 (file)
@@ -37,7 +37,7 @@ $string['cliincorrectvalueretry'] = 'Netočna vrijednost, pokušajte ponovno';
 $string['clitypevalue'] = 'unesite vrijednost';
 $string['clitypevaluedefault'] = 'unesite vrijednost ili pritisnite Enter za korištenje zadane vrijednosti ({$a})';
 $string['cliunknowoption'] = 'Nepoznate opcije: {$a} Molimo koristite --help opciju.';
-$string['cliyesnoprompt'] = 'unesite y (znači da) ili n (znači ne)';
+$string['cliyesnoprompt'] = 'unesite d (znači da) ili n (znači ne)';
 $string['environmentrequireinstall'] = 'je neophodno instalirati/omogućiti';
 $string['environmentrequireversion'] = 'neophodna inačica je {$a->needed}, a vi trenutačno koristite inačicu {$a->current}';
 $string['upgradekeyset'] = 'Ključ za ažuriranje (ostavite prazno kako ga ne bi zadali)';
index 39e30aa..4838f0c 100644 (file)
@@ -1213,6 +1213,8 @@ $string['task_logretention'] = 'Retention period';
 $string['task_logretention_desc'] = 'The maximum period that logs should be kept for. This setting interacts with the \'Retain runs\' setting: whichever is reached first will apply';
 $string['task_logretainruns'] = 'Retain runs';
 $string['task_logretainruns_desc'] = 'The number of runs of each task to retain. This setting interacts with the \'Retention period\' setting: whichever is reached first will apply.';
+$string['task_logtostdout'] = 'Display log output';
+$string['task_logtostdout_desc'] = 'When jobs are running and the output is captured, whether the captured output should also be displayed as the task runs.';
 $string['task_type:adhoc'] = 'Ad hoc';
 $string['task_type:scheduled'] = 'Scheduled';
 $string['task_result:failed'] = 'Fail';
@@ -1318,6 +1320,7 @@ $string['unsupportedphpversion7'] = 'PHP version 7 is not supported.';
 $string['unsupportedphpversion71'] = 'PHP version 7.1 is not supported.';
 $string['unsupportedphpversion72'] = 'PHP version 7.2 is not supported.';
 $string['unsupportedphpversion73'] = 'PHP version 7.3 is not supported.';
+$string['unsupportedphpversion74'] = 'PHP version 7.4 is not supported.';
 $string['unsuspenduser'] = 'Activate user account';
 $string['updateaccounts'] = 'Update existing accounts';
 $string['updatecomponent'] = 'Update component';
index b735735..636cf13 100644 (file)
@@ -46,6 +46,12 @@ $string['nocourseactivity'] = 'Not enough course activity between the start and
 $string['nocourseendtime'] = 'The course does not have an end time';
 $string['nocoursesections'] = 'No course sections';
 $string['nocoursestudents'] = 'No students';
+$string['noaccesssincestartinfomessage'] = 'Hi {$a->userfirstname},
+
+</br><br/>Students in {$a->coursename} have never accessed the course.';
+$string['norecentaccessesinfomessage'] = 'Hi {$a->userfirstname},
+
+</br><br/>Students in {$a->coursename} have not accessed the course recently.';
 $string['noteachinginfomessage'] = 'Hi {$a->userfirstname},
 
 </br><br/>Courses with start dates in the next week have been identified as having no teacher or student enrolments.';
@@ -66,6 +72,10 @@ $string['target:coursedropout'] = 'Students at risk of dropping out';
 $string['target:coursedropout_help'] = 'This target describes whether the student is considered at risk of dropping out.';
 $string['target:coursegradetopass'] = 'Students at risk of not achieving the minimum grade to pass the course';
 $string['target:coursegradetopass_help'] = 'This target describes whether the student is at risk of not achieving the minimum grade to pass the course.';
+$string['target:noaccesssincecoursestart'] = 'Students who have not accessed the course yet';
+$string['target:noaccesssincecoursestart_help'] = 'This target describes students who never accessed a course they are enrolled in.';
+$string['target:norecentaccesses'] = 'Students who have not accessed the course recently';
+$string['target:norecentaccesses_help'] = 'This target describes students who have not accessed a course recently.';
 $string['target:noteachingactivity'] = 'Courses at risk of not starting';
 $string['target:noteachingactivity_help'] = 'This target describes whether courses due to start in the coming week will have teaching activity.';
 $string['targetlabelstudentcompletionno'] = 'Student who is likely to meet the course completion conditions';
index f597c33..7ec6c45 100644 (file)
@@ -37,8 +37,10 @@ $string['localewincharset'] = '';
 $string['oldcharset'] = 'ISO-8859-1';
 $string['parentlanguage'] = '';
 $string['strftimedate'] = '%d %B %Y';
+$string['strftimedatemonthabbr'] = '%d %b %Y';
 $string['strftimedatefullshort'] = '%d/%m/%y';
 $string['strftimedateshort'] = '%d %B';
+$string['strftimedateshortmonthabbr'] = '%d %b';
 $string['strftimedatetime'] = '%d %B %Y, %I:%M %p';
 $string['strftimedatetimeshort'] = '%d/%m/%y, %H:%M';
 $string['strftimedaydate'] = '%A, %d %B %Y';
index 58180ee..48edfc5 100644 (file)
@@ -1046,6 +1046,8 @@ $string['indicator:accessesbeforestart'] = 'Course accessed before start date';
 $string['indicator:accessesbeforestart_help'] = 'This indicator reflects if the student accessed the course before the course start date.';
 $string['indicator:activitiesdue'] = 'Activities due';
 $string['indicator:activitiesdue_help'] = 'The user has activities due.';
+$string['indicator:anycourseaccess'] = 'Any course access';
+$string['indicator:anycourseaccess_help'] = 'This indicator reflects any accesses to the provided course for the provided user.';
 $string['indicator:anywrite'] = 'Any write action';
 $string['indicator:anywrite_help'] = 'This indicator represents any write (submit) action taken by the student.';
 $string['indicator:anywriteincourse'] = 'Any write action in the course';
@@ -1991,12 +1993,24 @@ $string['timesplitting:quartersaccum'] = 'All previous quarters';
 $string['timesplitting:quartersaccum_help'] = 'This analysis interval divides the course into quarters (4 equal parts), with each prediction being based on the accumulated data of all previous quarters.';
 $string['timesplitting:singlerange'] = 'From start to end';
 $string['timesplitting:singlerange_help'] = 'This analysis interval considers the entire course as a single span.';
+$string['timesplitting:onemonthafterstart'] = 'One month after start';
+$string['timesplitting:onemonthafterstart_help'] = 'This analysis interval generates a prediction 1 month after the analysable start.';
+$string['timesplitting:oneweekafterstart'] = 'One week after start';
+$string['timesplitting:oneweekafterstart_help'] = 'This analysis interval generates a prediction 1 week after the analysable start.';
+$string['timesplitting:past3days'] = 'Past 3 days';
+$string['timesplitting:past3days_help'] = 'This analysis interval generates predictions every 3 days. The indicators calculations will be based on the past 3 days.';
+$string['timesplitting:pastmonth'] = 'Past month';
+$string['timesplitting:pastmonth_help'] = 'This analysis interval generates predictions every month. The indicators calculations will be based on the past month.';
+$string['timesplitting:pastweek'] = 'Past week';
+$string['timesplitting:pastweek_help'] = 'This analysis interval generates predictions every week. The indicators calculations will be based on the past week.';
 $string['timesplitting:upcoming3days'] = 'Upcoming 3 days';
 $string['timesplitting:upcoming3days_help'] = 'This analysis interval generates predictions every 3 days. The indicators calculations will be based on the upcoming 3 days.';
 $string['timesplitting:upcomingfortnight'] = 'Upcoming fortnight';
 $string['timesplitting:upcomingfortnight_help'] = 'This analysis interval generates predictions every fortnight. The indicators calculations will be based on the upcoming fortnight.';
 $string['timesplitting:upcomingweek'] = 'Upcoming week';
 $string['timesplitting:upcomingweek_help'] = 'This analysis interval generates predictions every week. The indicators calculations will be based on the upcoming week.';
+$string['timesplitting:tenpercentafterstart'] = '10% after start';
+$string['timesplitting:tenpercentafterstart_help'] = 'This analysis interval generates a prediction after the 10% of the course is completed.';
 $string['thanks'] = 'Thanks';
 $string['theme'] = 'Theme';
 $string['themes'] = 'Themes';
index d4e6b04..15471dc 100644 (file)
Binary files a/lib/amd/build/templates.min.js and b/lib/amd/build/templates.min.js differ
index ff59240..08d2f3f 100644 (file)
Binary files a/lib/amd/build/templates.min.js.map and b/lib/amd/build/templates.min.js.map differ
index e11dc12..75baa08 100644 (file)
@@ -492,7 +492,7 @@ define([
         // Escape the {{ and the ".
         // This involves wrapping {{, and }} in change delimeter tags.
         content = content
-            .replace('"', '\\"')
+            .replace(/"/g, '\\"')
             .replace(/([\{\}]{2,3})/g, '{{=<% %>=}}$1<%={{ }}=%>')
             .replace(/(\r\n|\r|\n)/g, '&#x0a;')
             ;
index 4f11771..1988e01 100644 (file)
@@ -31,6 +31,9 @@ require_once(__DIR__ . '/behat_command.php');
 require_once(__DIR__ . '/behat_config_manager.php');
 
 require_once(__DIR__ . '/../../filelib.php');
+require_once(__DIR__ . '/../../clilib.php');
+
+use Behat\Mink\Session;
 
 /**
  * Init/reset utilities for Behat database and dataroot
@@ -374,4 +377,38 @@ class behat_util extends testing_util {
         // $CFG values from the old run. @see set_config.
         initialise_cfg();
     }
+
+    /**
+     * Pause execution immediately.
+     *
+     * @param Session $session
+     * @param string $message The message to show when pausing.
+     * This will be passed through cli_ansi_format so appropriate ANSI formatting and features are available.
+     */
+    public static function pause(Session $session, string $message): void {
+        $posixexists = function_exists('posix_isatty');
+
+        // Make sure this step is only used with interactive terminal (if detected).
+        if ($posixexists && !@posix_isatty(STDOUT)) {
+            throw new ExpectationException('Break point should only be used with interactive terminal.', $session);
+        }
+
+        // Save the cursor position, ring the bell, and add a new line.
+        fwrite(STDOUT, cli_ansi_format("<cursor:save><bell><newline>"));
+
+        // Output the formatted message and reset colour back to normal.
+        $formattedmessage = cli_ansi_format("{$message}<colour:normal>");
+        fwrite(STDOUT, $formattedmessage);
+
+        // Wait for input.
+        fread(STDIN, 1024);
+
+        // Move the cursor back up to the previous position, then restore the original position stored earlier, and move
+        // it back down again.
+        fwrite(STDOUT, cli_ansi_format("<cursor:up><cursor:up><cursor:restore><cursor:down><cursor:down>"));
+
+        // Add any extra lines back if the provided message was spread over multiple lines.
+        $linecount = count(explode("\n", $formattedmessage));
+        fwrite(STDOUT, str_repeat(cli_ansi_format("<cursor:down>"), $linecount - 1));
+    }
 }
diff --git a/lib/classes/analytics/indicator/any_course_access.php b/lib/classes/analytics/indicator/any_course_access.php
new file mode 100644 (file)
index 0000000..355e382
--- /dev/null
@@ -0,0 +1,135 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Any access indicator.
+ *
+ * @package   core
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\indicator;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Any access indicator.
+ *
+ * @package   core
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class any_course_access extends \core_analytics\local\indicator\binary {
+
+    /**
+     * Returns the name.
+     *
+     * If there is a corresponding '_help' string this will be shown as well.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('indicator:anycourseaccess');
+    }
+
+    /**
+     * required_sample_data
+     *
+     * @return string[]
+     */
+    public static function required_sample_data() {
+        return array('course', 'user');
+    }
+
+    /**
+     * Store userid => timeaccess relation if the provided analysable is a course.
+     *
+     * @param  \core_analytics\analysable $analysable
+     * @return null
+     */
+    public function fill_per_analysable_caches(\core_analytics\analysable $analysable) {
+        global $DB;
+
+        if ($analysable instanceof \core_analytics\course) {
+            // Indexed by userid (there is a UNIQUE KEY at DB level).
+            $this->lastaccesses = $DB->get_records('user_lastaccess', ['courseid' => $analysable->get_id()],
+                '', 'userid, timeaccess');
+        }
+    }
+
+    /**
+     * calculate_sample
+     *
+     * @param int $sampleid
+     * @param string $sampleorigin
+     * @param int $starttime
+     * @param int $endtime
+     * @return float
+     */
+    protected function calculate_sample($sampleid, $sampleorigin, $starttime = false, $endtime = false) {
+
+        $course = $this->retrieve('course', $sampleid);
+        $user = $this->retrieve('user', $sampleid);
+
+        // We first try using user_lastaccess as it is much faster than the log table.
+        if (empty($this->lastaccesses[$user->id]->timeaccess)) {
+            // The user never accessed.
+            return self::get_min_value();
+        } else if (!$starttime && !$endtime) {
+            // No time restrictions, so all good as long as there is a record.
+            return self::get_max_value();
+        } else if ($starttime && $this->lastaccesses[$user->id]->timeaccess < $starttime) {
+            // The last access is prior to $starttime.
+            return self::get_min_value();
+        } else if ($endtime && $this->lastaccesses[$user->id]->timeaccess < $endtime) {
+            // The last access is before the $endtime.
+            return self::get_max_value();
+        } else if ($starttime && !$endtime && $starttime <= $this->lastaccesses[$user->id]->timeaccess) {
+            // No end time, so max value as long as the last access is after $starttime.
+            return self::get_max_value();
+        }
+
+        // If the last access is after $endtime we can not know for sure if the user accessed or not
+        // between $starttime and $endtime, we need to check the logs table in this case. Note that
+        // it is unlikely that we will reach this point as this indicator will be used in models whose
+        // dates are in the past.
+
+        if (!$logstore = \core_analytics\manager::get_analytics_logstore()) {
+            throw new \coding_exception('No available log stores');
+        }
+
+        // Filter by context to use the logstore_standard_log db table index.
+        $select = "userid = :userid AND courseid = :courseid";
+        $params = ['courseid' => $course->id, 'userid' => $user->id];
+
+        if ($starttime) {
+            $select .= " AND timecreated > :starttime";
+            $params['starttime'] = $starttime;
+        }
+        if ($endtime) {
+            $select .= " AND timecreated <= :endtime";
+            $params['endtime'] = $endtime;
+        }
+
+        $nlogs = $logstore->get_events_select_count($select, $params);
+        if ($nlogs) {
+            return self::get_max_value();
+        } else {
+            return self::get_min_value();
+        }
+    }
+}
diff --git a/lib/classes/analytics/time_splitting/one_month_after_start.php b/lib/classes/analytics/time_splitting/one_month_after_start.php
new file mode 100644 (file)
index 0000000..650c176
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates predictions one month after the analysable start.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates predictions one month after the analysable start.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class one_month_after_start extends \core_analytics\local\time_splitting\after_start {
+
+    /**
+     * The time splitting method name.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:onemonthafterstart');
+    }
+
+    /**
+     * The period we should wait until we generate predictions for this.
+     *
+     * @param  \core_analytics\analysable $analysable Not used in this implementation.
+     * @return \DateInterval
+     */
+    protected function wait_period(\core_analytics\analysable $analysable) {
+        return new \DateInterval('P1M');
+    }
+}
diff --git a/lib/classes/analytics/time_splitting/one_week_after_start.php b/lib/classes/analytics/time_splitting/one_week_after_start.php
new file mode 100644 (file)
index 0000000..5957484
--- /dev/null
@@ -0,0 +1,56 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates predictions one week after the analysable start.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates predictions one week after the analysable start.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class one_week_after_start extends \core_analytics\local\time_splitting\after_start {
+
+    /**
+     * The time splitting method name.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:oneweekafterstart');
+    }
+
+    /**
+     * The period we should wait until we generate predictions for this.
+     *
+     * @param  \core_analytics\analysable $analysable Not used in this implementation.
+     * @return \DateInterval
+     */
+    protected function wait_period(\core_analytics\analysable $analysable) {
+        return new \DateInterval('P1W');
+    }
+}
diff --git a/lib/classes/analytics/time_splitting/past_3_days.php b/lib/classes/analytics/time_splitting/past_3_days.php
new file mode 100644 (file)
index 0000000..a63a38a
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates predictions every 3 days.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates predictions every 3 days.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class past_3_days extends \core_analytics\local\time_splitting\past_periodic {
+
+    /**
+     * The time splitting method name.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:past3days');
+    }
+
+    /**
+     * Once every 3 days.
+     *
+     * @return \DateInterval
+     */
+    public function periodicity() {
+        return new \DateInterval('P3D');
+    }
+}
diff --git a/lib/classes/analytics/time_splitting/past_month.php b/lib/classes/analytics/time_splitting/past_month.php
new file mode 100644 (file)
index 0000000..3d73569
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates monthly predictions.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates monthly predictions.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class past_month extends \core_analytics\local\time_splitting\past_periodic {
+
+    /**
+     * The time splitting method name.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:pastmonth');
+    }
+
+    /**
+     * Once a month.
+     *
+     * @return \DateInterval
+     */
+    public function periodicity() {
+        return new \DateInterval('P1M');
+    }
+}
@@ -22,6 +22,8 @@
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+namespace core\analytics\time_splitting;
+
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -31,18 +33,19 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class test_timesplitting_weekly extends \core_analytics\local\time_splitting\periodic {
+class past_week extends \core_analytics\local\time_splitting\past_periodic {
 
     /**
      * The time splitting method name.
      * @return \lang_string
      */
     public static function get_name() : \lang_string {
-        return new \lang_string('error');
+        return new \lang_string('timesplitting:pastweek');
     }
 
     /**
      * Once per week.
+     *
      * @return \DateInterval
      */
     public function periodicity() {
diff --git a/lib/classes/analytics/time_splitting/ten_percent_after_start.php b/lib/classes/analytics/time_splitting/ten_percent_after_start.php
new file mode 100644 (file)
index 0000000..a8b5642
--- /dev/null
@@ -0,0 +1,81 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Time splitting method that generates predictions 3 days after the analysable start.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\analytics\time_splitting;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Time splitting method that generates predictions 3 days after the analysable start.
+ *
+ * @package   core_analytics
+ * @copyright 2019 David Monllao {@link http://www.davidmonllao.com}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class ten_percent_after_start extends \core_analytics\local\time_splitting\after_start {
+
+    /**
+     * The time splitting method name.
+     *
+     * @return \lang_string
+     */
+    public static function get_name() : \lang_string {
+        return new \lang_string('timesplitting:tenpercentafterstart');
+    }
+
+    /**
+     * Extended as we require and end date here.
+     *
+     * @param \core_analytics\analysable $analysable
+     * @return bool
+     */
+    public function is_valid_analysable(\core_analytics\analysable $analysable) {
+
+        // We require an end date to calculate the 10%.
+        if (!$analysable->get_end()) {
+            return false;
+        }
+
+        return parent::is_valid_analysable($analysable);
+    }
+
+    /**
+     * The period we should wait until we generate predictions for this.
+     *
+     * @throws \coding_exception
+     * @param  \core_analytics\analysable $analysable
+     * @return \DateInterval
+     */
+    protected function wait_period(\core_analytics\analysable $analysable) {
+
+        if (!$analysable->get_end() || !$analysable->get_start()) {
+            throw new \coding_exception('Analysables with no start or end should be discarded in is_valid_analysable.');
+        }
+
+        $diff = $analysable->get_end() - $analysable->get_start();
+
+        // A 10% of $diff.
+        return new \DateInterval('PT' . intval($diff / 10) . 'S');
+    }
+}
index 960cdf7..1e65d4f 100644 (file)
@@ -1687,7 +1687,7 @@ class core_plugin_manager {
                 'equation', 'fontcolor', 'html', 'image', 'indent', 'italic',
                 'link', 'managefiles', 'media', 'noautolink', 'orderedlist',
                 'recordrtc', 'rtl', 'strike', 'subscript', 'superscript', 'table',
-                'title', 'underline', 'undo', 'unorderedlist'
+                'title', 'underline', 'undo', 'unorderedlist', 'h5p'
             ),
 
             'assignment' => array(
index 8b812e1..819ec44 100644 (file)
@@ -76,13 +76,18 @@ class logmanager {
      */
     protected static $oblevel = null;
 
+    /**
+     * @var bool Output logged content to screen.
+     */
+    protected static $outputloggedcontent = true;
+
     /**
      * Create a new task logger for the specified task, and prepare for logging.
      *
      * @param   \core\task\task_base    $task The task being run
      */
     public static function start_logging(task_base $task) {
-        global $DB;
+        global $CFG, $DB;
 
         if (!self::should_log()) {
             return;
@@ -129,6 +134,8 @@ class logmanager {
             self::$oblevel = null;
         }
 
+        self::$outputloggedcontent = !empty($CFG->task_logtostdout);
+
         // Start capturing output.
         ob_start([\core\task\logmanager::class, 'add_line'], self::CHUNKSIZE);
     }
@@ -336,6 +343,10 @@ class logmanager {
             fwrite(self::$fh, $log);
         }
 
-        return $log;
+        if (self::$outputloggedcontent) {
+            return $log;
+        } else {
+            return '';
+        }
     }
 }
index ba243f1..c0b908e 100644 (file)
@@ -227,3 +227,59 @@ function cli_logo($padding=2, $return=false) {
         cli_write($logo);
     }
 }
+
+/**
+ * Substitute cursor, colour, and bell placeholders in a CLI output to ANSI escape characters when ANSI is available.
+ *
+ * @param string $message
+ * @return string
+ */
+function cli_ansi_format(string $message): string {
+    global $CFG;
+
+    $replacements = [
+        "<newline>" => "\n",
+        "<bell>" => "\007",
+
+        // Cursor movement: https://www.tldp.org/HOWTO/Bash-Prompt-HOWTO/x361.html.
+        "<cursor:save>" => "\033[s",
+        "<cursor:restore>" => "\033[u",
+        "<cursor:up>" => "\033[1A",
+        "<cursor:down>" => "\033[1B",
+        "<cursor:forward>" => "\033[1C",
+        "<cursor:back>" => "\033[1D",
+    ];
+
+    $colours = [
+        'normal' => '0;0',
+        'black' => '0;30',
+        'darkGray' => '1;30',
+        'blue' => '0;34',
+        'lightBlue' => '1;34',
+        'green' => '0;32',
+        'lightGreen' => '1;32',
+        'cyan' => '0;36',
+        'lightCyan' => '1;36',
+        'red' => '0;31',
+        'lightRed' => '1;31',
+        'purple' => '0;35',
+        'lightPurple' => '1;35',
+        'brown' => '0;33',
+        'yellow' => '1;33',
+        'lightYellow' => '0;93',
+        'lightGray' => '0;37',
+        'white' => '1;37',
+    ];
+
+    foreach ($colours as $colour => $code) {
+        $replacements["<colour:{$colour}>"] = "\033[{$code}m";
+    }
+
+    // Windows don't support ANSI code by default, but does if ANSICON is available.
+    $isansicon = getenv('ANSICON');
+    if (($CFG->ostype === 'WINDOWS') && empty($isansicon)) {
+        return str_replace(array_keys($replacements), '', $message);
+    }
+
+    return str_replace(array_keys($replacements), array_values($replacements), $message);
+}
index 5a689fc..7efac78 100644 (file)
@@ -97,4 +97,20 @@ $models = [
         'timesplitting' => '\core\analytics\time_splitting\upcoming_week',
         'enabled' => true,
     ],
+    [
+        'target' => '\core_course\analytics\target\no_access_since_course_start',
+        'indicators' => [
+            '\core\analytics\indicator\any_course_access',
+        ],
+        'timesplitting' => '\core\analytics\time_splitting\one_month_after_start',
+        'enabled' => true,
+    ],
+    [
+        'target' => '\core_course\analytics\target\no_recent_accesses',
+        'indicators' => [
+            '\core\analytics\indicator\any_course_access',
+        ],
+        'timesplitting' => '\core\analytics\time_splitting\past_month',
+        'enabled' => true,
+    ],
 ];
index 62b74b1..fd519a1 100644 (file)
@@ -634,6 +634,15 @@ $functions = array(
         'type' => 'read',
         'capabilities' => 'moodle/course:enrolreview'
     ),
+    'core_enrol_search_users' => [
+        'classname' => 'core_enrol_external',
+        'methodname' => 'search_users',
+        'classpath' => 'enrol/externallib.php',
+        'description' => 'Search within the list of course participants',
+        'ajax' => true,
+        'type' => 'read',
+        'capabilities' => 'moodle/course:viewparticipants',
+    ],
     'core_enrol_get_users_courses' => array(
         'classname' => 'core_enrol_external',
         'methodname' => 'get_users_courses',
index 7b1f233..7986689 100644 (file)
@@ -103,5 +103,28 @@ function xmldb_editor_atto_upgrade($oldversion) {
     // Automatically generated Moodle v3.7.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2019090900) {
+        $toolbar = get_config('editor_atto', 'toolbar');
+
+        if (strpos($toolbar, 'h5p') === false) {
+            $glue = "\r\n";
+            if (strpos($toolbar, $glue) === false) {
+                $glue = "\n";
+            }
+            $groups = explode($glue, $toolbar);
+            // Try to put h5p in the files group.
+            foreach ($groups as $i => $group) {
+                $parts = explode('=', $group);
+                if (trim($parts[0]) == 'files') {
+                    $groups[$i] = 'files = ' . trim($parts[1]) . ', h5p';
+                    // Update config variable.
+                    $toolbar = implode($glue, $groups);
+                    set_config('toolbar', $toolbar, 'editor_atto');
+                }
+            }
+        }
+        // Atto editor savepoint reached.
+        upgrade_plugin_savepoint(true, 2019090900, 'editor', 'atto');
+    }
     return true;
 }
diff --git a/lib/editor/atto/plugins/h5p/classes/privacy/provider.php b/lib/editor/atto/plugins/h5p/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..e2b5aeb
--- /dev/null
@@ -0,0 +1,46 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Privacy Subsystem implementation for atto_h5p.
+ *
+ * @package    atto_h5p
+ * @copyright  2019 Bas Brands <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace atto_h5p\privacy;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Privacy Subsystem for atto_h5p implementing null_provider.
+ *
+ * @copyright  2019 Bas Brands <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class provider implements \core_privacy\local\metadata\null_provider {
+
+    /**
+     * Get the language string identifier with the component's language
+     * file to explain why this plugin stores no data.
+     *
+     * @return  string
+     */
+    public static function get_reason() : string {
+        return 'privacy:metadata';
+    }
+}
diff --git a/lib/editor/atto/plugins/h5p/db/access.php b/lib/editor/atto/plugins/h5p/db/access.php
new file mode 100644 (file)
index 0000000..76bde79
--- /dev/null
@@ -0,0 +1,35 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * H5P Atto button capabilities.
+ *
+ * @package    atto_h5p
+ * @copyright  2019 Bas Brands <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$capabilities = [
+    'atto/h5p:addembed' => [
+        'captype'      => 'write',
+        'contextlevel' => CONTEXT_MODULE,
+        'archetypes' => [
+            'editingteacher' => CAP_ALLOW,
+        ],
+    ]
+];
diff --git a/lib/editor/atto/plugins/h5p/js/h5p-resizer.js b/lib/editor/atto/plugins/h5p/js/h5p-resizer.js
new file mode 100644 (file)
index 0000000..ed78724
--- /dev/null
@@ -0,0 +1,131 @@
+// H5P iframe Resizer
+(function () {
+  if (!window.postMessage || !window.addEventListener || window.h5pResizerInitialized) {
+    return; // Not supported
+  }
+  window.h5pResizerInitialized = true;
+
+  // Map actions to handlers
+  var actionHandlers = {};
+
+  /**
+   * Prepare iframe resize.
+   *
+   * @private
+   * @param {Object} iframe Element
+   * @param {Object} data Payload
+   * @param {Function} respond Send a response to the iframe
+   */
+  actionHandlers.hello = function (iframe, data, respond) {
+    // Make iframe responsive
+    iframe.style.width = '100%';
+
+    // Bugfix for Chrome: Force update of iframe width. If this is not done the
+    // document size may not be updated before the content resizes.
+    iframe.getBoundingClientRect();
+
+    // Tell iframe that it needs to resize when our window resizes
+    var resize = function () {
+      if (iframe.contentWindow) {
+        // Limit resize calls to avoid flickering
+        respond('resize');
+      }
+      else {
+        // Frame is gone, unregister.
+        window.removeEventListener('resize', resize);
+      }
+    };
+    window.addEventListener('resize', resize, false);
+
+    // Respond to let the iframe know we can resize it
+    respond('hello');
+  };
+
+  /**
+   * Prepare iframe resize.
+   *
+   * @private
+   * @param {Object} iframe Element
+   * @param {Object} data Payload
+   * @param {Function} respond Send a response to the iframe
+   */
+  actionHandlers.prepareResize = function (iframe, data, respond) {
+    // Do not resize unless page and scrolling differs
+    if (iframe.clientHeight !== data.scrollHeight ||
+        data.scrollHeight !== data.clientHeight) {
+
+      // Reset iframe height, in case content has shrinked.
+      iframe.style.height = data.clientHeight + 'px';
+      respond('resizePrepared');
+    }
+  };
+
+  /**
+   * Resize parent and iframe to desired height.
+   *
+   * @private
+   * @param {Object} iframe Element
+   * @param {Object} data Payload
+   * @param {Function} respond Send a response to the iframe
+   */
+  actionHandlers.resize = function (iframe, data) {
+    // Resize iframe so all content is visible. Use scrollHeight to make sure we get everything
+    iframe.style.height = data.scrollHeight + 'px';
+  };
+
+  /**
+   * Keyup event handler. Exits full screen on escape.
+   *
+   * @param {Event} event
+   */
+  var escape = function (event) {
+    if (event.keyCode === 27) {
+      exitFullScreen();
+    }
+  };
+
+  // Listen for messages from iframes
+  window.addEventListener('message', function receiveMessage(event) {
+    if (event.data.context !== 'h5p') {
+      return; // Only handle h5p requests.
+    }
+
+    // Find out who sent the message
+    var iframe, iframes = document.getElementsByTagName('iframe');
+    for (var i = 0; i < iframes.length; i++) {
+      if (iframes[i].contentWindow === event.source) {
+        iframe = iframes[i];
+        break;
+      }
+    }
+
+    if (!iframe) {
+      return; // Cannot find sender
+    }
+
+    // Find action handler handler
+    if (actionHandlers[event.data.action]) {
+      actionHandlers[event.data.action](iframe, event.data, function respond(action, data) {
+        if (data === undefined) {
+          data = {};
+        }
+        data.action = action;
+        data.context = 'h5p';
+        event.source.postMessage(data, event.origin);
+      });
+    }
+  }, false);
+
+  // Let h5p iframes know we're ready!
+  var iframes = document.getElementsByTagName('iframe');
+  var ready = {
+    context: 'h5p',
+    action: 'ready'
+  };
+  for (var i = 0; i < iframes.length; i++) {
+    if (iframes[i].src.indexOf('h5p') !== -1) {
+      iframes[i].contentWindow.postMessage(ready, '*');
+    }
+  }
+
+})();
diff --git a/lib/editor/atto/plugins/h5p/js/readme_moodle.txt b/lib/editor/atto/plugins/h5p/js/readme_moodle.txt
new file mode 100644 (file)
index 0000000..e403b44
--- /dev/null
@@ -0,0 +1,9 @@
+The H5P resizer JS.
+
+to update:
+
+Downloaded last release from: https://github.com/h5p/h5p-php-library/releases
+
+Import
+
+- In the downloaded h5p-php-library copy js/h5p-resizer.js into lib/editor/atto/plugins/h5p/js
diff --git a/lib/editor/atto/plugins/h5p/lang/en/atto_h5p.php b/lib/editor/atto/plugins/h5p/lang/en/atto_h5p.php
new file mode 100644 (file)
index 0000000..2343281
--- /dev/null
@@ -0,0 +1,31 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Strings for component 'atto_h5p', language 'en'.
+ *
+ * @package    atto_h5p
+ * @copyright  2019 Bas Brands <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+$string['enterurl'] = 'Enter URL';
+$string['h5pproperties'] = 'H5P properties';
+$string['invalidh5purl'] = 'Invalid URL';
+$string['pluginname'] = 'Insert H5P';
+$string['privacy:metadata'] = 'The atto_h5p plugin does not store any personal data.';
+$string['h5p:addembed'] = 'Add embedded H5P';
+$string['saveh5p'] = 'Save H5P';
\ No newline at end of file
diff --git a/lib/editor/atto/plugins/h5p/lib.php b/lib/editor/atto/plugins/h5p/lib.php
new file mode 100644 (file)
index 0000000..d415bc0
--- /dev/null
@@ -0,0 +1,67 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Atto text editor integration version file.
+ *
+ * @package    atto_h5p
+ * @copyright  2019 Bas Brands <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Set params for this button.
+ *
+ * @param string $elementid
+ * @param stdClass $options - the options for the editor, including the context.
+ * @param stdClass $fpoptions - unused.
+ */
+function atto_h5p_params_for_js($elementid, $options, $fpoptions) {
+    $context = $options['context'];
+    if (!$context) {
+        $context = context_system::instance();
+    }
+    $addembed = has_capability('atto/h5p:addembed', $context);
+
+    $allowedmethods = 'none';
+    if ($addembed) {
+        $allowedmethods = 'embed';
+    }
+
+    $params = ['allowedmethods' => $allowedmethods];
+    return $params;
+}
+
+/**
+ * Initialise the strings required for js
+ */
+function atto_h5p_strings_for_js() {
+    global $PAGE;
+
+    $strings = array(
+        'saveh5p',
+        'h5pproperties',
+        'enterurl',
+        'invalidh5purl'
+    );
+
+    $PAGE->requires->strings_for_js($strings, 'atto_h5p');
+    $PAGE->requires->js(new moodle_url('/lib/editor/atto/plugins/h5p/js/h5p-resizer.js'));
+}
+
+
diff --git a/lib/editor/atto/plugins/h5p/pix/icon.png b/lib/editor/atto/plugins/h5p/pix/icon.png
new file mode 100644 (file)
index 0000000..51a640d
Binary files /dev/null and b/lib/editor/atto/plugins/h5p/pix/icon.png differ
diff --git a/lib/editor/atto/plugins/h5p/pix/icon.svg b/lib/editor/atto/plugins/h5p/pix/icon.svg
new file mode 100644 (file)
index 0000000..2aaf327
--- /dev/null
@@ -0,0 +1,14 @@
+<?xml version="1.0" encoding="utf-8"?>
+<!-- Generator: Adobe Illustrator 23.0.1, SVG Export Plug-In . SVG Version: 6.00 Build 0)  -->
+<svg version="1.1" id="Layer_1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" x="0px" y="0px"
+        viewBox="0 0 345 150" style="enable-background:new 0 0 345 150;" xml:space="preserve" preserveAspectRatio="xMinYMid meet">
+<g>
+       <path d="M325.7,14.7C317.6,6.9,305.3,3,289,3h-43.5H234v31h-66l-5.4,22.2c4.5-2.1,10.9-4.2,15.3-5.3c4.4-1.1,8.8-0.9,13.1-0.9
+               c14.6,0,26.5,4.5,35.6,13.3c9.1,8.8,13.6,20,13.6,33.4c0,9.4-2.3,18.5-7,27.2s-11.3,15.4-19.9,20c-3.1,1.6-6.5,3.1-10.2,4.1h42.4
+               H259V95h25c18.2,0,31.7-4.2,40.6-12.5s13.3-19.9,13.3-34.6C337.9,33.6,333.8,22.5,325.7,14.7z M288.7,60.6c-3.5,3-9.6,4.4-18.3,4.4
+               H259V33h13.2c8.4,0,14.2,1.5,17.2,4.7c3.1,3.2,4.6,6.9,4.6,11.5C294,53.9,292.2,57.6,288.7,60.6z"/>
+       <path d="M176.5,76.3c-7.9,0-14.7,4.6-18,11.2L119,81.9L136.8,3h-23.6H101v62H51V3H7v145h44V95h50v53h12.2h42
+               c-6.7-2-12.5-4.6-17.2-8.1c-4.8-3.6-8.7-7.7-11.7-12.3c-3-4.6-5.3-9.7-7.3-16.5l39.6-5.7c3.3,6.6,10.1,11.1,17.9,11.1
+               c11.1,0,20.1-9,20.1-20.1S187.5,76.3,176.5,76.3z"/>
+</g>
+</svg>
diff --git a/lib/editor/atto/plugins/h5p/styles.css b/lib/editor/atto/plugins/h5p/styles.css
new file mode 100644 (file)
index 0000000..c808561
--- /dev/null
@@ -0,0 +1,17 @@
+.attoh5poverlay {
+    display: none;
+}
+.editor_atto_content_wrap .attoh5poverlay {
+    display: block;
+    position: absolute;
+    cursor: pointer;
+    top: 0;
+    left: 0;
+    bottom: 0;
+    width: 100%;
+    height: 100%;
+    background: url([[pix:atto_h5p|icon]]) center center / 100px auto no-repeat #adb5bd;
+}
+.h5p-embed-placeholder .attoh5poverlay + br {
+    display: none;
+}
\ No newline at end of file
diff --git a/lib/editor/atto/plugins/h5p/tests/behat/h5p.feature b/lib/editor/atto/plugins/h5p/tests/behat/h5p.feature
new file mode 100644 (file)
index 0000000..1d2c920
--- /dev/null
@@ -0,0 +1,56 @@
+@editor @editor_atto @atto @atto_h5p @_switch_iframe
+Feature: Add h5ps to Atto
+  To write rich text - I need to add h5ps.
+
+  Background:
+    Given the following "courses" exist:
+      | shortname | fullname |
+      | C1        | Course 1 |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+    And the following "activities" exist:
+      | activity | name       | intro      | introformat | course | content  | contentformat | idnumber |
+      | page     | PageName1  | PageDesc1  | 1           | C1     | H5Ptest  | 1             | 1        |
+
+  @javascript
+  Scenario: Insert an embedded h5p
+    Given I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    And I navigate to "Edit settings" in current page administration
+    And I click on "Insert H5P" "button" in the "#fitem_id_page" "css_element"
+    And I set the field "Enter URL" to "https://h5p.org/h5p/embed/576651"
+    And I click on "Save H5P" "button" in the "H5P properties" "dialogue"
+    And I wait until the page is ready
+    When I click on "Save and display" "button"
+    And I switch to "h5pcontent" iframe
+    Then ".h5p-iframe" "css_element" should exist
+
+  @javascript
+  Scenario: Test an invalid url
+    Given I log in as "admin"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    And I navigate to "Edit settings" in current page administration
+    And I click on "Insert H5P" "button" in the "#fitem_id_page" "css_element"
+    And I set the field "Enter URL" to "ftp://h5p.org/h5p/embed/576651"
+    And I click on "Save H5P" "button" in the "H5P properties" "dialogue"
+    And I wait until the page is ready
+    Then I should see "Invalid URL" in the "H5P properties" "dialogue"
+
+  @javascript
+  Scenario: No embed h5p capabilities
+    Given I log in as "admin"
+    And I set the following system permissions of "Teacher" role:
+    | capability | permission |
+    | atto/h5p:addembed | Prohibit |
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "PageName1"
+    And I navigate to "Edit settings" in current page administration
+    Then "Insert H5P" "button" should not exist
\ No newline at end of file
diff --git a/lib/editor/atto/plugins/h5p/thirdpartylibs.xml b/lib/editor/atto/plugins/h5p/thirdpartylibs.xml
new file mode 100644 (file)
index 0000000..e7f5237
--- /dev/null
@@ -0,0 +1,10 @@
+<?xml version="1.0"?>
+<libraries>
+  <library>
+    <location>js/h5p-resizer.js</location>
+    <name>H5P Resizer</name>
+    <license>GPL-3.0</license>
+    <version>1.23.1</version>
+    <licenseversion></licenseversion>
+  </library>
+</libraries>
\ No newline at end of file
diff --git a/lib/editor/atto/plugins/h5p/version.php b/lib/editor/atto/plugins/h5p/version.php
new file mode 100644 (file)
index 0000000..62077db
--- /dev/null
@@ -0,0 +1,29 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Atto text editor integration version file.
+ *
+ * @package    atto_h5p
+ * @copyright  2019 Bas Brands  <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$plugin->version   = 2019081900;        // The current plugin version (Date: YYYYMMDDXX).
+$plugin->requires  = 2019051100;        // Requires this Moodle version.
+$plugin->component = 'atto_h5p';  // Full name of the plugin (used for diagnostics).
diff --git a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js
new file mode 100644 (file)
index 0000000..7defc49
Binary files /dev/null and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-debug.js differ
diff --git a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js
new file mode 100644 (file)
index 0000000..ecb5221
Binary files /dev/null and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button-min.js differ
diff --git a/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js
new file mode 100644 (file)
index 0000000..7defc49
Binary files /dev/null and b/lib/editor/atto/plugins/h5p/yui/build/moodle-atto_h5p-button/moodle-atto_h5p-button.js differ
diff --git a/lib/editor/atto/plugins/h5p/yui/src/button/build.json b/lib/editor/atto/plugins/h5p/yui/src/button/build.json
new file mode 100644 (file)
index 0000000..a7d3bda
--- /dev/null
@@ -0,0 +1,10 @@
+{
+  "name": "moodle-atto_h5p-button",
+  "builds": {
+    "moodle-atto_h5p-button": {
+      "jsfiles": [
+        "button.js"
+      ]
+    }
+  }
+}
diff --git a/lib/editor/atto/plugins/h5p/yui/src/button/js/button.js b/lib/editor/atto/plugins/h5p/yui/src/button/js/button.js
new file mode 100644 (file)
index 0000000..8ba6225
--- /dev/null
@@ -0,0 +1,315 @@
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/*
+ * @package    atto_h5p
+ * @copyright  2019 Bas Brands  <bas@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * @module moodle-atto_h5p-button
+ */
+
+/**
+ * Atto h5p content tool.
+ *
+ * @namespace M.atto_h5p
+ * @class Button
+ * @extends M.editor_atto.EditorPlugin
+ */
+
+var CSS = {
+        INPUTALT: 'atto_h5p_altentry',
+        INPUTSUBMIT: 'atto_h5p_urlentrysubmit',
+        INPUTH5PURL: 'atto_h5p_url',
+        URLWARNING: 'atto_h5p_warning'
+    },
+    SELECTORS = {
+        INPUTH5PURL: '.' + CSS.INPUTH5PURL
+    },
+
+    COMPONENTNAME = 'atto_h5p',
+
+    TEMPLATE = '' +
+            '<form class="atto_form">' +
+                '<div class="mb-4">' +
+                    '<label for="{{elementid}}_{{CSS.INPUTH5PURL}}">{{get_string "enterurl" component}}</label>' +
+                    '<div style="display:none" role="alert" class="alert alert-warning mb-1 {{CSS.URLWARNING}}">' +
+                        '{{get_string "invalidh5purl" component}}' +
+                    '</div>' +
+                    '<input class="form-control fullwidth {{CSS.INPUTH5PURL}}" type="url" ' +
+                    'id="{{elementid}}_{{CSS.INPUTH5PURL}}" size="32"/>' +
+                '</div>' +
+                '<div class="text-center">' +
+                '<button class="btn btn-secondary {{CSS.INPUTSUBMIT}}" type="submit">' + '' +
+                    '{{get_string "saveh5p" component}}</button>' +
+                '</div>' +
+            '</form>',
+
+        H5PTEMPLATE = '' +
+            '<div class="position-relative h5p-embed-placeholder">' +
+                '<div class="attoh5poverlay"></div>' +
+                '<iframe id="h5pcontent" class="h5pcontent" src="{{url}}/embed" ' +
+                    'width="100%" height="637" frameborder="0"' +
+                    'allowfullscreen="{{allowfullscreen}}" allowmedia="{{allowmedia}}">' +
+                '</iframe>' +
+                '<script src="' + M.cfg.wwwroot + '/lib/editor/atto/plugins/h5p/js/h5p-resizer.js"' +
+                    'charset="UTF-8"></script>' +
+                '</div>' +
+            '</div>' +
+            '<p><br></p>';
+
+Y.namespace('M.atto_h5p').Button = Y.Base.create('button', Y.M.editor_atto.EditorPlugin, [], {
+    /**
+     * A reference to the current selection at the time that the dialogue
+     * was opened.
+     *
+     * @property _currentSelection
+     * @type Range
+     * @private
+     */
+    _currentSelection: null,
+
+    /**
+     * A reference to the currently open form.
+     *
+     * @param _form
+     * @type Node
+     * @private
+     */
+    _form: null,
+
+    /**
+     * A reference to the currently selected H5P placeholder.
+     *
+     * @param _form
+     * @type Node
+     * @private
+     */
+    _placeholderH5P: null,
+
+    initializer: function() {
+        var allowedmethods = this.get('allowedmethods');
+        if (allowedmethods !== 'embed') {
+            // Plugin not available here.
+            return;
+        }
+
+        this.addButton({
+            icon: 'icon',
+            iconComponent: 'atto_h5p',
+            callback: this._displayDialogue,
+            tags: '.attoh5poverlay',
+            tagMatchRequiresAll: false
+        });
+
+        this.editor.delegate('dblclick', this._handleDblClick, '.attoh5poverlay', this);
+        this.editor.delegate('click', this._handleClick, '.attoh5poverlay', this);
+    },
+
+    /**
+     * Handle a double click on a H5P Placeholder.
+     *
+     * @method _handleDblClick
+     * @private
+     */
+    _handleDblClick: function() {
+        this._displayDialogue();
+    },
+
+    /**
+     * Handle a click on a H5P Placeholder.
+     *
+     * @method _handleClick
+     * @param {EventFacade} e
+     * @private
+     */
+    _handleClick: function(e) {
+        var h5pplaceholder = e.target;
+
+        var selection = this.get('host').getSelectionFromNode(h5pplaceholder);
+        if (this.get('host').getSelection() !== selection) {
+            this.get('host').setSelection(selection);
+        }
+    },
+
+    /**
+     * Display the h5p editing tool.
+     *
+     * @method _displayDialogue
+     * @private
+     */
+    _displayDialogue: function() {
+        // Store the current selection.
+        this._currentSelection = this.get('host').getSelection();
+        this._placeholderH5P = this._getH5PIframe();
+
+        if (this._currentSelection === false) {
+            return;
+        }
+        var dialogue = this.getDialogue({
+            headerContent: M.util.get_string('h5pproperties', COMPONENTNAME),
+            width: 'auto',
+            focusAfterHide: true,
+            focusOnShowSelector: SELECTORS.INPUTH5PURL
+        });
+
+        // Set the dialogue content, and then show the dialogue.
+        dialogue.set('bodyContent', this._getDialogueContent())
+            .show();
+    },
+
+    /**
+     * Get the H5P iframe
+     *
+     * @method _resolveH5P
+     * @return {Node} The H5P iframe selected.
+     * @private
+     */
+    _getH5PIframe: function() {
+        var selectednode = this.get('host').getSelectionParentNode();
+        if (!selectednode) {
+            return;
+        }
+        return Y.one(selectednode).one('iframe.h5pcontent');
+    },
+
+
+    /**
+     * Return the dialogue content for the tool, attaching any required
+     * events.
+     *
+     * @method _getDialogueContent
+     * @return {Node} The content to place in the dialogue.
+     * @private
+     */
+    _getDialogueContent: function() {
+        var template = Y.Handlebars.compile(TEMPLATE),
+            content = Y.Node.create(template({
+                elementid: this.get('host').get('elementid'),
+                CSS: CSS,
+                component: COMPONENTNAME
+            }));
+
+        this._form = content;
+
+        if (this._placeholderH5P) {
+            var oldurl = this._placeholderH5P.getAttribute('src');
+            this._form.one(SELECTORS.INPUTH5PURL).setAttribute('value', oldurl);
+        }
+
+        this._form.one('.' + CSS.INPUTSUBMIT).on('click', this._setH5P, this);
+
+        return content;
+    },
+
+    /**
+     * Set the h5p in the contenteditable.
+     *
+     * @method _setH5P
+     * @param {EventFacade} e
+     * @private
+     */
+    _setH5P: function(e) {
+        var form = this._form,
+            url = form.one(SELECTORS.INPUTH5PURL).get('value'),
+            h5phtml,
+            host = this.get('host');
+
+        e.preventDefault();
+
+        // Check if there are any issues.
+        if (this._updateWarning()) {
+            return;
+        }
+
+        // Focus on the editor in preparation for inserting the h5p.
+        host.focus();
+
+        // If a H5P placeholder was selected we only update the placeholder.
+        if (this._placeholderH5P) {
+            this._placeholderH5P.setAttribute('src', url);
+
+        } else if (url !== '') {
+
+            host.setSelection(this._currentSelection);
+
+            var template = Y.Handlebars.compile(H5PTEMPLATE);
+            h5phtml = template({
+                url: url,
+                allowfullscreen: 'allowfullscreen',
+                allowmedia: 'geolocation *; microphone *; camera *; midi *; encrypted-media *'
+            });
+
+            this.get('host').insertContentAtFocusPoint(h5phtml);
+
+            this.markUpdated();
+        }
+
+        this.getDialogue({
+            focusAfterHide: null
+        }).hide();
+    },
+
+    /**
+     * Check if this could be a h5p URL.
+     *
+     * @method _updateWarning
+     * @param {String} str
+     * @return {boolean} whether a warning should be displayed.
+     * @private
+     */
+    _validURL: function(str) {
+        var pattern = new RegExp('^(https?:\\/\\/)?' + // Protocol.
+            '((([a-z\\d]([a-z\\d-]*[a-z\\d])*)\\.)+[a-z]{2,}|' + // Domain name.
+            '((\\d{1,3}\\.){3}\\d{1,3}))' + // OR ip (v4) address.
+            '(\\:\\d+)?(\\/[-a-z\\d%_.~+]*)*'); // Port and path.
+        return !!pattern.test(str);
+    },
+
+    /**
+     * Update the url warning.
+     *
+     * @method _updateWarning
+     * @return {boolean} whether a warning should be displayed.
+     * @private
+     */
+    _updateWarning: function() {
+        var form = this._form,
+            state = true,
+            url = form.one('.' + CSS.INPUTH5PURL).get('value');
+        if (this._validURL(url)) {
+            form.one('.' + CSS.URLWARNING).setStyle('display', 'none');
+            state = false;
+        } else {
+            form.one('.' + CSS.URLWARNING).setStyle('display', 'block');
+            state = true;
+        }
+        return state;
+    }
+}, {
+    ATTRS: {
+        /**
+         * The allowedmethods of adding h5p content.
+         *
+         * @attribute allowedmethods
+         * @type String
+         */
+        allowedmethods: {
+            value: null
+        }
+    }
+});
diff --git a/lib/editor/atto/plugins/h5p/yui/src/button/meta/button.json b/lib/editor/atto/plugins/h5p/yui/src/button/meta/button.json
new file mode 100644 (file)
index 0000000..e1cd317
--- /dev/null
@@ -0,0 +1,7 @@
+{
+    "moodle-atto_h5p-button": {
+        "requires": [
+            "moodle-editor_atto-plugin"
+        ]
+    }
+}
index 0c083a6..4fe02e9 100644 (file)
@@ -36,7 +36,7 @@ if ($ADMIN->fulltree) {
 style1 = title, bold, italic
 list = unorderedlist, orderedlist
 links = link
-files = image, media, recordrtc, managefiles
+files = image, media, recordrtc, managefiles, h5p
 style2 = underline, strike, subscript, superscript
 align = align
 indent = indent
index 3596c5e..90b3f46 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019052000;        // The current plugin version (Date: YYYYMMDDXX).
+$plugin->version   = 2019090900;        // The current plugin version (Date: YYYYMMDDXX).
 $plugin->requires  = 2019051100;        // Requires this Moodle version.
 $plugin->component = 'editor_atto';  // Full name of the plugin (used for diagnostics).
index dbf2520..3daebae 100644 (file)
@@ -1628,3 +1628,14 @@ function restrict_php_version_72(&$result) {
 function restrict_php_version_73(&$result) {
     return restrict_php_version($result, '7.3');
 }
+
+/**
+ * Check if the current PHP version is greater than or equal to
+ * PHP version 7.4.
+ *
+ * @param object $result an environment_results instance
+ * @return bool result of version check
+ */
+function restrict_php_version_74(&$result) {
+    return restrict_php_version($result, '7.4');
+}
index ca39e81..2dd9b33 100644 (file)
@@ -2470,14 +2470,16 @@ require(["core/event", "jquery"], function(Event, $) {
 ';
                 }
             }
+            // This handles both randomised (MDL-65217) and non-randomised IDs.
+            $errorid = preg_replace('/^id_/', 'id_error_', $this->_attributes['id']);
             $validateJS .= '
       ret = validate_' . $this->_formName . '_' . $escapedElementName.'(frm.elements[\''.$elementName.'\'], \''.$escapedElementName.'\') && ret;
       if (!ret && !first_focus) {
         first_focus = true;
         Y.use(\'moodle-core-event\', function() {
             Y.Global.fire(M.core.globalEvents.FORM_ERROR, {formid: \'' . $this->_attributes['id'] . '\',
-                                                           elementid: \'id_error_' . $escapedElementName . '\'});
-            document.getElementById(\'id_error_' . $escapedElementName . '\').focus();
+                                                           elementid: \'' . $errorid. '\'});
+            document.getElementById(\'' . $errorid . '\').focus();
         });
       }
 ';
index a668f23..f9130cd 100644 (file)
@@ -2589,13 +2589,14 @@ class grade_category extends grade_object {
      */
     public function set_hidden($hidden, $cascade=false) {
         $this->load_grade_item();
-        //this hides the associated grade item (the course total)
-        $this->grade_item->set_hidden($hidden, $cascade);
         //this hides the category itself and everything it contains
         parent::set_hidden($hidden, $cascade);
 
         if ($cascade) {
 
+            // This hides the associated grade item (the course/category total).
+            $this->grade_item->set_hidden($hidden, $cascade);
+
             if ($children = grade_item::fetch_all(array('categoryid'=>$this->id))) {
 
                 foreach ($children as $child) {
@@ -2619,9 +2620,7 @@ class grade_category extends grade_object {
             if ($category_array && array_key_exists($this->parent, $category_array)) {
                 $category = $category_array[$this->parent];
                 //call set_hidden on the category regardless of whether it is hidden as its parent might be hidden
-                //if($category->is_hidden()) {
-                    $category->set_hidden($hidden, false);
-                //}
+                $category->set_hidden($hidden, false);
             }
         }
     }
index 715ace9..9cb8385 100644 (file)
@@ -681,9 +681,7 @@ class grade_item extends grade_object {
             if ($category_array && array_key_exists($this->categoryid, $category_array)) {
                 $category = $category_array[$this->categoryid];
                 //call set_hidden on the category regardless of whether it is hidden as its parent might be hidden
-                //if($category->is_hidden()) {
-                    $category->set_hidden($hidden, false);
-                //}
+                $category->set_hidden($hidden, false);
             }
         }
     }
index 86201f1..03a6fe1 100644 (file)
@@ -63,6 +63,7 @@ class core_grade_category_testcase extends grade_base_testcase {
         $this->sub_test_grade_category_is_hidden();
         $this->sub_test_grade_category_set_hidden();
         $this->sub_test_grade_category_can_control_visibility();
+        $this->sub_test_grade_category_total_visibility();
 
         // This won't work until MDL-11837 is complete.
         // $this->sub_test_grade_category_generate_grades();
@@ -784,7 +785,7 @@ class core_grade_category_testcase extends grade_base_testcase {
     protected function sub_test_grade_category_set_hidden() {
         $category = new grade_category($this->grade_categories[0]);
         $this->assertTrue(method_exists($category, 'set_hidden'));
-        $category->set_hidden(1);
+        $category->set_hidden(1, true);
         $category->load_grade_item();
         $this->assertEquals(true, $category->grade_item->is_hidden());
     }
@@ -858,4 +859,43 @@ class core_grade_category_testcase extends grade_base_testcase {
         $this->assertFalse(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MIN));
         $this->assertFalse(grade_category::aggregation_uses_extracredit(GRADE_AGGREGATE_MODE));
     }
+
+    /**
+     * Test for category total visibility.
+     */
+    protected function sub_test_grade_category_total_visibility() {
+        // 15 is a manual grade item in grade_categories[5].
+        $category = new grade_category($this->grade_categories[5], true);
+        $gradeitem = new grade_item($this->grade_items[15], true);
+
+        // Hide grade category.
+        $category->set_hidden(true, true);
+        $this->assertTrue($category->is_hidden());
+        // Category total is hidden.
+        $categorytotal = $category->get_grade_item();
+        $this->assertTrue($categorytotal->is_hidden());
+        // Manual grade is hidden.
+        $gradeitem->update_from_db();
+        $this->assertTrue($gradeitem->is_hidden());
+
+        // Unhide manual grade item.
+        $gradeitem->set_hidden(false);
+        $this->assertFalse($gradeitem->is_hidden());
+        // Category is unhidden.
+        $category->update_from_db();
+        $this->assertFalse($category->is_hidden());
+        // Category total remain hidden.
+        $categorytotal = $category->get_grade_item();
+        $this->assertTrue($categorytotal->is_hidden());
+
+        // Edit manual grade item.
+        $this->assertFalse($gradeitem->is_locked());
+        $gradeitem->set_locked(true);
+        $gradeitem->update_from_db();
+        $this->assertTrue($gradeitem->is_locked());
+        // Category total should still be hidden.
+        $category->update_from_db();
+        $categorytotal = $category->get_grade_item();
+        $this->assertTrue($categorytotal->is_hidden());
+    }
 }
index a189716..3828010 100644 (file)
@@ -1619,3 +1619,22 @@ function grade_floats_different($f1, $f2) {
 function grade_floats_equal($f1, $f2) {
     return (grade_floatval($f1) === grade_floatval($f2));
 }
+
+/**
+ * Get the most appropriate grade date for a grade item given the user that the grade relates to.
+ *
+ * @param \stdClass $grade
+ * @param \stdClass $user
+ * @return int
+ */
+function grade_get_date_for_user_grade(\stdClass $grade, \stdClass $user): int {
+    // The `datesubmitted` is the time that the grade was created.
+    // The `dategraded` is the time that it was modified or overwritten.
+    // If the grade was last modified by the user themselves use the date graded.
+    // Otherwise use date submitted.
+    if ($grade->usermodified == $user->id || empty($grade->datesubmitted)) {
+        return $grade->dategraded;
+    } else {
+        return $grade->datesubmitted;
+    }
+}
index ea2f517..7d28821 100644 (file)
@@ -9090,11 +9090,11 @@ function mtrace($string, $eol="\n", $sleep=0) {
         $fn($string, $eol);
         return;
     } else if (defined('STDOUT') && !PHPUNIT_TEST && !defined('BEHAT_TEST')) {
-        fwrite(STDOUT, $string.$eol);
-
         // We must explicitly call the add_line function here.
         // Uses of fwrite to STDOUT are not picked up by ob_start.
-        \core\task\logmanager::add_line("{$string}{$eol}");
+        if ($output = \core\task\logmanager::add_line("{$string}{$eol}")) {
+            fwrite(STDOUT, $output);
+        }
     } else {
         echo $string . $eol;
     }
index fa0cc06..88bf930 100644 (file)
@@ -2241,6 +2241,7 @@ class core_renderer extends renderer_base {
         if ($rating->user_can_view_aggregate()) {
 
             $aggregatelabel = $ratingmanager->get_aggregate_label($rating->settings->aggregationmethod);
+            $aggregatelabel = html_writer::tag('span', $aggregatelabel, array('class'=>'rating-aggregate-label'));
             $aggregatestr   = $rating->get_aggregate_string();
 
             $aggregatehtml  = html_writer::tag('span', $aggregatestr, array('id' => 'ratingaggregate'.$rating->itemid, 'class' => 'ratingaggregate')).' ';
@@ -2251,17 +2252,16 @@ class core_renderer extends renderer_base {
             }
             $aggregatehtml .= html_writer::tag('span', $countstr, array('id'=>"ratingcount{$rating->itemid}", 'class' => 'ratingcount')).' ';
 
-            $ratinghtml .= html_writer::tag('span', $aggregatelabel, array('class'=>'rating-aggregate-label'));
             if ($rating->settings->permissions->viewall && $rating->settings->pluginpermissions->viewall) {
 
                 $nonpopuplink = $rating->get_view_ratings_url();
                 $popuplink = $rating->get_view_ratings_url(true);
 
                 $action = new popup_action('click', $popuplink, 'ratings', array('height' => 400, 'width' => 600));
-                $ratinghtml .= $this->action_link($nonpopuplink, $aggregatehtml, $action);
-            } else {
-                $ratinghtml .= $aggregatehtml;
+                $aggregatehtml = $this->action_link($nonpopuplink, $aggregatehtml, $action);
             }
+
+            $ratinghtml .= html_writer::tag('span', $aggregatelabel . $aggregatehtml, array('class' => 'rating-aggregate-container'));
         }
 
         $formstart = null;
index 18bfe43..b1c9806 100644 (file)
@@ -1635,27 +1635,9 @@ EOF;
      *
      * @Then /^(?:|I )pause(?:| scenario execution)$/
      */
-    public function i_pause_scenario_executon() {
-        global $CFG;
-
-        $posixexists = function_exists('posix_isatty');
-
-        // Make sure this step is only used with interactive terminal (if detected).
-        if ($posixexists && !@posix_isatty(STDOUT)) {
-            $session = $this->getSession();
-            throw new ExpectationException('Break point should only be used with interative terminal.', $session);
-        }
-
-        // Windows don't support ANSI code by default, but with ANSICON.
-        $isansicon = getenv('ANSICON');
-        if (($CFG->ostype === 'WINDOWS') && empty($isansicon)) {
-            fwrite(STDOUT, "Paused. Press Enter/Return to continue.");
-            fread(STDIN, 1024);
-        } else {
-            fwrite(STDOUT, "\033[s\n\033[0;93mPaused. Press \033[1;31mEnter/Return\033[0;93m to continue.\033[0m");
-            fread(STDIN, 1024);
-            fwrite(STDOUT, "\033[2A\033[u\033[2B");
-        }
+    public function i_pause_scenario_execution() {
+        $message = "<colour:lightYellow>Paused. Press <colour:lightRed>Enter/Return<colour:lightYellow> to continue.";
+        behat_util::pause($this->getSession(), $message);
     }
 
     /**
index 8cda5a7..46b3c87 100644 (file)
@@ -500,11 +500,7 @@ class behat_hooks extends behat_base {
             throw new coding_exception("Step '" . $scope->getStep()->getText() . "'' is undefined.");
         }
 
-        // Save the page content if the step failed.
-        if (!empty($CFG->behat_faildump_path) &&
-            $scope->getTestResult()->getResultCode() === Behat\Testwork\Tester\Result\TestResult::FAILED) {
-            $this->take_contentdump($scope);
-        }
+        $isfailed = $scope->getTestResult()->getResultCode() === Behat\Testwork\Tester\Result\TestResult::FAILED;
 
         // Abort any open transactions to prevent subsequent tests hanging.
         // This does the same as abort_all_db_transactions(), but doesn't call error_log() as we don't
@@ -516,17 +512,30 @@ class behat_hooks extends behat_base {
             }
         }
 
+        if ($isfailed && !empty($CFG->behat_faildump_path)) {
+            // Save the page content (html).
+            $this->take_contentdump($scope);
+
+            if ($this->running_javascript()) {
+                // Save a screenshot.
+                $this->take_screenshot($scope);
+            }
+        }
+
+        if ($isfailed && !empty($CFG->behat_pause_on_fail)) {
+            $exception = $scope->getTestResult()->getException();
+            $message = "<colour:lightRed>Scenario failed. ";
+            $message .= "<colour:lightYellow>Paused for inspection. Press <colour:lightRed>Enter/Return<colour:lightYellow> to continue.<newline>";
+            $message .= "<colour:lightRed>Exception follows:<newline>";
+            $message .= trim($exception->getMessage());
+            behat_util::pause($this->getSession(), $message);
+        }
+
         // Only run if JS.
         if (!$this->running_javascript()) {
             return;
         }
 
-        // Save a screenshot if the step failed.
-        if (!empty($CFG->behat_faildump_path) &&
-            $scope->getTestResult()->getResultCode() === Behat\Testwork\Tester\Result\TestResult::FAILED) {
-            $this->take_screenshot($scope);
-        }
-
         try {
             $this->wait_for_pending_js();
             self::$currentstepexception = null;
index 0e6eebc..8401916 100644 (file)
@@ -151,4 +151,70 @@ class core_gradelib_testcase extends advanced_testcase {
         // Now because of the failure, two things need updating.
         $this->assertEquals(2, $DB->count_records('grade_items', ['courseid' => $course->id, 'needsupdate' => 1]));
     }
+
+    /**
+     * Tests for the grade_get_date_for_user_grade function.
+     *
+     * @dataProvider grade_get_date_for_user_grade_provider
+     * @param stdClass $grade
+     * @param stdClass $user
+     * @param int $expected
+     */
+    public function test_grade_get_date_for_user_grade(stdClass $grade, stdClass $user, int $expected): void {
+        $this->assertEquals($expected, grade_get_date_for_user_grade($grade, $user));
+    }
+
+    /**
+     * Data provider for tests of the grade_get_date_for_user_grade function.
+     *
+     * @return array
+     */
+    public function grade_get_date_for_user_grade_provider(): array {
+        $u1 = (object) [
+            'id' => 42,
+        ];
+        $u2 = (object) [
+            'id' => 930,
+        ];
+
+        $d1 = 1234567890;
+        $d2 = 9876543210;
+
+        $g1 = (object) [
+            'usermodified' => $u1->id,
+            'dategraded' => $d1,
+            'datesubmitted' => $d2,
+        ];
+        $g2 = (object) [
+            'usermodified' => $u1->id,
+            'dategraded' => $d1,
+            'datesubmitted' => 0,
+        ];
+
+        return [
+            'If the user is the last person to have modified the grade_item then show the date that it was graded' => [
+                $g1,
+                $u1,
+                $d1,
+            ],
+            'If the user is not the last person to have modified the grade_item, ' .
+            'and there is no submission date, then show the date that it was submitted' => [
+                $g1,
+                $u2,
+                $d2,
+            ],
+            'If the user is not the last person to have modified the grade_item, ' .
+            'but there is no submission date, then show the date that it was graded' => [
+                $g2,
+                $u2,
+                $d1,
+            ],
+            'If the user is the last person to have modified the grade_item, ' .
+            'and there is no submission date, then still show the date that it was graded' => [
+                $g2,
+                $u1,
+                $d1,
+            ],
+        ];
+    }
 }
index 4511766..e804dff 100644 (file)
@@ -48,6 +48,7 @@ class core_analytics_indicators_testcase extends advanced_testcase {
      * @return void
      */
     public function test_core_indicators() {
+        global $DB;
 
         $this->preventResetByRollback();
         $this->resetAfterTest(true);
@@ -122,6 +123,65 @@ class core_analytics_indicators_testcase extends advanced_testcase {
         $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
         $this->assertEquals($indicator::get_min_value(), $values[$user2->id][0]);
 
+        // Test any course access.
+        $course = $this->getDataGenerator()->create_course($params);
+        $coursecontext = \context_course::instance($course->id);
+        $this->getDataGenerator()->enrol_user($user1->id, $course->id);
+
+        $indicator = new \core\analytics\indicator\any_course_access();
+
+        $sampleids = array($user1->id => $user1->id);
+        $data = array($user1->id => array(
+            'course' => $course,
+            'user' => $user1
+        ));
+        $indicator->add_sample_data($data);
+        $analysable = new \core_analytics\course($course);
+
+        // Min value if no user_lastaccess records.
+        $indicator->fill_per_analysable_caches($analysable);
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere');
+        $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', time() - 10, time() + 10);
+        $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
+
+        // Any access is enough if no time restrictions.
+        $DB->insert_record('user_lastaccess', array('userid' => $user1->id,
+            'courseid' => $course->id, 'timeaccess' => time() - 1));
+        $indicator->fill_per_analysable_caches($analysable);
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere');
+        $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
+
+        // Min value if the existing records are old.
+        $indicator->fill_per_analysable_caches($analysable);
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', time(), time() + 10);
+        $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', time());
+        $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
+
+        // Max value if the existing records are prior to end.
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', time() - 10, time());
+        $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', false, time());
+        $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', false, time());
+        $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
+
+        // Max value if no end time and existing user_lastaccess record.
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', time() - 10);
+        $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
+
+        // Rely on logs if the last time access is after the end time.
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', false, time() - 10);
+        // Min value if no logs are found.
+        $this->assertEquals($indicator::get_min_value(), $values[$user1->id][0]);
+
+        \logstore_standard\event\unittest_executed::create(
+            array('context' => \context_course::instance($course->id), 'userid' => $user1->id))->trigger();
+        // Max value if logs are found before the end time.
+        list($values, $unused) = $indicator->calculate($sampleids, 'notrelevanthere', false, time() + 10);
+        $this->assertEquals($indicator::get_max_value(), $values[$user1->id][0]);
+
         // Test any write action.
         $course1 = $this->getDataGenerator()->create_course();
         $coursecontext1 = \context_course::instance($course1->id);