Merge branch 'MDL-66166' of https://github.com/brendanheywood/moodle
authorAndrew Nicols <andrew@nicols.co.uk>
Wed, 9 Oct 2019 03:14:09 +0000 (11:14 +0800)
committerAndrew Nicols <andrew@nicols.co.uk>
Wed, 9 Oct 2019 03:14:09 +0000 (11:14 +0800)
358 files changed:
admin/registration/confirmregistration.php
admin/registration/forms.php
admin/registration/index.php
admin/registration/renewregistration.php
admin/renderer.php
admin/settings/appearance.php
admin/settings/plugins.php
admin/tool/analytics/classes/output/insights_report.php [moved from admin/tool/analytics/classes/output/effectiveness_report.php with 78% similarity]
admin/tool/analytics/classes/output/models_list.php
admin/tool/analytics/classes/output/renderer.php
admin/tool/analytics/lang/en/tool_analytics.php
admin/tool/analytics/model.php
admin/tool/analytics/templates/insights_report.mustache [moved from admin/tool/analytics/templates/effectiveness_report.mustache with 90% similarity]
admin/tool/lp/classes/output/manage_competency_frameworks_page.php
admin/tool/mobile/classes/api.php
admin/tool/mobile/classes/external.php
admin/tool/mobile/lang/en/tool_mobile.php
admin/tool/mobile/settings.php
admin/tool/mobile/tests/externallib_test.php
admin/tool/monitor/lib.php
admin/tool/usertours/classes/manager.php
analytics/classes/action.php [new file with mode: 0644]
analytics/classes/bulk_action.php [new file with mode: 0644]
analytics/classes/default_bulk_actions.php [new file with mode: 0644]
analytics/classes/insights_generator.php
analytics/classes/local/target/base.php
analytics/classes/manager.php
analytics/classes/model.php
analytics/classes/prediction.php
analytics/classes/prediction_action.php
analytics/classes/predictor.php
analytics/classes/stats.php
analytics/tests/behat/manage_models.feature
analytics/tests/manager_test.php
analytics/tests/prediction_actions_test.php
analytics/tests/prediction_test.php
analytics/tests/privacy_test.php
analytics/tests/stats_test.php
analytics/upgrade.txt
auth/oauth2/classes/api.php
auth/oauth2/tests/api_test.php
backup/controller/backup_controller.class.php
backup/moodle2/backup_plugin.class.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_plugin.class.php
backup/moodle2/restore_stepslib.php
backup/util/plan/restore_structure_step.class.php
badges/classes/assertion.php
blocks/social_activities/tests/behat/behat_block_social_activities.php
cache/stores/redis/addinstanceform.php
cache/stores/redis/lang/en/cachestore_redis.php
cache/stores/redis/lib.php
cache/stores/redis/tests/compressor_test.php [new file with mode: 0644]
cache/upgrade.txt
calendar/lib.php
calendar/tests/lib_test.php
comment/classes/external.php
comment/lib.php
comment/tests/externallib_test.php
comment/upgrade.txt
course/classes/analytics/target/course_competencies.php
course/classes/analytics/target/course_completion.php
course/classes/analytics/target/course_dropout.php
course/classes/analytics/target/course_enrolments.php
course/classes/analytics/target/course_gradetopass.php
course/classes/analytics/target/no_recent_accesses.php
course/classes/analytics/target/no_teaching.php
course/externallib.php
course/lib.php
course/tests/targets_test.php [moved from lib/tests/targets_test.php with 75% similarity]
customfield/tests/api_test.php
customfield/tests/category_controller_test.php
customfield/tests/data_controller_test.php
customfield/tests/field_controller_test.php
customfield/tests/generator_test.php
customfield/tests/privacy_test.php
enrol/tests/enrollib_test.php
filter/h5p/classes/privacy/provider.php [new file with mode: 0644]
filter/h5p/filter.php [new file with mode: 0644]
filter/h5p/lang/en/filter_h5p.php [new file with mode: 0644]
filter/h5p/settings.php [new file with mode: 0644]
filter/h5p/tests/filter_test.php [new file with mode: 0644]
filter/h5p/version.php [new file with mode: 0644]
index.php
install/lang/nl/install.php
lang/en/admin.php
lang/en/analytics.php
lang/en/countries.php
lang/en/deprecated.txt
lang/en/hub.php
lang/en/mimetypes.php
lib/amd/build/drawer.min.js [new file with mode: 0644]
lib/amd/build/drawer.min.js.map [new file with mode: 0644]
lib/amd/build/drawer_events.min.js [new file with mode: 0644]
lib/amd/build/drawer_events.min.js.map [new file with mode: 0644]
lib/amd/build/str.min.js
lib/amd/build/str.min.js.map
lib/amd/build/templates.min.js
lib/amd/build/templates.min.js.map
lib/amd/src/drawer.js [new file with mode: 0644]
lib/amd/src/drawer_events.js [new file with mode: 0644]
lib/amd/src/str.js
lib/amd/src/templates.js
lib/classes/dataformat/base.php
lib/classes/dataformat/spout_base.php
lib/classes/filetypes.php
lib/classes/hub/api.php
lib/classes/hub/registration.php
lib/classes/hub/site_registration_form.php
lib/classes/hub/site_unregistration_form.php [deleted file]
lib/classes/output/icon_system_fontawesome.php
lib/classes/persistent.php
lib/classes/plugin_manager.php
lib/classes/plugininfo/mlbackend.php
lib/classes/useragent.php
lib/db/services.php
lib/db/upgrade.php
lib/db/upgradelib.php
lib/dml/tests/dml_table_test.php
lib/enrollib.php
lib/filestorage/tests/file_storage_test.php
lib/filestorage/tests/file_system_filedir_test.php
lib/filestorage/tests/file_system_test.php
lib/form/templates/element-select-inline.mustache
lib/form/templates/element-select.mustache
lib/form/templates/element-selectgroups-inline.mustache
lib/form/templates/element-selectgroups.mustache
lib/form/templates/element-selectwithlink.mustache
lib/mlbackend/php/classes/processor.php
lib/mlbackend/php/upgrade.txt [new file with mode: 0644]
lib/mlbackend/python/classes/processor.php
lib/mlbackend/python/lang/en/mlbackend_python.php
lib/mlbackend/python/settings.php [new file with mode: 0644]
lib/mlbackend/python/upgrade.txt [new file with mode: 0644]
lib/mlbackend/python/version.php
lib/modinfolib.php
lib/moodlelib.php
lib/outputcomponents.php
lib/outputrenderers.php
lib/pagelib.php
lib/templates/action_menu_trigger.mustache
lib/templates/drawer.mustache [new file with mode: 0644]
lib/templates/filemanager_processexistingfile.mustache
lib/templates/full_header.mustache
lib/templates/single_button.mustache
lib/tests/modinfolib_test.php
lib/tests/tablelib_test.php
lib/tests/upgradelib_test.php
lib/upgrade.txt
media/player/videojs/amd/build/Youtube-lazy.min.js
media/player/videojs/amd/build/Youtube-lazy.min.js.map
media/player/videojs/amd/build/document.min.js [new file with mode: 0644]
media/player/videojs/amd/build/document.min.js.map [new file with mode: 0644]
media/player/videojs/amd/build/video-lazy.min.js
media/player/videojs/amd/build/video-lazy.min.js.map
media/player/videojs/amd/build/videojs-flash-lazy.min.js
media/player/videojs/amd/build/videojs-flash-lazy.min.js.map
media/player/videojs/amd/build/window.min.js [new file with mode: 0644]
media/player/videojs/amd/build/window.min.js.map [new file with mode: 0644]
media/player/videojs/amd/src/Youtube-lazy.js
media/player/videojs/amd/src/document.js [new file with mode: 0644]
media/player/videojs/amd/src/video-lazy.js
media/player/videojs/amd/src/videojs-flash-lazy.js
media/player/videojs/amd/src/window.js [new file with mode: 0644]
media/player/videojs/classes/plugin.php
media/player/videojs/fonts/VideoJS.svg
media/player/videojs/fonts/VideoJS.ttf
media/player/videojs/fonts/VideoJS.woff
media/player/videojs/lang/en/media_videojs.php
media/player/videojs/readme_moodle.txt
media/player/videojs/settings.php
media/player/videojs/styles.css
media/player/videojs/tests/player_test.php
media/player/videojs/thirdpartylibs.xml
media/player/videojs/videojs/lang/ar.js
media/player/videojs/videojs/lang/ar.json [new file with mode: 0644]
media/player/videojs/videojs/lang/ba.js
media/player/videojs/videojs/lang/ba.json [new file with mode: 0644]
media/player/videojs/videojs/lang/bg.js
media/player/videojs/videojs/lang/bg.json [new file with mode: 0644]
media/player/videojs/videojs/lang/ca.js
media/player/videojs/videojs/lang/ca.json [new file with mode: 0644]
media/player/videojs/videojs/lang/cs.js
media/player/videojs/videojs/lang/cs.json [new file with mode: 0644]
media/player/videojs/videojs/lang/cy.js [new file with mode: 0644]
media/player/videojs/videojs/lang/cy.json [new file with mode: 0644]
media/player/videojs/videojs/lang/da.js
media/player/videojs/videojs/lang/da.json [new file with mode: 0644]
media/player/videojs/videojs/lang/de.js
media/player/videojs/videojs/lang/de.json [new file with mode: 0644]
media/player/videojs/videojs/lang/el.js
media/player/videojs/videojs/lang/el.json [new file with mode: 0644]
media/player/videojs/videojs/lang/en.js
media/player/videojs/videojs/lang/en.json [new file with mode: 0644]
media/player/videojs/videojs/lang/es.js
media/player/videojs/videojs/lang/es.json [new file with mode: 0644]
media/player/videojs/videojs/lang/fa.js
media/player/videojs/videojs/lang/fa.json [new file with mode: 0644]
media/player/videojs/videojs/lang/fi.js
media/player/videojs/videojs/lang/fi.json [new file with mode: 0644]
media/player/videojs/videojs/lang/fr.js
media/player/videojs/videojs/lang/fr.json [new file with mode: 0644]
media/player/videojs/videojs/lang/gd.js [new file with mode: 0644]
media/player/videojs/videojs/lang/gd.json [new file with mode: 0644]
media/player/videojs/videojs/lang/gl.js
media/player/videojs/videojs/lang/gl.json [new file with mode: 0644]
media/player/videojs/videojs/lang/he.js [new file with mode: 0644]
media/player/videojs/videojs/lang/he.json [new file with mode: 0644]
media/player/videojs/videojs/lang/hr.js
media/player/videojs/videojs/lang/hr.json [new file with mode: 0644]
media/player/videojs/videojs/lang/hu.js
media/player/videojs/videojs/lang/hu.json [new file with mode: 0644]
media/player/videojs/videojs/lang/it.js
media/player/videojs/videojs/lang/it.json [new file with mode: 0644]
media/player/videojs/videojs/lang/ja.js
media/player/videojs/videojs/lang/ja.json [new file with mode: 0644]
media/player/videojs/videojs/lang/ko.js
media/player/videojs/videojs/lang/ko.json [new file with mode: 0644]
media/player/videojs/videojs/lang/nb.js
media/player/videojs/videojs/lang/nb.json [new file with mode: 0644]
media/player/videojs/videojs/lang/nl.js
media/player/videojs/videojs/lang/nl.json [new file with mode: 0644]
media/player/videojs/videojs/lang/nn.js
media/player/videojs/videojs/lang/nn.json [new file with mode: 0644]
media/player/videojs/videojs/lang/oc.js [new file with mode: 0644]
media/player/videojs/videojs/lang/oc.json [new file with mode: 0644]
media/player/videojs/videojs/lang/pl.js
media/player/videojs/videojs/lang/pl.json [new file with mode: 0644]
media/player/videojs/videojs/lang/pt-BR.js
media/player/videojs/videojs/lang/pt-BR.json [new file with mode: 0644]
media/player/videojs/videojs/lang/pt-PT.js
media/player/videojs/videojs/lang/pt-PT.json [new file with mode: 0644]
media/player/videojs/videojs/lang/ru.js
media/player/videojs/videojs/lang/ru.json [new file with mode: 0644]
media/player/videojs/videojs/lang/sk.js
media/player/videojs/videojs/lang/sk.json [new file with mode: 0644]
media/player/videojs/videojs/lang/sr.js
media/player/videojs/videojs/lang/sr.json [new file with mode: 0644]
media/player/videojs/videojs/lang/sv.js
media/player/videojs/videojs/lang/sv.json [new file with mode: 0644]
media/player/videojs/videojs/lang/tr.js
media/player/videojs/videojs/lang/tr.json [new file with mode: 0644]
media/player/videojs/videojs/lang/uk.js
media/player/videojs/videojs/lang/uk.json [new file with mode: 0644]
media/player/videojs/videojs/lang/vi.js
media/player/videojs/videojs/lang/vi.json [new file with mode: 0644]
media/player/videojs/videojs/lang/zh-CN.js
media/player/videojs/videojs/lang/zh-CN.json [new file with mode: 0644]
media/player/videojs/videojs/lang/zh-Hans.js [new file with mode: 0644]
media/player/videojs/videojs/lang/zh-Hans.json [new file with mode: 0644]
media/player/videojs/videojs/lang/zh-Hant.js [new file with mode: 0644]
media/player/videojs/videojs/lang/zh-Hant.json [new file with mode: 0644]
media/player/videojs/videojs/lang/zh-TW.js
media/player/videojs/videojs/lang/zh-TW.json [new file with mode: 0644]
media/player/videojs/videojs/video-js.swf
message/amd/build/message_drawer.min.js
message/amd/build/message_drawer.min.js.map
message/amd/src/message_drawer.js
message/templates/message_drawer.mustache
message/templates/message_drawer_view_conversation_body.mustache
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
mod/forum/amd/build/discussion_modern.min.js.map
mod/forum/amd/build/favourite_toggle.min.js
mod/forum/amd/build/favourite_toggle.min.js.map
mod/forum/amd/build/lock_toggle.min.js
mod/forum/amd/build/lock_toggle.min.js.map
mod/forum/amd/build/pin_toggle.min.js
mod/forum/amd/build/pin_toggle.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_list.js
mod/forum/amd/src/discussion_modern.js
mod/forum/amd/src/favourite_toggle.js
mod/forum/amd/src/lock_toggle.js
mod/forum/amd/src/pin_toggle.js
mod/forum/amd/src/subscription_toggle.js
mod/forum/classes/local/renderers/discussion.php
mod/forum/discuss.php
mod/forum/lang/en/forum.php
mod/forum/renderer.php
mod/forum/styles.css
mod/forum/templates/discussion_settings_body_content.mustache [new file with mode: 0644]
mod/forum/templates/discussion_settings_drawer.mustache [new file with mode: 0644]
mod/forum/templates/forum_discussion.mustache
mod/forum/templates/forum_discussion_modern.mustache
mod/forum/templates/setting_switch.mustache [new file with mode: 0644]
mod/forum/templates/settings_drawer_trigger.mustache [new file with mode: 0644]
mod/forum/templates/settings_header.mustache [new file with mode: 0644]
mod/forum/tests/managers_capability_test.php
mod/forum/tests/vaults_forum_test.php
mod/forum/tests/vaults_post_test.php
mod/forum/view.php
mod/imscp/tests/behat/display_imscp_description.feature
mod/quiz/attemptlib.php
mod/quiz/tests/behat/attempt_redo_questions.feature
mod/quiz/tests/behat/backup.feature
mod/quiz/tests/behat/behat_mod_quiz.php
phpunit.xml.dist
privacy/tests/approved_contextlist_test.php
privacy/tests/approved_userlist_test.php
privacy/tests/collection_test.php
privacy/tests/contextlist_base_test.php
privacy/tests/contextlist_collection_test.php
privacy/tests/contextlist_test.php
privacy/tests/manager_test.php
privacy/tests/moodle_content_writer_test.php
privacy/tests/writer_test.php
question/engine/datalib.php
question/engine/questionusage.php
question/engine/states.php
question/engine/tests/datalib_reporting_queries_test.php
question/format.php
question/format/xml/format.php
question/format/xml/tests/fixtures/category_with_description.xml
question/format/xml/tests/fixtures/export_category.xml
question/format/xml/tests/fixtures/nested_categories.xml
question/format/xml/tests/fixtures/nested_categories_with_questions.xml
question/format/xml/tests/qformat_xml_import_export_test.php
question/format/xml/tests/xmlformat_test.php
report/insights/amd/build/actions.min.js
report/insights/amd/build/actions.min.js.map
report/insights/amd/build/message_users.min.js [new file with mode: 0644]
report/insights/amd/build/message_users.min.js.map [new file with mode: 0644]
report/insights/amd/src/actions.js
report/insights/amd/src/message_users.js [new file with mode: 0644]
report/insights/classes/external.php
report/insights/classes/output/actions_exporter.php [new file with mode: 0644]
report/insights/classes/output/insight.php
report/insights/classes/output/insights_list.php
report/insights/classes/output/renderer.php
report/insights/db/services.php
report/insights/insights.php
report/insights/lang/en/report_insights.php
report/insights/prediction.php
report/insights/templates/bulk_action_button.mustache [new file with mode: 0644]
report/insights/templates/bulk_actions.mustache [new file with mode: 0644]
report/insights/templates/insight.mustache
report/insights/templates/insight_details.mustache
report/insights/templates/insights_list.mustache
report/insights/tests/externallib_test.php [new file with mode: 0644]
report/insights/version.php
repository/upload/tests/behat/behat_repository_upload.php
theme/boost/lang/en/theme_boost.php
theme/boost/layout/columns2.php
theme/boost/scss/moodle/blocks.scss
theme/boost/scss/moodle/buttons.scss
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/drawer.scss
theme/boost/scss/moodle/message.scss
theme/boost/scss/moodle/modules.scss
theme/boost/style/moodle.css
theme/classic/lang/en/theme_classic.php
theme/classic/style/moodle.css
theme/classic/templates/core/full_header.mustache
user/classes/analytics/target/upcoming_activities_due.php
version.php

index 7c9d422..ff99680 100644 (file)
@@ -46,7 +46,7 @@ $error = optional_param('error', '', PARAM_ALPHANUM);
 admin_externalpage_setup('registrationmoodleorg');
 
 if ($url !== HUB_MOODLEORGHUBURL) {
-    // Allow other plugins to confirm registration on hubs other than moodle.net . Plugins implementing this
+    // Allow other plugins to confirm registration on custom hubs. Plugins implementing this
     // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration .
     $callbacks = get_plugins_with_function('hub_registration');
     foreach ($callbacks as $plugintype => $plugins) {
index 6fe9553..af05a7a 100644 (file)
@@ -32,5 +32,4 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-debugging('Support for alternative hubs has been removed from Moodle in 3.4. For communication with moodle.net ' .
-    'see lib/classes/hub/ .', DEBUG_DEVELOPER);
+debugging('Support for alternative hubs has been removed from Moodle in 3.4.', DEBUG_DEVELOPER);
index 62f251a..ede6a67 100644 (file)
@@ -22,7 +22,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL
  * @copyright  (C) 1999 onwards Martin Dougiamas  http://dougiamas.com
  *
- * This page displays the site registration form for Moodle.net.
+ * This page displays the site registration form.
  * It handles redirection to the hub to continue the registration workflow process.
  * It also handles update operation by web service.
  */
@@ -32,24 +32,25 @@ require_once($CFG->libdir . '/adminlib.php');
 
 admin_externalpage_setup('registrationmoodleorg');
 
-$unregistration = optional_param('unregistration', 0, PARAM_INT);
+$unregistration = optional_param('unregistration', false, PARAM_BOOL);
+$confirm = optional_param('confirm', false, PARAM_BOOL);
 
 if ($unregistration && \core\hub\registration::is_registered()) {
-    $siteunregistrationform = new \core\hub\site_unregistration_form();
+    if ($confirm) {
+        require_sesskey();
+        \core\hub\registration::unregister(false, false);
 
-    if ($siteunregistrationform->is_cancelled()) {
-        redirect(new moodle_url('/admin/registration/index.php'));
-    } else if ($data = $siteunregistrationform->get_data()) {
-        \core\hub\registration::unregister($data->unpublishalladvertisedcourses,
-            $data->unpublishalluploadedcourses);
         if (!\core\hub\registration::is_registered()) {
             redirect(new moodle_url('/admin/registration/index.php'));
         }
     }
 
     echo $OUTPUT->header();
-    echo $OUTPUT->heading(get_string('unregisterfrom', 'hub', 'Moodle.net'), 3, 'main');
-    $siteunregistrationform->display();
+    echo $OUTPUT->confirm(
+        get_string('registerwithmoodleorgremove', 'core_hub'),
+        new moodle_url(new moodle_url('/admin/registration/index.php', ['unregistration' => 1, 'confirm' => 1])),
+        new moodle_url(new moodle_url('/admin/registration/index.php'))
+    );
     echo $OUTPUT->footer();
     exit;
 }
@@ -82,7 +83,7 @@ if ($fromform = $siteregistrationform->get_data()) {
 
 echo $OUTPUT->header();
 
-// Current status of registration on Moodle.net.
+// Current status of registration.
 
 $notificationtype = \core\output\notification::NOTIFY_ERROR;
 if (\core\hub\registration::is_registered()) {
@@ -104,11 +105,11 @@ if (\core\hub\registration::is_registered()) {
 
 // Heading.
 if (\core\hub\registration::is_registered()) {
-    echo $OUTPUT->heading(get_string('updatesite', 'hub', 'Moodle.net'));
+    echo $OUTPUT->heading(get_string('registerwithmoodleorgupdate', 'core_hub'));
 } else if ($isinitialregistration) {
-    echo $OUTPUT->heading(get_string('completeregistration', 'hub'));
+    echo $OUTPUT->heading(get_string('registerwithmoodleorgcomplete', 'core_hub'));
 } else {
-    echo $OUTPUT->heading(get_string('registerwithmoodleorg', 'admin'));
+    echo $OUTPUT->heading(get_string('registerwithmoodleorg', 'core_hub'));
 }
 
 $renderer = $PAGE->get_renderer('core', 'admin');
@@ -119,8 +120,9 @@ $siteregistrationform->display();
 if (\core\hub\registration::is_registered()) {
     // Unregister link.
     $unregisterhuburl = new moodle_url("/admin/registration/index.php", ['unregistration' => 1]);
-    echo html_writer::div(html_writer::link($unregisterhuburl, get_string('unregister', 'hub')), 'unregister');
+    echo html_writer::div(html_writer::link($unregisterhuburl, get_string('unregister', 'hub')), 'unregister mt-2');
 } else if ($isinitialregistration) {
-    echo html_writer::div(html_writer::link(new moodle_url($returnurl), get_string('skipregistration', 'hub')), 'skipregistration');
+    echo html_writer::div(html_writer::link(new moodle_url($returnurl), get_string('skipregistration', 'hub')),
+        'skipregistration mt-2');
 }
 echo $OUTPUT->footer();
index 84e727b..bc05da6 100644 (file)
@@ -40,7 +40,7 @@ $token = optional_param('token', '', PARAM_TEXT);
 admin_externalpage_setup('registrationmoodleorg');
 
 if ($url !== HUB_MOODLEORGHUBURL) {
-    // Allow other plugins to renew registration on hubs other than moodle.net . Plugins implementing this
+    // Allow other plugins to renew registration on custom hubs. Plugins implementing this
     // callback need to redirect or exit. See https://docs.moodle.org/en/Hub_registration .
     $callbacks = get_plugins_with_function('hub_registration');
     foreach ($callbacks as $plugintype => $plugins) {
@@ -56,7 +56,7 @@ if ($url !== HUB_MOODLEORGHUBURL) {
 
 echo $OUTPUT->header();
 echo $OUTPUT->heading(get_string('renewregistration', 'hub'), 3, 'main');
-$hublink = html_writer::tag('a', 'Moodle.net', array('href' => HUB_MOODLEORGHUBURL));
+$hublink = html_writer::tag('a', HUB_MOODLEORGHUBURL, array('href' => HUB_MOODLEORGHUBURL));
 
 $deletedregmsg = get_string('previousregistrationdeleted', 'hub', $hublink);
 
index 773ba4d..5abd08e 100644 (file)
@@ -2112,6 +2112,27 @@ class core_admin_renderer extends plugin_renderer_base {
      * @return string
      */
     public function moodleorg_registration_message() {
-        return format_text(get_string('registermoodlenet', 'admin'), FORMAT_HTML, ['noclean' => true]);
+
+        $out = format_text(get_string('registerwithmoodleorginfo', 'core_hub'), FORMAT_MARKDOWN);
+
+        $out .= html_writer::link(
+            new moodle_url('/admin/settings.php', ['section' => 'moodleservices']),
+            $this->output->pix_icon('i/info', '').' '.get_string('registerwithmoodleorginfoapp', 'core_hub'),
+            ['class' => 'btn btn-link', 'role' => 'opener', 'target' => '_href']
+        );
+
+        $out .= html_writer::link(
+            HUB_MOODLEORGHUBURL,
+            $this->output->pix_icon('i/stats', '').' '.get_string('registerwithmoodleorginfostats', 'core_hub'),
+            ['class' => 'btn btn-link', 'role' => 'opener', 'target' => '_href']
+        );
+
+        $out .= html_writer::link(
+            HUB_MOODLEORGHUBURL.'/sites',
+            $this->output->pix_icon('i/location', '').' '.get_string('registerwithmoodleorginfosites', 'core_hub'),
+            ['class' => 'btn btn-link', 'role' => 'opener', 'target' => '_href']
+        );
+
+        return $this->output->box($out);
     }
 }
index dc924f7..c9d406a 100644 (file)
@@ -197,6 +197,10 @@ preferences,moodle|/user/preferences.php|t/preferences',
         'idnumber' => new lang_string('sort_idnumber', 'admin'),
     );
     $temp->add(new admin_setting_configselect('navsortmycoursessort', new lang_string('navsortmycoursessort', 'admin'), new lang_string('navsortmycoursessort_help', 'admin'), 'sortorder', $sortoptions));
+    $temp->add(new admin_setting_configcheckbox('navsortmycourseshiddenlast',
+            new lang_string('navsortmycourseshiddenlast', 'admin'),
+            new lang_string('navsortmycourseshiddenlast_help', 'admin'),
+            1));
     $temp->add(new admin_setting_configtext('navcourselimit', new lang_string('navcourselimit', 'admin'),
         new lang_string('confignavcourselimit', 'admin'), 10, PARAM_INT));
     $temp->add(new admin_setting_configcheckbox('usesitenameforsitepages', new lang_string('usesitenameforsitepages', 'admin'), new lang_string('configusesitenameforsitepages', 'admin'), 0));
index 925829c..6b3848b 100644 (file)
@@ -175,6 +175,13 @@ if ($hassiteconfig) {
         $plugin->load_settings($ADMIN, 'antivirussettings', $hassiteconfig);
     }
 
+    // Machine learning backend plugins.
+    $ADMIN->add('modules', new admin_category('mlbackendsettings', new lang_string('mlbackendsettings', 'admin')));
+    $plugins = core_plugin_manager::instance()->get_plugins_of_type('mlbackend');
+    foreach ($plugins as $plugin) {
+        $plugin->load_settings($ADMIN, 'mlbackendsettings', $hassiteconfig);
+    }
+
 /// License types
     $ADMIN->add('modules', new admin_category('licensesettings', new lang_string('licenses')));
     $temp = new admin_settingpage('managelicenses', new lang_string('managelicenses', 'admin'));
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * Effectiveness report renderable.
+ * Insights report renderable.
  *
  * @package    tool_analytics
  * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
@@ -27,13 +27,13 @@ namespace tool_analytics\output;
 defined('MOODLE_INTERNAL') || die;
 
 /**
- * Effectiveness report renderable.
+ * Insights report renderable.
  *
  * @package    tool_analytics
  * @copyright  2019 David Monllao {@link http://www.davidmonllao.com}
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class effectiveness_report implements \renderable, \templatable {
+class insights_report implements \renderable, \templatable {
 
     /**
      * @var \core_analytics\model
@@ -46,7 +46,7 @@ class effectiveness_report implements \renderable, \templatable {
     private $context = null;
 
     /**
-     * Inits the effectiveness report renderable.
+     * Inits the insights report renderable.
      *
      * @param \core_analytics\model $model
      * @param int|null $contextid
@@ -79,7 +79,7 @@ class effectiveness_report implements \renderable, \templatable {
         $predictioncontexts = $this->model->get_predictions_contexts(false);
         if ($predictioncontexts && count($predictioncontexts) > 1) {
             $url = new \moodle_url('/admin/tool/analytics/model.php', ['id' => $this->model->get_id(),
-                'action' => 'effectivenessreport']);
+                'action' => 'insightsreport']);
 
             if ($this->context) {
                 $selected = $this->context->id;
@@ -98,20 +98,15 @@ class effectiveness_report implements \renderable, \templatable {
                 // Using this unusual execution flow to init the chart data because $predictionactionrecords
                 // is a \moodle_recordset.
                 if (empty($actionlabels)) {
-                    list($actionlabels, $actionvalues) = $this->init_action_labels($record);
+                    list($actionlabels, $actionvalues, $actiontypes) = $this->init_action_labels($record);
                 }
 
                 // One value for each action.
                 $actionvalues['separated'][$record->actionname]++;
 
-                // Data grouped in three boxes.
-                if ($record->actionname == 'notuseful') {
-                    $actionvalues['grouped']['negative']++;
-                } else if ($record->actionname == 'predictiondetails') {
-                    $actionvalues['grouped']['neutral']++;
-                } else {
-                    $actionvalues['grouped']['positive']++;
-                }
+                // Grouped value.
+                $actiontype = $actiontypes[$record->actionname];
+                $actionvalues['grouped'][$actiontype]++;
             }
             $predictionactionrecords->close();
 
@@ -162,18 +157,28 @@ class effectiveness_report implements \renderable, \templatable {
 
         $actionlabels = [];
         $actionvalues = ['separated' => [], 'grouped' => []];
+        $actiontypes = [];
         foreach ($predictionactions as $action) {
             $actionlabels['separated'][$action->get_action_name()] = $action->get_text();
             $actionvalues['separated'][$action->get_action_name()] = 0;
+            $actiontypes[$action->get_action_name()] = $action->get_type();
+        }
+
+        $bulkactions = $this->model->get_target()->bulk_actions($predictions);
+        foreach ($bulkactions as $action) {
+            $actionlabels['separated'][$action->get_action_name()] = $action->get_text();
+            $actionvalues['separated'][$action->get_action_name()] = 0;
+            $actiontypes[$action->get_action_name()] = $action->get_type();
         }
 
-        $actionlabels['grouped']['positive'] = get_string('useful', 'analytics');
-        $actionlabels['grouped']['neutral'] = get_string('neutral', 'analytics');
-        $actionlabels['grouped']['negative'] = get_string('notuseful', 'analytics');
-        $actionvalues['grouped']['positive'] = 0;
-        $actionvalues['grouped']['neutral'] = 0;
-        $actionvalues['grouped']['negative'] = 0;
+        $actionlabels['grouped'][\core_analytics\action::TYPE_POSITIVE] = get_string('useful', 'analytics');
+        $actionlabels['grouped'][\core_analytics\action::TYPE_NEUTRAL] = get_string('neutral', 'analytics');
+        $actionlabels['grouped'][\core_analytics\action::TYPE_NEGATIVE] = get_string('notuseful', 'analytics');
+
+        $actionvalues['grouped'][\core_analytics\action::TYPE_POSITIVE] = 0;
+        $actionvalues['grouped'][\core_analytics\action::TYPE_NEUTRAL] = 0;
+        $actionvalues['grouped'][\core_analytics\action::TYPE_NEGATIVE] = 0;
 
-        return [$actionlabels, $actionvalues];
+        return [$actionlabels, $actionvalues, $actiontypes];
     }
 }
index be1c7ba..ade6739 100644 (file)
@@ -288,12 +288,12 @@ class models_list implements \renderable, \templatable {
                 }
             }
 
-            // Effectivity report.
+            // Insights report.
             if (!empty($anypredictionobtained) && $model->uses_insights()) {
-                $urlparams['action'] = 'effectivenessreport';
+                $urlparams['action'] = 'insightsreport';
                 $url = new \moodle_url('/admin/tool/analytics/model.php', $urlparams);
-                $pix = new \pix_icon('i/report', get_string('effectivenessreport', 'tool_analytics'));
-                $icon = new \action_menu_link_secondary($url, $pix, get_string('effectivenessreport', 'tool_analytics'));
+                $pix = new \pix_icon('i/report', get_string('insightsreport', 'tool_analytics'));
+                $icon = new \action_menu_link_secondary($url, $pix, get_string('insightsreport', 'tool_analytics'));
                 $actionsmenu->add($icon);
             }
 
index 60bc2a2..b4f7a51 100644 (file)
@@ -211,12 +211,12 @@ class renderer extends plugin_renderer_base {
     /**
      * Defer to template.
      *
-     * @param \tool_analytics\output\effectiveness_report $effectivenessreport
+     * @param \tool_analytics\output\insights_report $insightsreport
      * @return string HTML
      */
-    protected function render_effectiveness_report(\tool_analytics\output\effectiveness_report $effectivenessreport): string {
-        $data = $effectivenessreport->export_for_template($this);
-        return parent::render_from_template('tool_analytics/effectiveness_report', $data);
+    protected function render_insights_report(\tool_analytics\output\insights_report $insightsreport): string {
+        $data = $insightsreport->export_for_template($this);
+        return parent::render_from_template('tool_analytics/insights_report', $data);
     }
 
     /**
index 7041b83..ca3d1a2 100644 (file)
@@ -25,6 +25,7 @@
 $string['accuracy'] = 'Accuracy';
 $string['actions'] = 'Actions';
 $string['actionsexecutedbyusers'] = 'Actions executed by users';
+$string['actionsexecutedbyusersfor'] = 'Actions executed by users for "{$a}" model';
 $string['actionexecutedgroupedusefulness'] = 'Grouped actions';
 $string['allpredictions'] = 'All predictions';
 $string['alltimesplittingmethods'] = 'All analysis intervals';
@@ -51,8 +52,6 @@ $string['deletemodelconfirmation'] = 'Are you sure you want to delete "{$a}"? Th
 $string['disabled'] = 'Disabled';
 $string['editmodel'] = 'Edit "{$a}" model';
 $string['edittrainedwarning'] = 'This model has already been trained. Note that changing its indicators or its analysis interval will delete its previous predictions and start generating new predictions.';
-$string['effectivenessreport'] = 'Effectiveness report';
-$string['effectivenessreportfor'] = 'Model "{$a}" effectiveness';
 $string['enabled'] = 'Enabled';
 $string['errorcantenablenotimesplitting'] = 'You need to select an analysis interval before enabling the model';
 $string['errornoenabledandtrainedmodels'] = 'There are no enabled and trained models to predict.';
@@ -91,6 +90,7 @@ $string['indicators_help'] = 'The indicators are what you think will lead to an
 $string['indicators_link'] = 'Indicators';
 $string['indicatorsnum'] = 'Number of indicators: {$a}';
 $string['info'] = 'Info';
+$string['insightsreport'] = 'Insights report';
 $string['ignoreversionmismatches'] = 'Ignore version mismatches';
 $string['ignoreversionmismatchescheckbox'] = 'Ignore the differences between this site version and the original site version.';
 $string['importedsuccessfully'] = 'The model has been successfully imported.';
index 653ab82..7397de1 100644 (file)
@@ -69,8 +69,8 @@ switch ($action) {
     case 'clear':
         $title = get_string('clearpredictions', 'tool_analytics');
         break;
-    case 'effectivenessreport':
-        $title = get_string('effectivenessreport', 'tool_analytics');
+    case 'insightsreport':
+        $title = get_string('insightsreport', 'tool_analytics');
         break;
     case 'invalidanalysables':
         $title = get_string('invalidanalysables', 'tool_analytics');
@@ -282,13 +282,13 @@ switch ($action) {
         redirect($returnurl);
         break;
 
-    case 'effectivenessreport':
+    case 'insightsreport':
 
         $contextid = optional_param('contextid', null, PARAM_INT);
 
         echo $OUTPUT->header();
 
-        $renderable = new \tool_analytics\output\effectiveness_report($model, $contextid);
+        $renderable = new \tool_analytics\output\insights_report($model, $contextid);
         $renderer = $PAGE->get_renderer('tool_analytics');
         echo $renderer->render($renderable);
 
@@ -15,9 +15,9 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template tool_analytics/effectiveness_report
+    @template tool_analytics/insights_report
 
-    Template for the effectiveness report.
+    Template for the insights report.
 
     Classes required for JS:
     * none
@@ -39,7 +39,7 @@
 }}
 
 <div class="box">
-    <h3>{{#str}}effectivenessreportfor, tool_analytics, {{modelname}}{{/str}}</h3>
+    <h3>{{#str}}actionsexecutedbyusersfor, tool_analytics, {{modelname}}{{/str}}</h3>
 
     {{#contextselect}}
         <div class="mt-3">
index 7a2181b..07a6dad 100644 (file)
@@ -75,7 +75,7 @@ class manage_competency_frameworks_page implements renderable, templatable {
             );
             $this->navigation[] = $addpage;
             $competenciesrepository = new single_button(
-                new moodle_url('https://moodle.net/competencies'),
+                new moodle_url('https://archive.moodle.net/competencies'),
                 get_string('competencyframeworksrepository', 'tool_lp'),
                 'get'
             );
index 978f950..1b34498 100644 (file)
@@ -177,6 +177,10 @@ class api {
             'langmenu' => $CFG->langmenu,
             'langlist' => $CFG->langlist,
             'locale' => $CFG->locale,
+            'tool_mobile_minimumversion' => get_config('tool_mobile', 'minimumversion'),
+            'tool_mobile_iosappid' => get_config('tool_mobile', 'iosappid'),
+            'tool_mobile_androidappid' => get_config('tool_mobile', 'androidappid'),
+            'tool_mobile_setuplink' => clean_param(get_config('tool_mobile', 'setuplink'), PARAM_URL),
         );
 
         $typeoflogin = get_config('tool_mobile', 'typeoflogin');
index 73c81ff..fe1dad0 100644 (file)
@@ -179,6 +179,13 @@ class external extends external_api {
                 'langmenu' => new external_value(PARAM_INT, 'Whether the language menu should be displayed.', VALUE_OPTIONAL),
                 'langlist' => new external_value(PARAM_RAW, 'Languages on language menu.', VALUE_OPTIONAL),
                 'locale' => new external_value(PARAM_RAW, 'Sitewide locale.', VALUE_OPTIONAL),
+                'tool_mobile_minimumversion' => new external_value(PARAM_NOTAGS, 'Minimum required version to access.',
+                    VALUE_OPTIONAL),
+                'tool_mobile_iosappid' => new external_value(PARAM_ALPHANUM, 'iOS app\'s unique identifier.',
+                    VALUE_OPTIONAL),
+                'tool_mobile_androidappid' => new external_value(PARAM_NOTAGS, 'Android app\'s unique identifier.',
+                    VALUE_OPTIONAL),
+                'tool_mobile_setuplink' => new external_value(PARAM_URL, 'App download page.', VALUE_OPTIONAL),
                 'warnings' => new external_warnings(),
             )
         );
index b02ff9e..5c7686a 100644 (file)
@@ -75,6 +75,8 @@ $string['loginintheapp'] = 'Via the app';
 $string['logininthebrowser'] = 'Via a browser window (for SSO plugins)';
 $string['loginintheembeddedbrowser'] = 'Via an embedded browser (for SSO plugins)';
 $string['mainmenu'] = 'Main menu';
+$string['minimumversion'] = 'Require users to upgrade their apps to the minimum version indicated. Those using previous versions of the app will not be able to access to the site. This works since app version 3.8.0 onward.';
+$string['minimumversion_key'] = 'Minimum app version required';
 $string['mobileapp'] = 'Mobile app';
 $string['mobileappconnected'] = 'Mobile app connected';
 $string['mobileappenabled'] = 'This site has mobile app access enabled.<br /><a href="{$a}">Download the mobile app</a>.';
index b5033d8..2121e84 100644 (file)
@@ -65,6 +65,10 @@ if ($hassiteconfig) {
                     new lang_string('forcedurlscheme_key', 'tool_mobile'),
                     new lang_string('forcedurlscheme', 'tool_mobile'), 'moodlemobile', PARAM_ALPHANUM));
 
+        $temp->add(new admin_setting_configtext('tool_mobile/minimumversion',
+                    new lang_string('minimumversion_key', 'tool_mobile'),
+                    new lang_string('minimumversion', 'tool_mobile'), '', PARAM_NOTAGS));
+
         $ADMIN->add('mobileapp', $temp);
 
         // Appearance related settings.
index 955fbb9..c2118dc 100644 (file)
@@ -95,6 +95,10 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
             'langmenu' => $CFG->langmenu,
             'langlist' => $CFG->langlist,
             'locale' => $CFG->locale,
+            'tool_mobile_minimumversion' => '',
+            'tool_mobile_iosappid' => get_config('tool_mobile', 'iosappid'),
+            'tool_mobile_androidappid' => get_config('tool_mobile', 'androidappid'),
+            'tool_mobile_setuplink' => get_config('tool_mobile', 'setuplink'),
             'warnings' => array()
         );
         $this->assertEquals($expected, $result);
@@ -111,6 +115,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         set_config('autolang', 1);
         set_config('lang', 'a_b');  // Set invalid lang.
         set_config('disabledfeatures', 'myoverview', 'tool_mobile');
+        set_config('minimumversion', '3.8.0', 'tool_mobile');
 
         list($authinstructions, $notusedformat) = external_format_text($authinstructions, FORMAT_MOODLE, $context->id);
         $expected['registerauth'] = 'email';
@@ -123,6 +128,7 @@ class tool_mobile_external_testcase extends externallib_advanced_testcase {
         $expected['autolang'] = '1';
         $expected['lang'] = ''; // Expect empty because it was set to an invalid lang.
         $expected['tool_mobile_disabledfeatures'] = 'myoverview';
+        $expected['tool_mobile_minimumversion'] = '3.8.0';
 
         if ($logourl = $OUTPUT->get_logo_url()) {
             $expected['logourl'] = $logourl->out(false);
index 58078ec..df11ac9 100644 (file)
@@ -119,7 +119,9 @@ function tool_monitor_can_subscribe() {
  * @return array|bool Returns an array of courses or false if the user has no permission to subscribe to rules.
  */
 function tool_monitor_get_user_courses() {
-    $orderby = 'visible DESC, sortorder ASC';
+    // Get the course sorting according to the admin settings.
+    $sort = enrol_get_courses_sortingsql();
+
     $options = array();
     if (has_capability('tool/monitor:subscribe', context_system::instance())) {
         $options[0] = get_string('site');
@@ -134,7 +136,7 @@ function tool_monitor_get_user_courses() {
         );
 
     $fields = implode(', ', $fieldlist);
-    if ($courses = get_user_capability_course('tool/monitor:subscribe', null, true, $fields, $orderby)) {
+    if ($courses = get_user_capability_course('tool/monitor:subscribe', null, true, $fields, $sort)) {
         foreach ($courses as $course) {
             context_helper::preload_from_record($course);
             $coursectx = context_course::instance($course->id);
index 33cd752..40d2595 100644 (file)
@@ -266,7 +266,7 @@ class manager {
                 'title' => get_string('importtour', 'tool_usertours'),
             ],
             (object) [
-                'link'  => new \moodle_url('https://moodle.net/tours'),
+                'link'  => new \moodle_url('https://archive.moodle.net/tours'),
                 'linkproperties' => [
                         'target' => '_blank',
                     ],
diff --git a/analytics/classes/action.php b/analytics/classes/action.php
new file mode 100644 (file)
index 0000000..93bbe87
--- /dev/null
@@ -0,0 +1,137 @@
+<?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/>.
+
+/**
+ * Representation of a suggested action.
+ *
+ * @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;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Representation of a suggested action.
+ *
+ * @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 action {
+
+    /**
+     * @var  Action type useful.
+     */
+    const TYPE_POSITIVE = 'useful';
+
+    /**
+     * @var  Action type notuseful.
+     */
+    const TYPE_NEGATIVE = 'notuseful';
+
+    /**
+     * @var  Action type neutral.
+     */
+    const TYPE_NEUTRAL = 'neutral';
+
+    /**
+     * @var string
+     */
+    protected $actionname = null;
+
+    /**
+     * @var \moodle_url
+     */
+    protected $url = null;
+
+    /**
+     * @var \renderable
+     */
+    protected $actionlink = null;
+
+    /**
+     * @var string
+     */
+    protected $text = null;
+
+    /**
+     * Returns the action name.
+     *
+     * @return string
+     */
+    public function get_action_name() {
+        return $this->actionname;
+    }
+
+    /**
+     * Returns the url to the action.
+     *
+     * @return \moodle_url
+     */
+    public function get_url() {
+        return $this->url;
+    }
+
+    /**
+     * Returns the link to the action.
+     *
+     * @return \renderable
+     */
+    public function get_action_link() {
+        return $this->actionlink;
+    }
+
+    /**
+     * Returns the action text.
+     * @return string
+     */
+    public function get_text() {
+        return $this->text;
+    }
+
+    /**
+     * Sets the type of the action according to its positiveness.
+     *
+     * @throws \coding_exception
+     * @param string|false $type \core_analytics\action::TYPE_POSITIVE, TYPE_NEGATIVE or TYPE_NEUTRAL
+     */
+    public function set_type($type = false) {
+        if (!$type) {
+            // Any non-standard action specified by a target is considered positive by default because that is what
+            // they are meant to be.
+            $type = self::TYPE_POSITIVE;
+        }
+
+        if ($type !== self::TYPE_POSITIVE && $type !== self::TYPE_NEUTRAL &&
+                $type !== self::TYPE_NEGATIVE) {
+            throw new \coding_exception('The provided type must be ' . self::TYPE_POSITIVE . ', ' . self::TYPE_NEUTRAL .
+                ' or ' . self::TYPE_NEGATIVE);
+        }
+        $this->type = $type;
+    }
+
+    /**
+     * Returns the type of action.
+     *
+     * @return string The positiveness of the action (self::TYPE_POSITIVE, self::TYPE_NEGATIVE or self::TYPE_NEUTRAL)
+     */
+    public function get_type() {
+        return $this->type;
+    }
+}
diff --git a/analytics/classes/bulk_action.php b/analytics/classes/bulk_action.php
new file mode 100644 (file)
index 0000000..e615816
--- /dev/null
@@ -0,0 +1,65 @@
+<?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/>.
+
+/**
+ * Representation of a suggested bulk action.
+ *
+ * @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;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Representation of a suggested bulk action.
+ *
+ * @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 bulk_action extends action {
+
+    /**
+     * Prediction action constructor.
+     *
+     * @param string $actionname They should match a-zA-Z_0-9-, as we apply a PARAM_ALPHANUMEXT filter
+     * @param \moodle_url $actionurl The final URL where the user should be forwarded.
+     * @param \pix_icon $icon Link icon
+     * @param string $text Link text
+     * @param bool $primary Primary button or secondary.
+     * @param array $attributes Link attributes
+     * @param string|false $type
+     * @return void
+     */
+    public function __construct($actionname, \moodle_url $actionurl, \pix_icon $icon,
+                                $text, $primary = false, $attributes = array(), $type = false) {
+        global $OUTPUT;
+
+        $this->actionname = $actionname;
+        $this->text = $text;
+        $this->set_type($type);
+
+        // We want to track how effective are our suggested actions, we pass users through a script that will log these actions.
+        $params = array('action' => $this->actionname, 'forwardurl' => $actionurl->out(false));
+        $this->url = new \moodle_url('/report/insights/action.php', $params);
+
+        $label = $OUTPUT->render($icon) . $this->text;
+        $this->actionlink = new \single_button($this->url, $label, 'get', $primary, $attributes);
+    }
+}
diff --git a/analytics/classes/default_bulk_actions.php b/analytics/classes/default_bulk_actions.php
new file mode 100644 (file)
index 0000000..e07d389
--- /dev/null
@@ -0,0 +1,126 @@
+<?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/>.
+
+/**
+ * Default list of bulk actions to reuse across different targets as presets.
+ *
+ * @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;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Default list of bulk actions to reuse across different targets as presets.
+ *
+ * @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 default_bulk_actions {
+
+    /**
+     * Accepted prediction.
+     *
+     * @return \core_analytics\bulk_action
+     */
+    public static function accept() {
+        $attrs = [
+            'data-bulk-actionname' => prediction::ACTION_FIXED
+        ] + self::bulk_action_base_attrs();
+
+        return new bulk_action(prediction::ACTION_FIXED,
+            new \moodle_url(''), new \pix_icon('t/check', get_string('fixedack', 'analytics')),
+            get_string('fixedack', 'analytics'), false, $attrs, action::TYPE_POSITIVE);
+    }
+
+    /**
+     * The prediction is not applicable for this same (e.g. This student was unenrolled in the uni SIS).
+     *
+     * @return \core_analytics\bulk_action
+     */
+    public static function not_applicable() {
+        $attrs = [
+            'data-bulk-actionname' => prediction::ACTION_NOT_APPLICABLE
+        ] + self::bulk_action_base_attrs();
+
+        return new bulk_action(prediction::ACTION_NOT_APPLICABLE,
+            new \moodle_url(''), new \pix_icon('fp/cross', get_string('notapplicable', 'analytics'), 'theme'),
+            get_string('notapplicable', 'analytics'), false, $attrs, action::TYPE_NEUTRAL);
+    }
+
+    /**
+     * Incorrectly flagged prediction, useful for models based on data.
+     *
+     * @return \core_analytics\bulk_action
+     */
+    public static function incorrectly_flagged() {
+        $attrs = [
+            'data-bulk-actionname' => prediction::ACTION_INCORRECTLY_FLAGGED
+        ] + self::bulk_action_base_attrs();
+
+        return new bulk_action(prediction::ACTION_INCORRECTLY_FLAGGED,
+            new \moodle_url(''), new \pix_icon('i/incorrect', get_string('incorrectlyflagged', 'analytics')),
+            get_string('incorrectlyflagged', 'analytics'), false, $attrs, action::TYPE_NEGATIVE);
+    }
+
+    /**
+     * Useful prediction.
+     *
+     * @return \core_analytics\bulk_action
+     */
+    public static function useful() {
+        $attrs = [
+            'data-bulk-actionname' => prediction::ACTION_USEFUL
+        ] + self::bulk_action_base_attrs();
+
+        return new bulk_action(prediction::ACTION_USEFUL,
+            new \moodle_url(''), new \pix_icon('t/check', get_string('useful', 'analytics')),
+            get_string('useful', 'analytics'), false, $attrs, action::TYPE_POSITIVE);
+
+    }
+
+    /**
+     * Not useful prediction.
+     *
+     * @return \core_analytics\bulk_action
+     */
+    public static function not_useful() {
+        $attrs = [
+            'data-bulk-actionname' => prediction::ACTION_NOT_USEFUL
+        ] + self::bulk_action_base_attrs();
+
+        return new bulk_action(prediction::ACTION_NOT_USEFUL,
+            new \moodle_url(''), new \pix_icon('t/delete', get_string('notuseful', 'analytics')),
+            get_string('notuseful', 'analytics'), false, $attrs, action::TYPE_NEGATIVE);
+    }
+
+    /**
+     * Common attributes for all the action renderables.
+     *
+     * @return array
+     */
+    private static final function bulk_action_base_attrs() {
+        return [
+            'disabled' => 'disabled',
+            'data-toggle' => 'action',
+            'data-action' => 'toggle',
+        ];
+    }
+}
\ No newline at end of file
index 2fd2320..7a18dc1 100644 (file)
@@ -187,8 +187,11 @@ class insights_generator {
         global $OUTPUT;
 
         // The prediction actions get passed to the target so that it can show them in its preferred way.
-        $predictionactions = $this->target->prediction_actions($prediction, true, true);
-        $predictioninfo = $this->target->get_insight_body_for_prediction($context, $user, $prediction, $predictionactions);
+        $actions = array_merge(
+            $this->target->prediction_actions($prediction, true, true),
+            $this->target->bulk_actions([$prediction])
+        );
+        $predictioninfo = $this->target->get_insight_body_for_prediction($context, $user, $prediction, $actions);
 
         // For FORMAT_PLAIN.
         $fullmessageplaintext = '';
@@ -200,18 +203,23 @@ class insights_generator {
 
         // For FORMAT_HTML.
         $messageactions  = [];
-        foreach ($predictionactions as $action) {
-            $actionurl = $action->get_url();
-            if (!$actionurl->get_param('forwardurl')) {
+        foreach ($actions as $action) {
+            if (!$action->get_url()->get_param('forwardurl')) {
 
                 $params = ['actionvisiblename' => $action->get_text(), 'target' => '_blank'];
                 $actiondoneurl = new \moodle_url('/report/insights/done.php', $params);
                 // Set the forward url to the 'done' script.
-                $actionurl->param('forwardurl', $actiondoneurl->out(false));
+                $action->get_url()->param('forwardurl', $actiondoneurl->out(false));
+            }
+
+            if ($action->get_url()->param('predictionid') === null) {
+                // Bulk actions do not include the prediction id by default.
+                $action->get_url()->param('predictionid', $prediction->get_prediction_data()->id);
             }
 
             if (empty($insighturl)) {
-                // We use the primary action url as insight url so we log that the user followed the provided link.
+                // Ideally the target provides us with the best URL for the insight, if it doesn't we default
+                // to the first actions.
                 $insighturl = $action->get_url();
             }
 
@@ -221,7 +229,7 @@ class insights_generator {
             $fullmessageplaintext .= get_string('insightinfomessageaction', 'analytics', $actiondata) . PHP_EOL;
 
             // We now process the HTML version actions, with a special treatment for useful/notuseful.
-            if ($action->get_action_name() === 'fixed') {
+            if ($action->get_action_name() === 'useful') {
                 $usefulurl = $actiondata->url;
             } else if ($action->get_action_name() === 'notuseful') {
                 $notusefulurl = $actiondata->url;
@@ -236,11 +244,12 @@ class insights_generator {
         }
 
         $contextinfo = [
-            'usefulbuttons' => $usefulbuttons,
+            'usefulbuttons' => !empty($usefulbuttons) ? $usefulbuttons : false,
             'actions' => $messageactions,
             'body' => $predictioninfo[FORMAT_HTML] ?? ''
         ];
         $fullmessagehtml = $OUTPUT->render_from_template('core_analytics/insight_info_message_prediction', $contextinfo);
+
         return [$insighturl, $fullmessageplaintext, $fullmessagehtml];
     }
 
index 579b911..b9f13a1 100644 (file)
@@ -141,7 +141,8 @@ abstract class base extends \core_analytics\calculable {
      *
      * @param \core_analytics\prediction $prediction
      * @param bool $includedetailsaction
-     * @param bool $isinsightuser
+     * @param bool $isinsightuser                       Force all the available actions to be returned as it the user who
+     *                                                  receives the insight is the one logged in.
      * @return \core_analytics\prediction_action[]
      */
     public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false,
@@ -152,8 +153,6 @@ abstract class base extends \core_analytics\calculable {
         $contextid = $prediction->get_prediction_data()->contextid;
         $modelid = $prediction->get_prediction_data()->modelid;
 
-        $PAGE->requires->js_call_amd('report_insights/actions', 'init', array($predictionid, $contextid, $modelid));
-
         $actions = array();
 
         if ($this->link_insights_report() && $includedetailsaction) {
@@ -163,30 +162,53 @@ abstract class base extends \core_analytics\calculable {
 
             $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_PREDICTION_DETAILS, $prediction,
                 $predictionurl, new \pix_icon('t/preview', $detailstext),
-                $detailstext);
+                $detailstext, false, [], \core_analytics\action::TYPE_NEUTRAL);
         }
 
-        // Flag as fixed / solved.
-        $fixedattrs = array(
-            'data-prediction-id' => $predictionid,
-            'data-prediction-methodname' => 'report_insights_set_fixed_prediction'
-        );
-        $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_FIXED,
-            $prediction, new \moodle_url(''), new \pix_icon('t/check', get_string('fixedack', 'analytics')),
-            get_string('fixedack', 'analytics'), false, $fixedattrs);
-
-        // Flag as not useful.
-        $notusefulattrs = array(
-            'data-prediction-id' => $predictionid,
-            'data-prediction-methodname' => 'report_insights_set_notuseful_prediction'
-        );
-        $actions[] = new \core_analytics\prediction_action(\core_analytics\prediction::ACTION_NOT_USEFUL,
-            $prediction, new \moodle_url(''), new \pix_icon('t/delete', get_string('notuseful', 'analytics')),
-            get_string('notuseful', 'analytics'), false, $notusefulattrs);
+        return $actions;
+    }
+
+    /**
+     * Suggested bulk actions for a user.
+     *
+     * @param  \core_analytics\prediction[]     $predictions List of predictions suitable for the bulk actions to use.
+     * @return \core_analytics\bulk_action[]                 The list of bulk actions.
+     */
+    public function bulk_actions(array $predictions) {
+
+        $analyserclass = $this->get_analyser_class();
+        if ($analyserclass::one_sample_per_analysable()) {
+            // Default actions are useful / not useful.
+            $actions = [
+                \core_analytics\default_bulk_actions::useful(),
+                \core_analytics\default_bulk_actions::not_useful()
+            ];
+
+        } else {
+            // Accept and not applicable.
+
+            $actions = [
+                \core_analytics\default_bulk_actions::accept(),
+                \core_analytics\default_bulk_actions::not_applicable()
+            ];
+
+            if (!self::based_on_assumptions()) {
+                // We include incorrectly flagged.
+                $actions[] = \core_analytics\default_bulk_actions::incorrectly_flagged();
+            }
+        }
 
         return $actions;
     }
 
+    /**
+     * Adds the JS required to run the bulk actions.
+     */
+    public function add_bulk_actions_js() {
+        global $PAGE;
+        $PAGE->requires->js_call_amd('report_insights/actions', 'initBulk', ['.insights-bulk-actions']);
+    }
+
     /**
      * Returns the view details link text.
      * @return string
@@ -320,13 +342,13 @@ abstract class base extends \core_analytics\calculable {
      * @param  \context                             $context
      * @param  \stdClass                            $user
      * @param  \core_analytics\prediction           $prediction
-     * @param  \core_analytics\prediction_action[]  $predictionactions  Passed by reference to remove duplicate links to actions.
-     * @return array                                                    Plain text msg, HTML message and the main URL for this
-     *                                                                  insight (you can return null if you are happy with the
-     *                                                                  default insight URL calculated in prediction_info())
+     * @param  \core_analytics\action[]             $actions        Passed by reference to remove duplicate links to actions.
+     * @return array                                                Plain text msg, HTML message and the main URL for this
+     *                                                              insight (you can return null if you are happy with the
+     *                                                              default insight URL calculated in prediction_info())
      */
     public function get_insight_body_for_prediction(\context $context, \stdClass $user, \core_analytics\prediction $prediction,
-            array &$predictionactions): array {
+            array &$actions) {
         // No extra message by default.
         return [FORMAT_PLAIN => '', FORMAT_HTML => '', 'url' => null];
     }
index 699d22b..00a3d58 100644 (file)
@@ -48,7 +48,7 @@ class manager {
     /**
      * @var \core_analytics\predictor[]
      */
-    protected static $predictionprocessors = null;
+    protected static $predictionprocessors = [];
 
     /**
      * @var \core_analytics\local\target\base[]
@@ -213,6 +213,14 @@ class manager {
         return $predictionprocessors;
     }
 
+    /**
+     * Resets the cached prediction processors.
+     * @return null
+     */
+    public static function reset_prediction_processors() {
+        self::$predictionprocessors = [];
+    }
+
     /**
      * Returns the name of the provided predictions processor.
      *
index 9632347..9a524be 100644 (file)
@@ -539,7 +539,7 @@ class model {
             debugging('Prediction processor ' . $predictorname . ' is not ready to be used. Model ' .
                 $this->model->id . ' could not be deleted.');
         } else {
-            $predictor->delete_output_dir($this->get_output_dir(array(), true));
+            $predictor->delete_output_dir($this->get_output_dir(array(), true), $this->get_unique_id());
         }
 
         $DB->delete_records('analytics_models', array('id' => $this->model->id));
@@ -1236,11 +1236,17 @@ class model {
             $sql .= " AND NOT EXISTS (
               SELECT 1
                 FROM {analytics_prediction_actions} apa
-               WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
+               WHERE apa.predictionid = ap.id AND apa.userid = :userid AND
+                     (apa.actionname = :fixed OR apa.actionname = :notuseful OR
+                     apa.actionname = :useful OR apa.actionname = :notapplicable OR
+                     apa.actionname = :incorrectlyflagged)
             )";
             $params['userid'] = $USER->id;
             $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
             $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
+            $params['useful'] = \core_analytics\prediction::ACTION_USEFUL;
+            $params['notapplicable'] = \core_analytics\prediction::ACTION_NOT_APPLICABLE;
+            $params['incorrectlyflagged'] = \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED;
         }
 
         return $DB->get_records_sql($sql, $params);
@@ -1319,11 +1325,17 @@ class model {
             $sql .= " AND NOT EXISTS (
               SELECT 1
                 FROM {analytics_prediction_actions} apa
-               WHERE apa.predictionid = ap.id AND apa.userid = :userid AND (apa.actionname = :fixed OR apa.actionname = :notuseful)
+               WHERE apa.predictionid = ap.id AND apa.userid = :userid AND
+                     (apa.actionname = :fixed OR apa.actionname = :notuseful OR
+                     apa.actionname = :useful OR apa.actionname = :notapplicable OR
+                     apa.actionname = :incorrectlyflagged)
             )";
             $params['userid'] = $USER->id;
             $params['fixed'] = \core_analytics\prediction::ACTION_FIXED;
             $params['notuseful'] = \core_analytics\prediction::ACTION_NOT_USEFUL;
+            $params['useful'] = \core_analytics\prediction::ACTION_USEFUL;
+            $params['notapplicable'] = \core_analytics\prediction::ACTION_NOT_APPLICABLE;
+            $params['incorrectlyflagged'] = \core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED;
         }
 
         $sql .= " ORDER BY ap.timecreated DESC";
index 24fa77a..6b0929e 100644 (file)
@@ -40,6 +40,11 @@ class prediction {
      */
     const ACTION_PREDICTION_DETAILS = 'predictiondetails';
 
+    /**
+     * Prediction useful (one of the default prediction actions)
+     */
+    const ACTION_USEFUL = 'useful';
+
     /**
      * Prediction not useful (one of the default prediction actions)
      */
@@ -50,6 +55,16 @@ class prediction {
      */
     const ACTION_FIXED = 'fixed';
 
+    /**
+     * Prediction not applicable.
+     */
+    const ACTION_NOT_APPLICABLE = 'notapplicable';
+
+    /**
+     * Prediction incorrectly flagged.
+     */
+    const ACTION_INCORRECTLY_FLAGGED = 'incorrectlyflagged';
+
     /**
      * @var \stdClass
      */
@@ -136,6 +151,12 @@ class prediction {
                 $found = true;
             }
         }
+        $bulkactions = $target->bulk_actions([$this]);
+        foreach ($bulkactions as $action) {
+            if ($action->get_action_name() === $actionname) {
+                $found = true;
+            }
+        }
         if (empty($found)) {
             throw new \moodle_exception('errorunknownaction', 'analytics');
         }
index 91b87bf..8777a64 100644 (file)
@@ -33,22 +33,7 @@ defined('MOODLE_INTERNAL') || die();
  * @copyright 2017 David Monllao {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class prediction_action {
-
-    /**
-     * @var string
-     */
-    protected $actionname = null;
-
-    /**
-     * @var \moodle_url
-     */
-    protected $url = null;
-
-    /**
-     * @var \action_menu_link
-     */
-    protected $actionlink = null;
+class prediction_action extends action {
 
     /**
      * Prediction action constructor.
@@ -60,58 +45,26 @@ class prediction_action {
      * @param string $text Link text
      * @param bool $primary Primary button or secondary.
      * @param array $attributes Link attributes
+     * @param string|false $type
      * @return void
      */
     public function __construct($actionname, \core_analytics\prediction $prediction, \moodle_url $actionurl, \pix_icon $icon,
-                                $text, $primary = false, $attributes = array()) {
+                                $text, $primary = false, $attributes = array(), $type = false) {
 
         $this->actionname = $actionname;
         $this->text = $text;
+        $this->set_type($type);
 
         $this->url = self::transform_to_forward_url($actionurl, $actionname, $prediction->get_prediction_data()->id);
 
+        // The \action_menu_link items are displayed as an icon with a label, no need to show any text.
         if ($primary === false) {
-            $this->actionlink = new \action_menu_link_secondary($this->url, $icon, $this->text, $attributes);
+            $this->actionlink = new \action_menu_link_secondary($this->url, $icon, '', $attributes);
         } else {
-            $this->actionlink = new \action_menu_link_primary($this->url, $icon, $this->text, $attributes);
+            $this->actionlink = new \action_menu_link_primary($this->url, $icon, '', $attributes);
         }
     }
 
-    /**
-     * Returns the action name.
-     *
-     * @return string
-     */
-    public function get_action_name() {
-        return $this->actionname;
-    }
-
-    /**
-     * Returns the url to the action.
-     *
-     * @return \moodle_url
-     */
-    public function get_url() {
-        return $this->url;
-    }
-
-    /**
-     * Returns the link to the action.
-     *
-     * @return \action_menu_link
-     */
-    public function get_action_link() {
-        return $this->actionlink;
-    }
-
-    /**
-     * Returns the action text.
-     * @return string
-     */
-    public function get_text() {
-        return $this->text;
-    }
-
     /**
      * Transforms the provided url to an action url so we can record the user actions.
      *
index 4dcb491..bb90061 100644 (file)
@@ -70,8 +70,9 @@ interface predictor {
      * can only be named 'execution', 'evaluation' or 'testing'.
      *
      * @param string $modeloutputdir The model directory id (parent of all model versions subdirectories).
+     * @param string $uniqueid
      * @return null
      */
-    public function delete_output_dir($modeloutputdir);
+    public function delete_output_dir($modeloutputdir, $uniqueid);
 
 }
index 0caf975..fd6e7cb 100644 (file)
@@ -73,6 +73,9 @@ class stats {
     public static function actions_not_useful() : int {
         global $DB;
 
-        return $DB->count_records('analytics_prediction_actions', ['actionname' => prediction::ACTION_NOT_USEFUL]);
+        // Simple version using core's TYPE_NEGATIVE actions.
+        return $DB->count_records_select('analytics_prediction_actions',
+            'actionname = :notuseful OR actionname = :incorrectlyflagged',
+            ['notuseful' => prediction::ACTION_NOT_USEFUL, 'incorrectlyflagged' => prediction::ACTION_INCORRECTLY_FLAGGED]);
     }
 }
index f8f4826..9064a0b 100644 (file)
@@ -111,17 +111,17 @@ Feature: Manage analytics models
     And I navigate to "Analytics > Analytics models" in site administration
     # View predictions
     When I select "C3" from the "contextid" singleselect
-    And I open the action menu in "Student 6" "table_row"
-    And I choose "View prediction details" in the open action menu
+    And I click on "View prediction details" "icon" in the "Student 6" "table_row"
     And I should see "Prediction details"
     And I should see "Any write action"
     And I should see "Read actions amount"
-    And I open the action menu in "Student 6" "table_row"
-    And I choose "Acknowledged" in the open action menu
-    And I open the action menu in "Student 5" "table_row"
-    And I choose "View prediction details" in the open action menu
-    And I open the action menu in "Student 5" "table_row"
-    And I choose "Not useful" in the open action menu
+    And I click on "Select Student 6 for bulk action" "checkbox" in the "Student 6" "table_row"
+    And I click on "Accept" "button"
+    And I click on "Confirm" "button" in the "Accept" "dialogue"
+    And I click on "View prediction details" "icon" in the "Student 5" "table_row"
+    And I click on "Select Student 5 for bulk action" "checkbox" in the "Student 5" "table_row"
+    And I click on "Not applicable" "button"
+    And I click on "Confirm" "button" in the "Not applicable" "dialogue"
     And I should see "No insights reported"
     # Clear predictions
     When I am on site homepage
index c092491..02166a8 100644 (file)
@@ -71,7 +71,7 @@ class analytics_manager_testcase extends advanced_testcase {
         $predictions = $DB->get_records('analytics_predictions');
         $prediction = reset($predictions);
         $prediction = new \core_analytics\prediction($prediction, array('whatever' => 'not used'));
-        $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $model->get_target());
+        $prediction->action_executed(\core_analytics\prediction::ACTION_USEFUL, $model->get_target());
 
         $predictioncontextid = $prediction->get_prediction_data()->contextid;
 
index 508d1f9..70d368d 100644 (file)
@@ -103,7 +103,7 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         $action = $DB->get_record('analytics_prediction_actions', array('userid' => $this->teacher2->id));
         $this->assertEquals(\core_analytics\prediction::ACTION_FIXED, $action->actionname);
 
-        $prediction->action_executed(\core_analytics\prediction::ACTION_NOT_USEFUL, $this->model->get_target());
+        $prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model->get_target());
         $recordset = $this->model->get_prediction_actions($this->context);
         $this->assertCount(2, $recordset);
         $recordset->close();
@@ -127,12 +127,14 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         list($ignored, $predictions) = $this->model->get_predictions($this->context, false);
         $this->assertCount(2, $predictions);
 
-        // Teacher 2 flags a prediction (it doesn't matter which one) as fixed.
+        // Teacher 2 flags a prediction (it doesn't matter which one).
         $prediction = reset($predictions);
         $prediction->action_executed(\core_analytics\prediction::ACTION_FIXED, $this->model->get_target());
+        $prediction->action_executed(\core_analytics\prediction::ACTION_NOT_APPLICABLE, $this->model->get_target());
+        $prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model->get_target());
 
         $recordset = $this->model->get_prediction_actions($this->context);
-        $this->assertCount(1, $recordset);
+        $this->assertCount(3, $recordset);
         $recordset->close();
 
         list($ignored, $predictions) = $this->model->get_predictions($this->context, true);
@@ -148,7 +150,7 @@ class analytics_prediction_actions_testcase extends advanced_testcase {
         $this->assertCount(2, $predictions);
 
         $recordset = $this->model->get_prediction_actions($this->context);
-        $this->assertCount(1, $recordset);
+        $this->assertCount(3, $recordset);
         $recordset->close();
     }
 }
index c3aeab3..2b437f6 100644 (file)
 /**
  * Unit tests for evaluation, training and prediction.
  *
+ * NOTE: in order to execute this test using a separate server for the
+ *       python ML backend you need to define these variables in your config.php file:
+ *
+ * define('TEST_MLBACKEND_PYTHON_HOST', '127.0.0.1');
+ * define('TEST_MLBACKEND_PYTHON_PORT', 5000);
+ * define('TEST_MLBACKEND_PYTHON_USERNAME', 'default');
+ * define('TEST_MLBACKEND_PYTHON_PASSWORD', 'sshhhh');
+ *
  * @package   core_analytics
  * @copyright 2017 David MonllaĆ³ {@link http://www.davidmonllao.com}
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
@@ -46,6 +54,23 @@ require_once(__DIR__ . '/../../course/lib.php');
  */
 class core_analytics_prediction_testcase extends advanced_testcase {
 
+    /**
+     * Purge all the mlbackend outputs.
+     *
+     * This is done automatically for mlbackends using the web server dataroot but
+     * other mlbackends may store files elsewhere and these files need to be removed.
+     *
+     * @return null
+     */
+    public function tearDown() {
+        $this->setAdminUser();
+
+        $models = \core_analytics\manager::get_all_models();
+        foreach ($models as $model) {
+            $model->delete();
+        }
+    }
+
     /**
      * test_static_prediction
      *
@@ -109,12 +134,18 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      * @param int $predictedrangeindex
      * @param int $nranges
      * @param string $predictionsprocessorclass
+     * @param array $forcedconfig
      * @return void
      */
-    public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $nranges, $predictionsprocessorclass) {
+    public function test_ml_training_and_prediction($timesplittingid, $predictedrangeindex, $nranges, $predictionsprocessorclass,
+            $forcedconfig) {
         global $DB;
 
         $this->resetAfterTest(true);
+
+        $this->set_forced_config($forcedconfig);
+        $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
+
         $this->setAdminuser();
         set_config('enabled_stores', 'logstore_standard', 'tool_log');
 
@@ -122,13 +153,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         $ncourses = 10;
         $this->generate_courses($ncourses);
 
-        // We repeat the test for all prediction processors.
-        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
-        if ($predictionsprocessor->is_ready() !== true) {
-            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
-        }
-
         $model = $this->add_perfect_model();
+
         $model->update(true, false, $timesplittingid, get_class($predictionsprocessor));
 
         // No samples trained yet.
@@ -250,6 +276,17 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         $this->assertCount(2, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
             \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
 
+        // Confirm that the files associated to the model are deleted on clear and on delete. The ML backend deletion
+        // processes will be triggered by these actions and any exception there would result in a failed test.
+        $model->clear();
+        $this->assertEquals(0, $DB->count_records('analytics_used_files',
+            array('modelid' => $model->get_id(), 'action' => 'trained')));
+        $this->assertCount(0, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
+            \core_analytics\dataset_manager::LABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
+        $this->assertCount(0, $fs->get_directory_files(\context_system::instance()->id, 'analytics',
+            \core_analytics\dataset_manager::UNLABELLED_FILEAREA, $model->get_id(), '/analysable/', true, false));
+        $model->delete();
+
         set_config('enabled_stores', '', 'tool_log');
         get_log_manager(true);
     }
@@ -273,11 +310,15 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      * test_ml_export_import
      *
      * @param string $predictionsprocessorclass The class name
+     * @param array $forcedconfig
      * @dataProvider provider_ml_processors
      */
-    public function test_ml_export_import($predictionsprocessorclass) {
-
+    public function test_ml_export_import($predictionsprocessorclass, $forcedconfig) {
         $this->resetAfterTest(true);
+
+        $this->set_forced_config($forcedconfig);
+        $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
+
         $this->setAdminuser();
         set_config('enabled_stores', 'logstore_standard', 'tool_log');
 
@@ -285,13 +326,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         $ncourses = 10;
         $this->generate_courses($ncourses);
 
-        // We repeat the test for all prediction processors.
-        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
-        if ($predictionsprocessor->is_ready() !== true) {
-            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
-        }
-
         $model = $this->add_perfect_model();
+
         $model->update(true, false, '\core\analytics\time_splitting\quarters', get_class($predictionsprocessor));
 
         $model->train();
@@ -355,15 +391,14 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      * @param int $nsamples
      * @param int $classes
      * @param string $predictionsprocessorclass
+     * @param array $forcedconfig
      * @return void
      */
-    public function test_ml_classifiers_return($success, $nsamples, $classes, $predictionsprocessorclass) {
+    public function test_ml_classifiers_return($success, $nsamples, $classes, $predictionsprocessorclass, $forcedconfig) {
         $this->resetAfterTest();
 
-        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
-        if ($predictionsprocessor->is_ready() !== true) {
-            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
-        }
+        $this->set_forced_config($forcedconfig);
+        $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
 
         if ($nsamples % count($classes) != 0) {
             throw new \coding_exception('The number of samples should be divisible by the number of classes');
@@ -396,7 +431,8 @@ class core_analytics_prediction_testcase extends advanced_testcase {
 
         // Training should work correctly if at least 1 sample of each class is included.
         $dir = make_request_directory();
-        $result = $predictionsprocessor->train_classification('whatever', $dataset, $dir);
+        $modeluniqueid = 'whatever' . microtime();
+        $result = $predictionsprocessor->train_classification($modeluniqueid, $dataset, $dir);
 
         switch ($success) {
             case 'yes':
@@ -411,6 +447,10 @@ class core_analytics_prediction_testcase extends advanced_testcase {
                 // what we really want to check is that an exception was not thrown.
                 $this->assertInstanceOf(\stdClass::class, $result);
         }
+
+        // Purge the directory used in this test (useful in case the mlbackend is storing files
+        // somewhere out of the default moodledata/models dir.
+        $predictionsprocessor->delete_output_dir($dir, $modeluniqueid);
     }
 
     /**
@@ -441,16 +481,19 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      * @dataProvider provider_test_multi_classifier
      * @param string $timesplittingid
      * @param string $predictionsprocessorclass
+     * @param array|null $forcedconfig
      * @throws coding_exception
      * @throws moodle_exception
      */
-    public function test_ml_multi_classifier($timesplittingid, $predictionsprocessorclass) {
+    public function test_ml_multi_classifier($timesplittingid, $predictionsprocessorclass, $forcedconfig) {
         global $DB;
 
         $this->resetAfterTest(true);
         $this->setAdminuser();
         set_config('enabled_stores', 'logstore_standard', 'tool_log');
 
+        $this->set_forced_config($forcedconfig);
+
         $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
         if ($predictionsprocessor->is_ready() !== true) {
             $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
@@ -483,6 +526,9 @@ class core_analytics_prediction_testcase extends advanced_testcase {
             // The range index is not important here, both ranges prediction will be the same.
             $this->assertEquals($correct[$sampleid], $predictiondata->prediction);
         }
+
+        set_config('enabled_stores', '', 'tool_log');
+        get_log_manager(true);
     }
 
     /**
@@ -508,10 +554,16 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      * @param int $ncourses
      * @param array $expected
      * @param string $predictionsprocessorclass
+     * @param array $forcedconfig
      * @return void
      */
-    public function test_ml_evaluation_configuration($modelquality, $ncourses, $expected, $predictionsprocessorclass) {
+    public function test_ml_evaluation_configuration($modelquality, $ncourses, $expected, $predictionsprocessorclass,
+            $forcedconfig) {
         $this->resetAfterTest(true);
+
+        $this->set_forced_config($forcedconfig);
+        $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
+
         $this->setAdminuser();
         set_config('enabled_stores', 'logstore_standard', 'tool_log');
 
@@ -530,12 +582,6 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         // Generate training data.
         $this->generate_courses($ncourses);
 
-        // We repeat the test for all prediction processors.
-        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
-        if ($predictionsprocessor->is_ready() !== true) {
-            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
-        }
-
         $model->update(false, false, false, get_class($predictionsprocessor));
         $results = $model->evaluate();
 
@@ -563,10 +609,15 @@ class core_analytics_prediction_testcase extends advanced_testcase {
      * @coversNothing
      * @dataProvider provider_ml_processors
      * @param  string $predictionsprocessorclass
+     * @param array $forcedconfig
      * @return null
      */
-    public function test_ml_evaluation_trained_model($predictionsprocessorclass) {
+    public function test_ml_evaluation_trained_model($predictionsprocessorclass, $forcedconfig) {
         $this->resetAfterTest(true);
+
+        $this->set_forced_config($forcedconfig);
+        $predictionsprocessor = $this->is_predictions_processor_ready($predictionsprocessorclass);
+
         $this->setAdminuser();
         set_config('enabled_stores', 'logstore_standard', 'tool_log');
 
@@ -575,12 +626,6 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         // Generate training data.
         $this->generate_courses(50);
 
-        // We repeat the test for all prediction processors.
-        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
-        if ($predictionsprocessor->is_ready() !== true) {
-            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready.');
-        }
-
         $model->update(true, false, '\\core\\analytics\\time_splitting\\quarters', get_class($predictionsprocessor));
         $model->train();
 
@@ -824,6 +869,41 @@ class core_analytics_prediction_testcase extends advanced_testcase {
         }
     }
 
+    /**
+     * Forces some configuration values.
+     *
+     * @param array $forcedconfig
+     */
+    protected function set_forced_config($forcedconfig) {
+        \core_analytics\manager::reset_prediction_processors();
+
+        if (empty($forcedconfig)) {
+            return;
+        }
+        foreach ($forcedconfig as $pluginname => $pluginconfig) {
+            foreach ($pluginconfig as $name => $value) {
+                set_config($name, $value, $pluginname);
+            }
+        }
+    }
+
+    /**
+     * Is the provided processor ready using the current configuration in the site?
+     *
+     * @param  string  $predictionsprocessorclass
+     * @return \core_analytics\predictor
+     */
+    protected function is_predictions_processor_ready(string $predictionsprocessorclass) {
+        // We repeat the test for all prediction processors.
+        $predictionsprocessor = \core_analytics\manager::get_predictions_processor($predictionsprocessorclass, false);
+        $ready = $predictionsprocessor->is_ready();
+        if ($ready !== true) {
+            $this->markTestSkipped('Skipping ' . $predictionsprocessorclass . ' as the predictor is not ready: ' . $ready);
+        }
+
+        return $predictionsprocessor;
+    }
+
     /**
      * add_prediction_processors
      *
@@ -834,12 +914,29 @@ class core_analytics_prediction_testcase extends advanced_testcase {
 
         $return = array();
 
-        // We need to test all system prediction processors.
+        if (defined('TEST_MLBACKEND_PYTHON_HOST') && defined('TEST_MLBACKEND_PYTHON_PORT')
+                && defined('TEST_MLBACKEND_PYTHON_USERNAME') && defined('TEST_MLBACKEND_PYTHON_USERNAME')) {
+            $testpythonserver = true;
+        }
+
+        // We need to test all prediction processors in the system.
         $predictionprocessors = \core_analytics\manager::get_all_prediction_processors();
-        foreach ($predictionprocessors as $classfullname => $unused) {
+        foreach ($predictionprocessors as $classfullname => $predictionsprocessor) {
             foreach ($cases as $key => $case) {
-                $newkey = $key . '-' . $classfullname;
-                $return[$newkey] = $case + array('predictionsprocessorclass' => $classfullname);
+
+                if (!$predictionsprocessor instanceof \mlbackend_python\processor || empty($testpythonserver)) {
+                    $extraparams = ['predictionsprocessor' => $classfullname, 'forcedconfig' => null];
+                    $return[$key . '-' . $classfullname] = $case + $extraparams;
+                } else {
+
+                    // We want the configuration to be forced during the test as things like importing models create new
+                    // instances of ML backend processors during the process.
+                    $forcedconfig = ['mlbackend_python' => ['useserver' => true, 'host' => TEST_MLBACKEND_PYTHON_HOST,
+                        'port' => TEST_MLBACKEND_PYTHON_PORT, 'secure' => false, 'username' => TEST_MLBACKEND_PYTHON_USERNAME,
+                        'password' => TEST_MLBACKEND_PYTHON_PASSWORD]];
+                    $casekey = $key . '-' . $classfullname . '-server';
+                    $return[$casekey] = $case + ['predictionsprocessor' => $classfullname, 'forcedconfig' => $forcedconfig];
+                }
             }
         }
 
index 57f113b..21fdd28 100644 (file)
@@ -108,7 +108,7 @@ class core_analytics_privacy_model_testcase extends \core_privacy\tests\provider
 
         $this->setUser($this->u3);
         $prediction = reset($predictions);
-        $prediction->action_executed('notuseful', $this->model2->get_target());
+        $prediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model2->get_target());
 
         $this->setAdminUser();
     }
@@ -381,7 +381,7 @@ class core_analytics_privacy_model_testcase extends \core_privacy\tests\provider
             }
         }
         $this->setUser($this->u3);
-        $otheruserprediction->action_executed('notuseful', $this->model1->get_target());
+        $otheruserprediction->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $this->model1->get_target());
         $this->setAdminUser();
 
         $this->export_context_data_for_user($this->u3->id, $system, 'core_analytics');
@@ -411,7 +411,7 @@ class core_analytics_privacy_model_testcase extends \core_privacy\tests\provider
             get_string('privacy:metadata:analytics:predictionactions', 'analytics'), $u3action->id]);
         $this->assertEquals(get_string('adminhelplogs'), $data->target);
         $this->assertEquals(get_string('coresystem'), $data->context);
-        $this->assertEquals('notuseful', $data->action);
+        $this->assertEquals(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $data->action);
 
     }
 }
index d543fa1..5054a3f 100644 (file)
@@ -156,7 +156,7 @@ class analytics_stats_testcase extends advanced_testcase {
         $this->assertEquals(0, \core_analytics\stats::actions_not_useful());
 
         // The user has marked the other prediction as not useful.
-        $p2->action_executed(\core_analytics\prediction::ACTION_NOT_USEFUL, $model->get_target());
+        $p2->action_executed(\core_analytics\prediction::ACTION_INCORRECTLY_FLAGGED, $model->get_target());
         $this->assertEquals(2, \core_analytics\stats::actions());
         $this->assertEquals(1, \core_analytics\stats::actions_not_useful());
     }
index adb4b9c..38395c3 100644 (file)
@@ -15,6 +15,21 @@ information provided here is intended especially for developers.
 * 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.
+* Targets can now specify a list of bulk actions in bulk_actions(). core_analytics\prediction_action is now
+  extending core_analytics\action and a new core_analytics\bulk_action class has been added. Actions can now
+  specify a type in its constructor: core_analytics\action::TYPE_POSITIVE, TYPE_NEUTRAL or TYPE_NEGATIVE. A list
+  of default bulk actions is available in \core_analytics\default_bulk_actions.
+* The default suggested actions provided to users changed:
+    * For targets with one single sample per analysable (e.g. upcoming activities due) the default actions are
+      Useful and Not useful.
+    * For targets with multiple samples per analysable (e.g. students at risk) the default actions are
+      Accept, Not applicable and Incorrectly flagged.
+* The suggested actions for the existing models have been reworded:
+    * Predictions flagged as "Acknowledged" in models whose targets use analysers that provide one sample per
+      analysable (e.g. upcoming activities due) have been updated to "Useful" flag.
+    * Predictions flagged as "Not useful" in models whose targets use analysers that provide multiple samples
+      per analysable (e.g. students at risk or no teaching) have been updated to "Incorrectly flagged".
+* \core_analytics\predictor::delete_output_dir has a new 2nd parameter, $uniquemodelid.
 
 === 3.7 ===
 
index 1b152b5..e5b0789 100644 (file)
@@ -402,7 +402,6 @@ class api {
      * @return bool
      */
     public static function is_enabled() {
-        $plugininfo = \core_plugin_manager::instance()->get_plugin_info('auth_oauth2');
-        return $plugininfo->is_enabled();
+        return is_enabled_auth('oauth2');
     }
 }
index 83bf1a6..d2fc8e1 100644 (file)
@@ -140,4 +140,23 @@ class auth_oauth2_external_testcase extends advanced_testcase {
         $this->assertEquals($newuser->id, $match->get('userid'));
     }
 
+    /**
+     * Test that is_enabled correctly identifies when the plugin is enabled.
+     */
+    public function test_is_enabled() {
+        $this->resetAfterTest();
+
+        set_config('auth', 'manual,oauth2');
+        $this->assertTrue(\auth_oauth2\api::is_enabled());
+    }
+
+    /**
+     * Test that is_enabled correctly identifies when the plugin is disabled.
+     */
+    public function test_is_enabled_disabled() {
+        $this->resetAfterTest();
+
+        set_config('auth', 'manual');
+        $this->assertFalse(\auth_oauth2\api::is_enabled());
+    }
 }
index cf5fcf9..52793b2 100644 (file)
@@ -55,6 +55,7 @@ class backup_controller extends base_controller {
 
     protected $status; // Current status of the controller (created, planned, configured...)
 
+    /** @var backup_plan */
     protected $plan;   // Backup execution plan
     protected $includefiles; // Whether this backup includes files or not.
 
index 6a060ab..32f6c1d 100644 (file)
@@ -34,13 +34,27 @@ defined('MOODLE_INTERNAL') || die();
  */
 abstract class backup_plugin {
 
+    /** @var string */
     protected $plugintype;
+    /** @var string */
     protected $pluginname;
+    /** @var string */
     protected $connectionpoint;
+    /** @var backup_optigroup_element */
     protected $optigroup; // Optigroup, parent of all optigroup elements
+    /** @var backup_structure_step */
     protected $step;
+    /** @var backup_course_task|backup_activity_task */
     protected $task;
 
+    /**
+     * backup_plugin constructor.
+     *
+     * @param string $plugintype
+     * @param string $pluginname
+     * @param backup_optigroup_element $optigroup
+     * @param backup_structure_step $step
+     */
     public function __construct($plugintype, $pluginname, $optigroup, $step) {
         $this->plugintype = $plugintype;
         $this->pluginname = $pluginname;
index 11dd4ce..ac8c917 100644 (file)
@@ -125,10 +125,9 @@ abstract class backup_activity_structure_step extends backup_structure_step {
 }
 
 /**
- * Abstract structure step, to be used by all the activities using core questions stuff
- * (namely quiz module), supporting question plugins, states and sessions
+ * Helper code for use by any plugin that stores question attempt data that it needs to back up.
  */
-abstract class backup_questions_activity_structure_step extends backup_activity_structure_step {
+trait backup_questions_attempt_data_trait {
 
     /**
      * Attach to $element (usually attempts) the needed backup structures
@@ -200,6 +199,17 @@ abstract class backup_questions_activity_structure_step extends backup_activity_
 }
 
 
+/**
+ * Abstract structure step to help activities that store question attempt data.
+ *
+ * @copyright 2011 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class backup_questions_activity_structure_step extends backup_activity_structure_step {
+    use backup_questions_attempt_data_trait;
+}
+
+
 /**
  * backup structure step in charge of calculating the categories to be
  * included in backup, based in the context being backuped (module/course)
@@ -275,6 +285,9 @@ class backup_module_structure_step extends backup_structure_step {
         // attach format plugin structure to $module element, only one allowed
         $this->add_plugin_structure('format', $module, false);
 
+        // Attach report plugin structure to $module element, multiple allowed.
+        $this->add_plugin_structure('report', $module, true);
+
         // attach plagiarism plugin structure to $module element, there can be potentially
         // many plagiarism plugins storing information about this course
         $this->add_plugin_structure('plagiarism', $module, true);
index b90bcdf..31a94f5 100644 (file)
@@ -34,12 +34,24 @@ defined('MOODLE_INTERNAL') || die();
  */
 abstract class restore_plugin {
 
+    /** @var string */
     protected $plugintype;
+    /** @var string */
     protected $pluginname;
+    /** @var string */
     protected $connectionpoint;
+    /** @var restore_structure_step */
     protected $step;
+    /** @var restore_course_task|restore_activity_task */
     protected $task;
 
+    /**
+     * restore_plugin constructor.
+     *
+     * @param string $plugintype
+     * @param string $pluginname
+     * @param restore_structure_step $step
+     */
     public function __construct($plugintype, $pluginname, $step) {
         $this->plugintype = $plugintype;
         $this->pluginname = $pluginname;
@@ -58,9 +70,11 @@ abstract class restore_plugin {
         $methodname = 'define_' . basename($this->connectionpoint->get_path()) . '_plugin_structure';
 
         if (method_exists($this, $methodname)) {
-            if ($bluginpaths = $this->$methodname()) {
-                foreach ($bluginpaths as $path) {
-                    $path->set_processing_object($this);
+            if ($pluginpaths = $this->$methodname()) {
+                foreach ($pluginpaths as $path) {
+                    if ($path->get_processing_object() === null && !$this->step->grouped_parent_exists($path, $paths)) {
+                        $path->set_processing_object($this);
+                    }
                     $paths[] = $path;
                 }
             }
@@ -258,4 +272,13 @@ abstract class restore_plugin {
                'plugin_' . $this->plugintype . '_' .
                $this->pluginname . '_' . basename($this->connectionpoint->get_path()) . $path;
     }
+
+    /**
+     * Get the task we are part of.
+     *
+     * @return restore_activity_task|restore_course_task the task.
+     */
+    protected function get_task() {
+        return $this->task;
+    }
 }
index 0d6518d..10199e0 100644 (file)
@@ -4180,6 +4180,9 @@ class restore_module_structure_step extends restore_structure_step {
         // Apply for 'format' plugins optional paths at module level
         $this->add_plugin_structure('format', $module);
 
+        // Apply for 'report' plugins optional paths at module level.
+        $this->add_plugin_structure('report', $module);
+
         // Apply for 'plagiarism' plugins optional paths at module level
         $this->add_plugin_structure('plagiarism', $module);
 
@@ -5323,10 +5326,9 @@ class restore_process_file_aliases_queue extends restore_execution_step {
 
 
 /**
- * Abstract structure step, to be used by all the activities using core questions stuff
- * (like the quiz module), to support qtype plugins, states and sessions
+ * Helper code for use by any plugin that stores question attempt data that it needs to back up.
  */
-abstract class restore_questions_activity_structure_step extends restore_activity_structure_step {
+trait restore_questions_attempt_data_trait {
     /** @var array question_attempt->id to qtype. */
     protected $qtypes = array();
     /** @var array question_attempt->id to questionid. */
@@ -5378,21 +5380,21 @@ abstract class restore_questions_activity_structure_step extends restore_activit
     /**
      * Process question_usages
      */
-    protected function process_question_usage($data) {
+    public function process_question_usage($data) {
         $this->restore_question_usage_worker($data, '');
     }
 
     /**
      * Process question_attempts
      */
-    protected function process_question_attempt($data) {
+    public function process_question_attempt($data) {
         $this->restore_question_attempt_worker($data, '');
     }
 
     /**
      * Process question_attempt_steps
      */
-    protected function process_question_attempt_step($data) {
+    public function process_question_attempt_step($data) {
         $this->restore_question_attempt_step_worker($data, '');
     }
 
@@ -5412,8 +5414,7 @@ abstract class restore_questions_activity_structure_step extends restore_activit
         $data = (object)$data;
         $oldid = $data->id;
 
-        $oldcontextid = $this->get_task()->get_old_contextid();
-        $data->contextid  = $this->get_mappingid('context', $this->task->get_old_contextid());
+        $data->contextid  = $this->task->get_contextid();
 
         // Everything ready, insert (no mapping needed)
         $newitemid = $DB->insert_record('question_usages', $data);
@@ -5569,6 +5570,17 @@ abstract class restore_questions_activity_structure_step extends restore_activit
             $this->add_related_files('question', $filearea, 'question_attempt_step');
         }
     }
+}
+
+
+/**
+ * Abstract structure step to help activities that store question attempt data.
+ *
+ * @copyright 2011 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+abstract class restore_questions_activity_structure_step extends restore_activity_structure_step {
+    use restore_questions_attempt_data_trait;
 
     /**
      * Attach below $element (usually attempts) the needed restore_path_elements
@@ -5612,7 +5624,7 @@ abstract class restore_questions_activity_structure_step extends restore_activit
     /**
      * Process the attempt data defined by {@link add_legacy_question_attempt_data()}.
      * @param object $data contains all the grouped attempt data to process.
-     * @param pbject $quiz data about the activity the attempts belong to. Required
+     * @param object $quiz data about the activity the attempts belong to. Required
      * fields are (basically this only works for the quiz module):
      *      oldquestions => list of question ids in this activity - using old ids.
      *      preferredbehaviour => the behaviour to use for questionattempts.
@@ -5674,7 +5686,7 @@ abstract class restore_questions_activity_structure_step extends restore_activit
 
         $data->uniqueid = $usage->id;
         $upgrader->save_usage($quiz->preferredbehaviour, $data, $qas,
-                 $this->questions_recode_layout($quiz->oldquestions));
+                $this->questions_recode_layout($quiz->oldquestions));
     }
 
     protected function find_question_session_and_states($data, $questionid) {
@@ -5734,6 +5746,7 @@ abstract class restore_questions_activity_structure_step extends restore_activit
     }
 }
 
+
 /**
  * Restore completion defaults for each module type
  *
index bffc7b7..3fe1978 100644 (file)
@@ -496,9 +496,9 @@ abstract class restore_structure_step extends restore_step {
         // Now, for each element not having one processing object, if
         // not child of grouped element, assign $this (the step itself) as processing element
         // Note method must exist or we'll get one @restore_path_element_exception
-        foreach($paths as $key => $pelement) {
+        foreach ($paths as $pelement) {
             if ($pelement->get_processing_object() === null && !$this->grouped_parent_exists($pelement, $paths)) {
-                $paths[$key]->set_processing_object($this);
+                $pelement->set_processing_object($this);
             }
             // Populate $elementsoldid and $elementsoldid based on available pathelements
             $this->elementsoldid[$pelement->get_name()] = null;
@@ -510,18 +510,22 @@ abstract class restore_structure_step extends restore_step {
 
     /**
      * Given one pathelement, return true if grouped parent was found
+     *
+     * @param restore_path_element $pelement the element we are interested in.
+     * @param restore_path_element[] $elements the elements that exist.
+     * @return bool true if this element is inside a grouped parent.
      */
-    protected function grouped_parent_exists($pelement, $elements) {
+    public function grouped_parent_exists($pelement, $elements) {
         foreach ($elements as $element) {
             if ($pelement->get_path() == $element->get_path()) {
-                continue; // Don't compare against itself
+                continue; // Don't compare against itself.
             }
-            // If element is grouped and parent of pelement, return true
+            // If element is grouped and parent of pelement, return true.
             if ($element->is_grouped() and strpos($pelement->get_path() .  '/', $element->get_path()) === 0) {
                 return true;
             }
         }
-        return false; // no grouped parent found
+        return false; // No grouped parent found.
     }
 
     /**
index 9525019..872495e 100644 (file)
@@ -127,7 +127,12 @@ class core_badges_assertion {
             $hash = $this->_data->uniquehash;
             $email = empty($this->_data->backpackemail) ? $this->_data->email : $this->_data->backpackemail;
             $assertionurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'obversion' => $this->_obversion));
-            $classurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'action' => 1));
+
+            if ($this->_obversion == OPEN_BADGES_V2) {
+                $classurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id()));
+            } else {
+                $classurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'action' => 1));
+            }
 
             // Required.
             $assertion['uid'] = $hash;
@@ -193,7 +198,11 @@ class core_badges_assertion {
             $class['image'] = 'data:image/png;base64,' . $imagedata;
             $class['criteria'] = $this->_url->out(false); // Currently issued badge URL.
             if ($issued) {
-                $issuerurl = new moodle_url('/badges/assertion.php', array('b' => $this->_data->uniquehash, 'action' => 0));
+                if ($this->_obversion == OPEN_BADGES_V2) {
+                    $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id(), 'action' => 0));
+                } else {
+                    $issuerurl = new moodle_url('/badges/assertion.php', array('b' => $this->_data->uniquehash, 'action' => 0));
+                }
                 $class['issuer'] = $issuerurl->out(false);
             }
             $this->embed_data_badge_version2($class, OPEN_BADGES_V2_TYPE_BADGE);
@@ -329,10 +338,10 @@ class core_badges_assertion {
             $hash = $this->_data->uniquehash;
             $assertionsurl = new moodle_url('/badges/assertion.php', array('b' => $hash, 'obversion' => $this->_obversion));
             $classurl = new moodle_url(
-                '/badges/assertion.php',
-                array('b' => $hash, 'action' => 1, 'obversion' => $this->_obversion)
+                '/badges/badge_json.php',
+                array('id' => $this->get_badge_id())
             );
-            $issuerurl = new moodle_url('/badges/assertion.php', array('b' => $this->_data->uniquehash, 'action' => 0,
+            $issuerurl = new moodle_url('/badges/badge_json.php', array('id' => $this->get_badge_id(), 'action' => 0,
                 'obversion' => $this->_obversion));
             // For assertion.
             if ($type == OPEN_BADGES_V2_TYPE_ASSERTION) {
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 ad9bc00..1a3faa1 100644 (file)
@@ -58,5 +58,11 @@ class cachestore_redis_addinstance_form extends cachestore_addinstance_form {
         $form->addHelpButton('serializer', 'useserializer', 'cachestore_redis');
         $form->setDefault('serializer', Redis::SERIALIZER_PHP);
         $form->setType('serializer', PARAM_INT);
+
+        $compressoroptions = cachestore_redis::config_get_compressor_options();
+        $form->addElement('select', 'compressor', get_string('usecompressor', 'cachestore_redis'), $compressoroptions);
+        $form->addHelpButton('compressor', 'usecompressor', 'cachestore_redis');
+        $form->setDefault('compressor', cachestore_redis::COMPRESSOR_NONE);
+        $form->setType('compressor', PARAM_INT);
     }
 }
index 0d155be..a52b93f 100644 (file)
@@ -24,6 +24,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['compressor_none'] = 'No compression.';
+$string['compressor_php_gzip'] = 'Use gzip compression.';
+$string['compressor_php_zstd'] = 'Use Zstandard compression.';
 $string['pluginname'] = 'Redis';
 $string['prefix'] = 'Key prefix';
 $string['prefix_help'] = 'This prefix is used for all key names on the Redis server.
@@ -48,3 +51,5 @@ $string['useserializer'] = 'Use serializer';
 $string['useserializer_help'] = 'Specifies the serializer to use for serializing.
 The valid serializers are Redis::SERIALIZER_PHP or Redis::SERIALIZER_IGBINARY.
 The latter is supported only when phpredis is configured with --enable-redis-igbinary option and the igbinary extension is loaded.';
+$string['usecompressor'] = 'Use compressor';
+$string['usecompressor_help'] = 'Specifies the compressor to use after serializing. It is done at Moodle Cache API level, not at php-redis level.';
index 3596200..bde6b9e 100644 (file)
@@ -38,6 +38,21 @@ defined('MOODLE_INTERNAL') || die();
  */
 class cachestore_redis extends cache_store implements cache_is_key_aware, cache_is_lockable,
         cache_is_configurable, cache_is_searchable {
+    /**
+     * Compressor: none.
+     */
+    const COMPRESSOR_NONE = 0;
+
+    /**
+     * Compressor: PHP GZip.
+     */
+    const COMPRESSOR_PHP_GZIP = 1;
+
+    /**
+     * Compressor: PHP Zstandard.
+     */
+    const COMPRESSOR_PHP_ZSTD = 2;
+
     /**
      * Name of this store.
      *
@@ -80,6 +95,13 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
      */
     protected $serializer = Redis::SERIALIZER_PHP;
 
+    /**
+     * Compressor for this store.
+     *
+     * @var int
+     */
+    protected $compressor = self::COMPRESSOR_NONE;
+
     /**
      * Determines if the requirements for this type of store are met.
      *
@@ -134,6 +156,9 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
         if (array_key_exists('serializer', $configuration)) {
             $this->serializer = (int)$configuration['serializer'];
         }
+        if (array_key_exists('compressor', $configuration)) {
+            $this->compressor = (int)$configuration['compressor'];
+        }
         $password = !empty($configuration['password']) ? $configuration['password'] : '';
         $prefix = !empty($configuration['prefix']) ? $configuration['prefix'] : '';
         $this->redis = $this->new_redis($configuration['server'], $prefix, $password);
@@ -161,7 +186,10 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
             if (!empty($password)) {
                 $redis->auth($password);
             }
-            $redis->setOption(Redis::OPT_SERIALIZER, $this->serializer);
+            // If using compressor, serialisation will be done at cachestore level, not php-redis.
+            if ($this->compressor == self::COMPRESSOR_NONE) {
+                $redis->setOption(Redis::OPT_SERIALIZER, $this->serializer);
+            }
             if (!empty($prefix)) {
                 $redis->setOption(Redis::OPT_PREFIX, $prefix);
             }
@@ -236,7 +264,13 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
      * @return mixed The value of the key, or false if there is no value associated with the key.
      */
     public function get($key) {
-        return $this->redis->hGet($this->hash, $key);
+        $value = $this->redis->hGet($this->hash, $key);
+
+        if ($this->compressor == self::COMPRESSOR_NONE) {
+            return $value;
+        }
+
+        return $this->uncompress($value);
     }
 
     /**
@@ -246,7 +280,17 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
      * @return array An array of the values of the given keys.
      */
     public function get_many($keys) {
-        return $this->redis->hMGet($this->hash, $keys);
+        $values = $this->redis->hMGet($this->hash, $keys);
+
+        if ($this->compressor == self::COMPRESSOR_NONE) {
+            return $values;
+        }
+
+        foreach ($values as &$value) {
+            $value = $this->uncompress($value);
+        }
+
+        return $values;
     }
 
     /**
@@ -257,6 +301,10 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
      * @return bool True if the operation succeeded, false otherwise.
      */
     public function set($key, $value) {
+        if ($this->compressor != self::COMPRESSOR_NONE) {
+            $value = $this->compress($value);
+        }
+
         return ($this->redis->hSet($this->hash, $key, $value) !== false);
     }
 
@@ -270,7 +318,12 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
     public function set_many(array $keyvaluearray) {
         $pairs = [];
         foreach ($keyvaluearray as $pair) {
-            $pairs[$pair['key']] = $pair['value'];
+            $key = $pair['key'];
+            if ($this->compressor != self::COMPRESSOR_NONE) {
+                $pairs[$key] = $this->compress($pair['value']);
+            } else {
+                $pairs[$key] = $pair['value'];
+            }
         }
         if ($this->redis->hMSet($this->hash, $pairs)) {
             return count($pairs);
@@ -446,7 +499,8 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
             'server' => $data->server,
             'prefix' => $data->prefix,
             'password' => $data->password,
-            'serializer' => $data->serializer
+            'serializer' => $data->serializer,
+            'compressor' => $data->compressor,
         );
     }
 
@@ -465,6 +519,9 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
         if (!empty($config['serializer'])) {
             $data['serializer'] = $config['serializer'];
         }
+        if (!empty($config['compressor'])) {
+            $data['compressor'] = $config['compressor'];
+        }
         $editform->set_data($data);
     }
 
@@ -538,4 +595,115 @@ class cachestore_redis extends cache_store implements cache_is_key_aware, cache_
         }
         return $options;
     }
+
+    /**
+     * Gets an array of options to use as the compressor.
+     *
+     * @return array
+     */
+    public static function config_get_compressor_options() {
+        $arr = [
+            self::COMPRESSOR_NONE     => get_string('compressor_none', 'cachestore_redis'),
+            self::COMPRESSOR_PHP_GZIP => get_string('compressor_php_gzip', 'cachestore_redis'),
+        ];
+
+        // Check if the Zstandard PHP extension is installed.
+        if (extension_loaded('zstd')) {
+            $arr[self::COMPRESSOR_PHP_ZSTD] = get_string('compressor_php_zstd', 'cachestore_redis');
+        }
+
+        return $arr;
+    }
+
+    /**
+     * Compress the given value, serializing it first.
+     *
+     * @param mixed $value
+     * @return string
+     */
+    private function compress($value) {
+        $value = $this->serialize($value);
+
+        switch ($this->compressor) {
+            case self::COMPRESSOR_NONE:
+                return $value;
+
+            case self::COMPRESSOR_PHP_GZIP:
+                return gzencode($value);
+
+            case self::COMPRESSOR_PHP_ZSTD:
+                return zstd_compress($value);
+
+            default:
+                debugging("Invalid compressor: {$this->compressor}");
+                return $value;
+        }
+    }
+
+    /**
+     * Uncompresses (deflates) the data, unserialising it afterwards.
+     *
+     * @param string $value
+     * @return mixed
+     */
+    private function uncompress($value) {
+        if ($value === false) {
+            return false;
+        }
+
+        switch ($this->compressor) {
+            case self::COMPRESSOR_NONE:
+                break;
+            case self::COMPRESSOR_PHP_GZIP:
+                $value = gzdecode($value);
+                break;
+            case self::COMPRESSOR_PHP_ZSTD:
+                $value = zstd_uncompress($value);
+                break;
+            default:
+                debugging("Invalid compressor: {$this->compressor}");
+        }
+
+        return $this->unserialize($value);
+    }
+
+    /**
+     * Serializes the data according to the configured serializer.
+     *
+     * @param mixed $value
+     * @return string
+     */
+    private function serialize($value) {
+        switch ($this->serializer) {
+            case Redis::SERIALIZER_NONE:
+                return $value;
+            case Redis::SERIALIZER_PHP:
+                return serialize($value);
+            case defined('Redis::SERIALIZER_IGBINARY') && Redis::SERIALIZER_IGBINARY:
+                return igbinary_serialize($value);
+            default:
+                debugging("Invalid serializer: {$this->serializer}");
+                return $value;
+        }
+    }
+
+    /**
+     * Unserializes the data according to the configured serializer
+     *
+     * @param string $value
+     * @return mixed
+     */
+    private function unserialize($value) {
+        switch ($this->serializer) {
+            case Redis::SERIALIZER_NONE:
+                return $value;
+            case Redis::SERIALIZER_PHP:
+                return unserialize($value);
+            case defined('Redis::SERIALIZER_IGBINARY') && Redis::SERIALIZER_IGBINARY:
+                return igbinary_unserialize($value);
+            default:
+                debugging("Invalid serializer: {$this->serializer}");
+                return $value;
+        }
+    }
 }
diff --git a/cache/stores/redis/tests/compressor_test.php b/cache/stores/redis/tests/compressor_test.php
new file mode 100644 (file)
index 0000000..0d9a583
--- /dev/null
@@ -0,0 +1,287 @@
+<?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/>.
+
+/**
+ * Redis cache test.
+ *
+ * If you wish to use these unit tests all you need to do is add the following definition to
+ * your config.php file.
+ *
+ * define('TEST_CACHESTORE_REDIS_TESTSERVERS', '127.0.0.1');
+ *
+ * @package   cachestore_redis
+ * @copyright 2018 Catalyst IT Australia {@link http://www.catalyst-au.net}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__.'/../../../tests/fixtures/stores.php');
+require_once(__DIR__.'/../lib.php');
+
+/**
+ * Redis cache test - compressor settings.
+ *
+ * @package   cachestore_redis
+ * @author    Daniel Thee Roperto <daniel.roperto@catalyst-au.net>
+ * @copyright 2018 Catalyst IT Australia {@link http://www.catalyst-au.net}
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class cachestore_redis_compressor_test extends advanced_testcase {
+
+    /**
+     * Test set up
+     */
+    public function setUp() {
+        if (!cachestore_redis::are_requirements_met() || !defined('TEST_CACHESTORE_REDIS_TESTSERVERS')) {
+            $this->markTestSkipped('Could not test cachestore_redis. Requirements are not met.');
+        }
+
+        parent::setUp();
+    }
+
+    /**
+     * Create a cachestore.
+     *
+     * @param int $compressor
+     * @param int $serializer
+     * @return cachestore_redis
+     */
+    public function create_store($compressor, $serializer) {
+        /** @var cache_definition $definition */
+        $definition = cache_definition::load_adhoc(cache_store::MODE_APPLICATION, 'cachestore_redis', 'phpunit_test');
+        $config = cachestore_redis::unit_test_configuration();
+        $config['compressor'] = $compressor;
+        $config['serializer'] = $serializer;
+        $store = new cachestore_redis('Test', $config);
+        $store->initialise($definition);
+
+        return $store;
+    }
+
+    /**
+     * It misses a value.
+     */
+    public function test_it_can_miss_one() {
+        $store = $this->create_store(cachestore_redis::COMPRESSOR_PHP_GZIP, Redis::SERIALIZER_PHP);
+
+        self::assertFalse($store->get('missme'));
+    }
+
+    /**
+     * It misses many values.
+     */
+    public function test_it_can_miss_many() {
+        $store = $this->create_store(cachestore_redis::COMPRESSOR_PHP_GZIP, Redis::SERIALIZER_PHP);
+
+        $expected = ['missme' => false, 'missmetoo' => false];
+        $actual = $store->get_many(array_keys($expected));
+        self::assertSame($expected, $actual);
+    }
+
+    /**
+     * It misses some values.
+     */
+    public function test_it_can_miss_some() {
+        $store = $this->create_store(cachestore_redis::COMPRESSOR_PHP_GZIP, Redis::SERIALIZER_PHP);
+        $store->set('iamhere', 'youfoundme');
+
+        $expected = ['missme' => false, 'missmetoo' => false, 'iamhere' => 'youfoundme'];
+        $actual = $store->get_many(array_keys($expected));
+        self::assertSame($expected, $actual);
+    }
+
+    /**
+     * A provider for test_works_with_different_types
+     *
+     * @return array
+     */
+    public function provider_for_test_it_works_with_different_types() {
+        $object = new stdClass();
+        $object->field = 'value';
+
+        return [
+            ['string', 'Abc Def'],
+            ['string_empty', ''],
+            ['string_binary', gzencode('some binary data')],
+            ['int', 123],
+            ['int_zero', 0],
+            ['int_negative', -100],
+            ['int_huge', PHP_INT_MAX],
+            ['float', 3.14],
+            ['boolean_true', true],
+            // Boolean 'false' is not tested as it is not allowed in Moodle.
+            ['array', [1, 'b', 3.4]],
+            ['array_map', ['a' => 'b', 'c' => 'd']],
+            ['object_stdClass', $object],
+            ['null', null],
+        ];
+    }
+
+    /**
+     * It works with different types.
+     *
+     * @dataProvider provider_for_test_it_works_with_different_types
+     * @param string $key
+     * @param mixed $value
+     */
+    public function test_it_works_with_different_types($key, $value) {
+        $store = $this->create_store(cachestore_redis::COMPRESSOR_PHP_GZIP, Redis::SERIALIZER_PHP);
+        $store->set($key, $value);
+
+        self::assertEquals($value, $store->get($key), "Failed set/get for: {$key}");
+    }
+
+    /**
+     * Test it works with different types for many.
+     */
+    public function test_it_works_with_different_types_for_many() {
+        $store = $this->create_store(cachestore_redis::COMPRESSOR_PHP_GZIP, Redis::SERIALIZER_PHP);
+
+        $provider = $this->provider_for_test_it_works_with_different_types();
+        $keys = [];
+        $values = [];
+        $expected = [];
+        foreach ($provider as $item) {
+            $keys[] = $item[0];
+            $values[] = ['key' => $item[0], 'value' => $item[1]];
+            $expected[$item[0]] = $item[1];
+        }
+        $store->set_many($values);
+        $actual = $store->get_many($keys);
+        self::assertEquals($expected, $actual);
+    }
+
+    /**
+     * Provider for set/get combination tests.
+     *
+     * @return array
+     */
+    public function provider_for_tests_setget() {
+        $data = [
+            ['none, none',
+                Redis::SERIALIZER_NONE, cachestore_redis::COMPRESSOR_NONE,
+                'value1', 'value2'],
+            ['none, gzip',
+                Redis::SERIALIZER_NONE, cachestore_redis::COMPRESSOR_PHP_GZIP,
+                gzencode('value1'), gzencode('value2')],
+            ['php, none',
+                Redis::SERIALIZER_PHP, cachestore_redis::COMPRESSOR_NONE,
+                serialize('value1'), serialize('value2')],
+            ['php, gzip',
+                Redis::SERIALIZER_PHP, cachestore_redis::COMPRESSOR_PHP_GZIP,
+                gzencode(serialize('value1')), gzencode(serialize('value2'))],
+        ];
+
+        if (defined('Redis::SERIALIZER_IGBINARY')) {
+            $data[] = [
+                'igbinary, none',
+                    Redis::SERIALIZER_IGBINARY, cachestore_redis::COMPRESSOR_NONE,
+                    igbinary_serialize('value1'), igbinary_serialize('value2'),
+            ];
+            $data[] = [
+                'igbinary, gzip',
+                    Redis::SERIALIZER_IGBINARY, cachestore_redis::COMPRESSOR_PHP_GZIP,
+                    gzencode(igbinary_serialize('value1')), gzencode(igbinary_serialize('value2')),
+            ];
+        }
+
+        if (extension_loaded('zstd')) {
+            $data[] = [
+                'none, zstd',
+                Redis::SERIALIZER_NONE, cachestore_redis::COMPRESSOR_PHP_ZSTD,
+                zstd_compress('value1'), zstd_compress('value2'),
+            ];
+            $data[] = [
+                'php, zstd',
+                Redis::SERIALIZER_PHP, cachestore_redis::COMPRESSOR_PHP_ZSTD,
+                zstd_compress(serialize('value1')), zstd_compress(serialize('value2')),
+            ];
+
+            if (defined('Redis::SERIALIZER_IGBINARY')) {
+                $data[] = [
+                    'igbinary, zstd',
+                    Redis::SERIALIZER_IGBINARY, cachestore_redis::COMPRESSOR_PHP_ZSTD,
+                    zstd_compress(igbinary_serialize('value1')), zstd_compress(igbinary_serialize('value2')),
+                ];
+            }
+        }
+
+        return $data;
+    }
+
+    /**
+     * Test we can use get and set with all combinations.
+     *
+     * @dataProvider provider_for_tests_setget
+     * @param string $name
+     * @param int $serializer
+     * @param int $compressor
+     * @param string $rawexpected1
+     * @param string $rawexpected2
+     */
+    public function test_it_can_use_getset($name, $serializer, $compressor, $rawexpected1, $rawexpected2) {
+        // Create a connection with the desired serialisation.
+        $store = $this->create_store($compressor, $serializer);
+        $store->set('key', 'value1');
+
+        // Disable compressor and serializer to check the actual stored value.
+        $rawstore = $this->create_store(cachestore_redis::COMPRESSOR_NONE, Redis::SERIALIZER_NONE);
+
+        $data = $store->get('key');
+        $rawdata = $rawstore->get('key');
+        self::assertSame('value1', $data, "Invalid serialisation/unserialisation for: {$name}");
+        self::assertSame($rawexpected1, $rawdata, "Invalid rawdata for: {$name}");
+    }
+
+    /**
+     * Test we can use get and set many with all combinations.
+     *
+     * @dataProvider provider_for_tests_setget
+     * @param string $name
+     * @param int $serializer
+     * @param int $compressor
+     * @param string $rawexpected1
+     * @param string $rawexpected2
+     */
+    public function test_it_can_use_getsetmany($name, $serializer, $compressor, $rawexpected1, $rawexpected2) {
+        $many = [
+            ['key' => 'key1', 'value' => 'value1'],
+            ['key' => 'key2', 'value' => 'value2'],
+        ];
+        $keys = ['key1', 'key2'];
+        $expectations = ['key1' => 'value1', 'key2' => 'value2'];
+        $rawexpectations = ['key1' => $rawexpected1, 'key2' => $rawexpected2];
+
+        // Create a connection with the desired serialisation.
+        $store = $this->create_store($compressor, $serializer);
+        $store->set_many($many);
+
+        // Disable compressor and serializer to check the actual stored value.
+        $rawstore = $this->create_store(cachestore_redis::COMPRESSOR_NONE, Redis::SERIALIZER_NONE);
+
+        $data = $store->get_many($keys);
+        $rawdata = $rawstore->get_many($keys);
+        foreach ($keys as $key) {
+            self::assertSame($expectations[$key],
+                             $data[$key],
+                             "Invalid serialisation/unserialisation for {$key} with serializer {$name}");
+            self::assertSame($rawexpectations[$key],
+                             $rawdata[$key],
+                             "Invalid rawdata for {$key} with serializer {$name}");
+        }
+    }
+}
index 4a801da..eaf6344 100644 (file)
@@ -1,6 +1,9 @@
 This files describes API changes in /cache/stores/* - cache store plugins.
 Information provided here is intended especially for developers.
 
+=== 3.8 ===
+* The Redis cache store can now make use of the Zstandard compression algorithm (see MDL-66428).
+
 === 3.7 ===
 * Upgraded MongoDB cache store to use the new lower level PHP-driver and MongoDB PHP Library.
 * The mongodb extension has replaced the old mongo extension. The mongodb pecl extension >= 1.5 must be installed to use MongoDB
index 831c0ce..a5f772e 100644 (file)
@@ -2203,8 +2203,8 @@ function calendar_view_event_allowed(calendar_event $event) {
         if (has_capability('moodle/calendar:manageentries', $event->context)) {
             return true;
         }
-        $mycourses = enrol_get_my_courses('id');
-        return isset($mycourses[$event->courseid]);
+
+        return can_access_course(get_course($event->courseid));
     } else if ($event->userid) {
         if ($event->userid != $USER->id) {
             // No-one can ever see another users events.
index 35809ec..5264d9d 100644 (file)
@@ -772,4 +772,72 @@ class core_calendar_lib_testcase extends advanced_testcase {
         $this->assertEquals(array($coursegroups[$courses[0]->id][1]->id), $groupids);
         $this->assertEquals($users[1]->id, $userid);
     }
+
+    /**
+     *  Test for calendar_view_event_allowed for course event types.
+     */
+    public function test_calendar_view_event_allowed_course_event() {
+        global $USER;
+
+        $this->setAdminUser();
+
+        $generator = $this->getDataGenerator();
+
+        // A student in a course.
+        $student = $generator->create_user();
+        // Some user not enrolled in any course.
+        $someuser = $generator->create_user();
+
+        // A course with manual enrolments.
+        $manualcourse = $generator->create_course();
+
+        // Enrol the student to the manual enrolment course.
+        $generator->enrol_user($student->id, $manualcourse->id);
+
+        // A course that allows guest access.
+        $guestcourse = $generator->create_course(
+            (object)[
+                'shortname' => 'guestcourse',
+                'enrol_guest_status_0' => ENROL_INSTANCE_ENABLED,
+                'enrol_guest_password_0' => ''
+            ]);
+
+        $manualevent = (object)[
+            'name' => 'Manual course event',
+            'description' => '',
+            'format' => 1,
+            'categoryid' => 0,
+            'courseid' => $manualcourse->id,
+            'groupid' => 0,
+            'userid' => $USER->id,
+            'modulename' => 0,
+            'instance' => 0,
+            'eventtype' => 'course',
+            'timestart' => time(),
+            'timeduration' => 86400,
+            'visible' => 1
+        ];
+        $caleventmanual = calendar_event::create($manualevent, false);
+
+        // Create a course event for the course with guest access.
+        $guestevent = clone $manualevent;
+        $guestevent->name = 'Guest course event';
+        $guestevent->courseid = $guestcourse->id;
+        $caleventguest = calendar_event::create($guestevent, false);
+
+        // Viewing as admin.
+        $this->assertTrue(calendar_view_event_allowed($caleventmanual));
+        $this->assertTrue(calendar_view_event_allowed($caleventguest));
+
+        // Viewing as someone enrolled in a course.
+        $this->setUser($student);
+        $this->assertTrue(calendar_view_event_allowed($caleventmanual));
+
+        // Viewing as someone not enrolled in any course.
+        $this->setUser($someuser);
+        // Viewing as someone not enrolled in a course without guest access on.
+        $this->assertFalse(calendar_view_event_allowed($caleventmanual));
+        // Viewing as someone not enrolled in a course with guest access on.
+        $this->assertTrue(calendar_view_event_allowed($caleventguest));
+    }
 }
index b76483d..41a8b41 100644 (file)
@@ -131,6 +131,7 @@ class core_comment_external extends external_api {
             'comments' => $comments,
             'count' => $commentobject->count(),
             'perpage' => (!empty($CFG->commentsperpage)) ? $CFG->commentsperpage : 15,
+            'canpost'  => $commentobject->can_post(),
             'warnings' => $warnings
         );
         return $results;
@@ -146,26 +147,219 @@ class core_comment_external extends external_api {
         return new external_single_structure(
             array(
                 'comments' => new external_multiple_structure(
-                    new external_single_structure(
-                        array(
-                            'id'             => new external_value(PARAM_INT,  'Comment ID'),
-                            'content'        => new external_value(PARAM_RAW,  'The content text formated'),
-                            'format'         => new external_format_value('content'),
-                            'timecreated'    => new external_value(PARAM_INT,  'Time created (timestamp)'),
-                            'strftimeformat' => new external_value(PARAM_NOTAGS, 'Time format'),
-                            'profileurl'     => new external_value(PARAM_URL,  'URL profile'),
-                            'fullname'       => new external_value(PARAM_NOTAGS, 'fullname'),
-                            'time'           => new external_value(PARAM_NOTAGS, 'Time in human format'),
-                            'avatar'         => new external_value(PARAM_RAW,  'HTML user picture'),
-                            'userid'         => new external_value(PARAM_INT,  'User ID'),
-                            'delete'         => new external_value(PARAM_BOOL, 'Permission to delete=true/false', VALUE_OPTIONAL)
-                        ), 'comment'
-                    ), 'List of comments'
+                    self::get_comment_structure(), 'List of comments'
                 ),
                 'count' => new external_value(PARAM_INT,  'Total number of comments.', VALUE_OPTIONAL),
                 'perpage' => new external_value(PARAM_INT,  'Number of comments per page.', VALUE_OPTIONAL),
+                'canpost' => new external_value(PARAM_BOOL, 'Whether the user can post in this comment area.', VALUE_OPTIONAL),
                 'warnings' => new external_warnings()
             )
         );
     }
+
+    /**
+     * Helper to get the structure of a single comment.
+     *
+     * @return external_single_structure the comment structure.
+     */
+    protected static function get_comment_structure() {
+        return new external_single_structure(
+            array(
+                'id'             => new external_value(PARAM_INT,  'Comment ID'),
+                'content'        => new external_value(PARAM_RAW,  'The content text formatted'),
+                'format'         => new external_format_value('content'),
+                'timecreated'    => new external_value(PARAM_INT,  'Time created (timestamp)'),
+                'strftimeformat' => new external_value(PARAM_NOTAGS, 'Time format'),
+                'profileurl'     => new external_value(PARAM_URL,  'URL profile'),
+                'fullname'       => new external_value(PARAM_NOTAGS, 'fullname'),
+                'time'           => new external_value(PARAM_NOTAGS, 'Time in human format'),
+                'avatar'         => new external_value(PARAM_RAW,  'HTML user picture'),
+                'userid'         => new external_value(PARAM_INT,  'User ID'),
+                'delete'         => new external_value(PARAM_BOOL, 'Permission to delete=true/false', VALUE_OPTIONAL)
+            ), 'comment'
+        );
+    }
+
+    /**
+     * Returns description of method parameters for the add_comments method.
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.8
+     */
+    public static function add_comments_parameters() {
+        return new external_function_parameters(
+            [
+                'comments' => new external_multiple_structure(
+                    new external_single_structure(
+                        [
+                            'contextlevel' => new external_value(PARAM_ALPHA, 'contextlevel system, course, user...'),
+                            'instanceid'   => new external_value(PARAM_INT, 'the id of item associated with the contextlevel'),
+                            'component'    => new external_value(PARAM_COMPONENT, 'component'),
+                            'content'      => new external_value(PARAM_RAW, 'component'),
+                            'itemid'       => new external_value(PARAM_INT, 'associated id'),
+                            'area'         => new external_value(PARAM_AREA, 'string comment area', VALUE_DEFAULT, ''),
+                        ]
+                    )
+                )
+            ]
+        );
+    }
+
+    /**
+     * Add a comment or comments.
+     *
+     * @param array $comments the array of comments to create.
+     * @return array the array containing those comments created.
+     * @throws comment_exception
+     * @since Moodle 3.8
+     */
+    public static function add_comments($comments) {
+        global $CFG, $SITE;
+
+        if (empty($CFG->usecomments)) {
+            throw new comment_exception('commentsnotenabled', 'moodle');
+        }
+
+        $params = self::validate_parameters(self::add_comments_parameters(), ['comments' => $comments]);
+
+        // Validate every intended comment before creating anything, storing the validated comment for use below.
+        foreach ($params['comments'] as $index => $comment) {
+            $context = self::get_context_from_params($comment);
+            self::validate_context($context);
+
+            list($context, $course, $cm) = get_context_info_array($context->id);
+            if ($context->id == SYSCONTEXTID) {
+                $course = $SITE;
+            }
+
+            // Initialising comment object.
+            $args = new stdClass();
+            $args->context   = $context;
+            $args->course    = $course;
+            $args->cm        = $cm;
+            $args->component = $comment['component'];
+            $args->itemid    = $comment['itemid'];
+            $args->area      = $comment['area'];
+
+            $manager = new comment($args);
+            if (!$manager->can_post()) {
+                throw new comment_exception('nopermissiontocomment');
+            }
+
+            $params['comments'][$index]['preparedcomment'] = $manager;
+        }
+
+        // Create the comments.
+        $results = [];
+        foreach ($params['comments'] as $comment) {
+            $manager = $comment['preparedcomment'];
+            $newcomment = $manager->add($comment['content']);
+            $newcomment->delete = true; // USER created the comment, so they can delete it.
+            $results[] = $newcomment;
+        }
+
+        return $results;
+    }
+
+    /**
+     * Returns description of method result value for the add_comments method.
+     *
+     * @return external_description
+     * @since Moodle 3.8
+     */
+    public static function add_comments_returns() {
+        return new external_multiple_structure(
+            self::get_comment_structure()
+        );
+    }
+
+    /**
+     * Returns description of method parameters for the delete_comments() method.
+     *
+     * @return external_function_parameters
+     * @since Moodle 3.8
+     */
+    public static function delete_comments_parameters() {
+        return new external_function_parameters(
+            [
+                'comments' => new external_multiple_structure(
+                    new external_value(PARAM_INT, 'id of the comment', VALUE_DEFAULT, 0)
+                )
+            ]
+        );
+    }
+
+    /**
+     * Deletes a comment or comments.
+     *
+     * @param array $comments array of comment ids to be deleted
+     * @return array
+     * @throws comment_exception
+     * @since Moodle 3.8
+     */
+    public static function delete_comments(array $comments) {
+        global $CFG, $DB, $USER, $SITE;
+
+        if (empty($CFG->usecomments)) {
+            throw new comment_exception('commentsnotenabled', 'moodle');
+        }
+
+        $params = self::validate_parameters(self::delete_comments_parameters(), ['comments' => $comments]);
+        $commentids = $params['comments'];
+
+        list($insql, $inparams) = $DB->get_in_or_equal($commentids);
+        $commentrecords = $DB->get_records_select('comments', "id {$insql}", $inparams);
+
+        // If one or more of the records could not be found, report this and fail early.
+        if (count($commentrecords) != count($comments)) {
+            $invalidcomments = array_diff($commentids, array_column($commentrecords, 'id'));
+            $invalidcommentsstr = implode(',', $invalidcomments);
+            throw new comment_exception("One or more comments could not be found by id: $invalidcommentsstr");
+        }
+
+        // Make sure we can delete every one of the comments before actually doing so.
+        $comments = []; // Holds the comment objects, for later deletion.
+        foreach ($commentrecords as $commentrecord) {
+            // Validate the context.
+            list($context, $course, $cm) = get_context_info_array($commentrecord->contextid);
+            if ($context->id == SYSCONTEXTID) {
+                $course = $SITE;
+            }
+            self::validate_context($context);
+
+            // Make sure the user is allowed to delete the comment.
+            $args = new stdClass;
+            $args->context   = $context;
+            $args->course    = $course;
+            $args->cm        = $cm;
+            $args->component = $commentrecord->component;
+            $args->itemid    = $commentrecord->itemid;
+            $args->area      = $commentrecord->commentarea;
+            $manager = new comment($args);
+
+            if ($commentrecord->userid != $USER->id && !$manager->can_delete($commentrecord->id)) {
+                throw new comment_exception('nopermissiontodelentry');
+            }
+
+            // User is allowed to delete it, so store the comment object, for use below in final deletion.
+            $comments[$commentrecord->id] = $manager;
+        }
+
+        // All comments can be deleted by the user. Make it so.
+        foreach ($comments as $commentid => $comment) {
+            $comment->delete($commentid);
+        }
+
+        return [];
+    }
+
+    /**
+     * Returns description of method result value for the delete_comments() method.
+     *
+     * @return external_description
+     * @since Moodle 3.8
+     */
+    public static function delete_comments_returns() {
+        return new external_warnings();
+    }
 }
index 463fa00..58f8151 100644 (file)
@@ -566,7 +566,7 @@ class comment {
                        c.commentarea = :commentarea AND
                        c.itemid = :itemid AND
                        $componentwhere
-              ORDER BY c.timecreated $sortdirection";
+              ORDER BY c.timecreated $sortdirection, c.id $sortdirection";
         $params['contextid'] = $this->contextid;
         $params['commentarea'] = $this->commentarea;
         $params['itemid'] = $this->itemid;
index e9eb0c2..b947392 100644 (file)
@@ -45,34 +45,40 @@ class core_comment_externallib_testcase extends externallib_advanced_testcase {
      * Tests set up
      */
     protected function setUp() {
-        global $CFG;
-
-        require_once($CFG->dirroot . '/comment/lib.php');
+        $this->resetAfterTest();
     }
 
     /**
-     * Test get_comments
+     * Helper used to set up a course, with a module, a teacher and two students.
+     *
+     * @return array the array of records corresponding to the course, teacher, and students.
      */
-    public function test_get_comments() {
-        global $DB, $CFG;
+    protected function setup_course_and_users_basic() {
+        global $CFG, $DB;
 
-        $this->resetAfterTest(true);
+        require_once($CFG->dirroot . '/comment/lib.php');
 
         $CFG->usecomments = true;
 
-        $user = $this->getDataGenerator()->create_user();
-        $course = $this->getDataGenerator()->create_course(array('enablecomment' => 1));
+        $student1 = $this->getDataGenerator()->create_user();
+        $student2 = $this->getDataGenerator()->create_user();
+        $teacher1 = $this->getDataGenerator()->create_user();
+        $course1 = $this->getDataGenerator()->create_course(array('enablecomment' => 1));
         $studentrole = $DB->get_record('role', array('shortname' => 'student'));
-        $this->getDataGenerator()->enrol_user($user->id, $course->id, $studentrole->id);
+        $teacherrole = $DB->get_record('role', array('shortname' => 'editingteacher'));
+        $this->getDataGenerator()->enrol_user($student1->id, $course1->id, $studentrole->id);
+        $this->getDataGenerator()->enrol_user($student2->id, $course1->id, $studentrole->id);
+        $this->getDataGenerator()->enrol_user($teacher1->id, $course1->id, $teacherrole->id);
 
+        // Create a database module instance.
         $record = new stdClass();
-        $record->course = $course->id;
-        $record->name = "Mod data  test";
+        $record->course = $course1->id;
+        $record->name = "Mod data test";
         $record->intro = "Some intro of some sort";
         $record->comments = 1;
 
-        $module = $this->getDataGenerator()->create_module('data', $record);
-        $field = data_get_field_new('text', $module);
+        $module1 = $this->getDataGenerator()->create_module('data', $record);
+        $field = data_get_field_new('text', $module1);
 
         $fielddetail = new stdClass();
         $fielddetail->name = 'Name';
@@ -80,57 +86,69 @@ class core_comment_externallib_testcase extends externallib_advanced_testcase {
 
         $field->define_field($fielddetail);
         $field->insert_field();
-        $recordid = data_add_record($module);
+        $recordid = data_add_record($module1);
 
         $datacontent = array();
         $datacontent['fieldid'] = $field->field->id;
         $datacontent['recordid'] = $recordid;
         $datacontent['content'] = 'Asterix';
+        $DB->insert_record('data_content', $datacontent);
 
-        $contentid = $DB->insert_record('data_content', $datacontent);
-        $cm = get_coursemodule_from_instance('data', $module->id, $course->id);
-
-        $context = context_module::instance($module->cmid);
-
-        $this->setUser($user);
+        return [$module1, $recordid, $teacher1, $student1, $student2];
+    }
 
-        // We need to add the comments manually, the comment API uses the global OUTPUT and this is going to make the WS to fail.
-        $newcmt = new stdClass;
-        $newcmt->contextid    = $context->id;
-        $newcmt->commentarea  = 'database_entry';
-        $newcmt->itemid       = $recordid;
-        $newcmt->content      = 'New comment';
-        $newcmt->format       = 0;
-        $newcmt->userid       = $user->id;
-        $newcmt->timecreated  = time();
-        $cmtid1 = $DB->insert_record('comments', $newcmt);
+    /**
+     * Test get_comments
+     */
+    public function test_get_comments() {
+        global $CFG;
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
 
-        $newcmt->content  = 'New comment 2';
-        $newcmt->timecreated  = time() + 1;
-        $cmtid2 = $DB->insert_record('comments', $newcmt);
+        // Create some comments as student 1.
+        $this->setUser($student1);
+        $inputdata = [
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ],
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'def',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ]
+        ];
+        $result = core_comment_external::add_comments($inputdata);
+        $result = external_api::clean_returnvalue(core_comment_external::add_comments_returns(), $result);
+        $ids = array_column($result, 'id');
 
+        // Verify we can get the comments.
         $contextlevel = 'module';
-        $instanceid = $cm->id;
+        $instanceid = $module1->cmid;
         $component = 'mod_data';
         $itemid = $recordid;
         $area = 'database_entry';
         $page = 0;
-
         $result = core_comment_external::get_comments($contextlevel, $instanceid, $component, $itemid, $area, $page);
-        // We need to execute the return values cleaning process to simulate the web service server.
-        $result = external_api::clean_returnvalue(
-            core_comment_external::get_comments_returns(), $result);
+        $result = external_api::clean_returnvalue(core_comment_external::get_comments_returns(), $result);
 
         $this->assertCount(0, $result['warnings']);
         $this->assertCount(2, $result['comments']);
         $this->assertEquals(2, $result['count']);
         $this->assertEquals(15, $result['perpage']);
+        $this->assertTrue($result['canpost']);
 
-        $this->assertEquals($user->id, $result['comments'][0]['userid']);
-        $this->assertEquals($user->id, $result['comments'][1]['userid']);
+        $this->assertEquals($student1->id, $result['comments'][0]['userid']);
+        $this->assertEquals($student1->id, $result['comments'][1]['userid']);
 
-        $this->assertEquals($cmtid2, $result['comments'][0]['id']); // Default ordering newer first.
-        $this->assertEquals($cmtid1, $result['comments'][1]['id']);
+        $this->assertEquals($ids[1], $result['comments'][0]['id']); // Default ordering newer first.
+        $this->assertEquals($ids[0], $result['comments'][1]['id']);
 
         // Test sort direction and pagination.
         $CFG->commentsperpage = 1;
@@ -141,7 +159,7 @@ class core_comment_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(1, $result['comments']); // Only one per page.
         $this->assertEquals(2, $result['count']);
         $this->assertEquals($CFG->commentsperpage, $result['perpage']);
-        $this->assertEquals($cmtid1, $result['comments'][0]['id']); // Comments order older first.
+        $this->assertEquals($ids[0], $result['comments'][0]['id']); // Comments order older first.
 
         // Next page.
         $result = core_comment_external::get_comments($contextlevel, $instanceid, $component, $itemid, $area, $page + 1, 'ASC');
@@ -151,6 +169,292 @@ class core_comment_externallib_testcase extends externallib_advanced_testcase {
         $this->assertCount(1, $result['comments']);
         $this->assertEquals(2, $result['count']);
         $this->assertEquals($CFG->commentsperpage, $result['perpage']);
-        $this->assertEquals($cmtid2, $result['comments'][0]['id']);
+        $this->assertEquals($ids[1], $result['comments'][0]['id']);
+    }
+
+    /**
+     * Test add_comments not enabled site level
+     */
+    public function test_add_comments_not_enabled_site_level() {
+        global $CFG;
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Try to add a comment, as student 1, when comments is disabled at site level.
+        $this->setUser($student1);
+        $CFG->usecomments = false;
+
+        $this->expectException(comment_exception::class);
+        core_comment_external::add_comments([
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ]
+        ]);
+    }
+
+    /**
+     * Test add_comments not enabled module level
+     */
+    public function test_add_comments_not_enabled_module_level() {
+        global $DB;
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Disable comments for the module.
+        $DB->set_field('data', 'comments', 0, array('id' => $module1->id));
+
+        // Verify we can't add a comment.
+        $this->setUser($student1);
+        $this->expectException(comment_exception::class);
+        core_comment_external::add_comments([
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ]
+        ]);
+    }
+
+    /**
+     * Test add_comments
+     */
+    public function test_add_comments_single() {
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Add a comment as student 1.
+        $this->setUser($student1);
+        $result = core_comment_external::add_comments([
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ]
+        ]);
+        $result = external_api::clean_returnvalue(core_comment_external::add_comments_returns(), $result);
+
+        // Verify the result contains 1 result having the correct structure.
+        $this->assertCount(1, $result);
+
+        $expectedkeys = [
+            'id',
+            'content',
+            'format',
+            'timecreated',
+            'strftimeformat',
+            'profileurl',
+            'fullname',
+            'time',
+            'avatar',
+            'userid',
+            'delete',
+        ];
+        foreach ($expectedkeys as $key) {
+            $this->assertArrayHasKey($key, $result[0]);
+        }
+    }
+
+    /**
+     * Test add_comments when one of the comments contains invalid data and cannot be created.
+     *
+     * This simply verifies that the entire operation fails.
+     */
+    public function test_add_comments_multiple_contains_invalid() {
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Try to create some comments as student 1, but provide a bad area for the second comment.
+        $this->setUser($student1);
+        $this->expectException(comment_exception::class);
+        core_comment_external::add_comments([
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ],
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'def',
+                'itemid' => $recordid,
+                'area' => 'badarea'
+            ],
+        ]);
+    }
+
+    /**
+     * Test add_comments when one of the comments contains invalid data and cannot be created.
+     *
+     * This simply verifies that the entire operation fails.
+     */
+    public function test_add_comments_multiple_all_valid() {
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Try to create some comments as student 1.
+        $this->setUser($student1);
+        $inputdata = [
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ],
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'def',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ]
+        ];
+        $result = core_comment_external::add_comments($inputdata);
+        $result = external_api::clean_returnvalue(core_comment_external::add_comments_returns(), $result);
+
+        // Two comments should have been created.
+        $this->assertCount(2, $result);
+
+        // The content for each comment should come back formatted.
+        foreach ($result as $index => $comment) {
+            $formatoptions = array('overflowdiv' => true, 'blanktarget' => true);
+            $expectedcontent = format_text($inputdata[$index]['content'], FORMAT_MOODLE, $formatoptions);
+            $this->assertEquals($expectedcontent, $comment['content']);
+        }
+    }
+
+    /**
+     * Test add_comments invalid area
+     */
+    public function test_add_comments_invalid_area() {
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Try to create a comment with an invalid area, verifying failure.
+        $this->setUser($student1);
+        $comments = [
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'spaghetti'
+            ]
+        ];
+        $this->expectException(comment_exception::class);
+        core_comment_external::add_comments($comments);
+    }
+
+    /**
+     * Test delete_comment invalid comment.
+     */
+    public function test_delete_comments_invalid_comment_id() {
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+        $this->setUser($student1);
+
+        $this->expectException(comment_exception::class);
+        core_comment_external::delete_comments([-1, 0]);
+    }
+
+    /**
+     * Test delete_comment own user.
+     */
+    public function test_delete_comments_own_user() {
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Create a few comments as student 1.
+        $this->setUser($student1);
+        $result = core_comment_external::add_comments([
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ],
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'def',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ]
+        ]);
+        $result = external_api::clean_returnvalue(core_comment_external::add_comments_returns(), $result);
+
+        // Delete those comments we just created.
+        $result = core_comment_external::delete_comments([
+            $result[0]['id'],
+            $result[1]['id']
+        ]);
+        $result = external_api::clean_returnvalue(core_comment_external::delete_comments_returns(), $result);
+        $this->assertEquals([], $result);
+    }
+
+    /**
+     * Test delete_comment other student.
+     */
+    public function test_delete_comment_other_student() {
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Create a comment as the student.
+        $this->setUser($student1);
+        $result = core_comment_external::add_comments([
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ]
+        ]);
+        $result = external_api::clean_returnvalue(core_comment_external::add_comments_returns(), $result);
+
+        // Now, as student 2, try to delete the comment made by student 1. Verify we can't.
+        $this->setUser($student2);
+        $this->expectException(comment_exception::class);
+        core_comment_external::delete_comments([$result[0]['id']]);
+    }
+
+    /**
+     * Test delete_comment as teacher.
+     */
+    public function test_delete_comments_as_teacher() {
+        [$module1, $recordid, $teacher1, $student1, $student2] = $this->setup_course_and_users_basic();
+
+        // Create a comment as the student.
+        $this->setUser($student1);
+        $result = core_comment_external::add_comments([
+            [
+                'contextlevel' => 'module',
+                'instanceid' => $module1->cmid,
+                'component' => 'mod_data',
+                'content' => 'abc',
+                'itemid' => $recordid,
+                'area' => 'database_entry'
+            ]
+        ]);
+        $result = external_api::clean_returnvalue(core_comment_external::add_comments_returns(), $result);
+
+        // Verify teachers can delete the comment.
+        $this->setUser($teacher1);
+        $result = core_comment_external::delete_comments([$result[0]['id']]);
+        $result = external_api::clean_returnvalue(core_comment_external::delete_comments_returns(), $result);
+        $this->assertEquals([], $result);
     }
 }
index 2510a99..029e8a3 100644 (file)
@@ -4,4 +4,5 @@ information provided here is intended especially for developers.
 === 3.8 ===
   * External function get_comments now returns the total count of comments and the number of comments per page.
     It also has a new parameter to indicate the sorting direction (defaulted to DESC).
-
+  * The external function core_comment_get_comments now indicates if the current user can post comments in the requested
+    area.
index 0aea626..a1ae89c 100644 (file)
@@ -114,10 +114,15 @@ class course_competencies extends course_enrolments {
      * @param \core_analytics\analysable $course
      * @param int $starttime
      * @param int $endtime
-     * @return float 0 -> competencies achieved, 1 -> competencies not achieved
+     * @return float|null 0 -> competencies achieved, 1 -> competencies not achieved
      */
     protected function calculate_sample($sampleid, \core_analytics\analysable $course, $starttime = false, $endtime = false) {
 
+        if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) {
+            // We should not use this sample as the analysis results could be misleading.
+            return null;
+        }
+
         $userenrol = $this->retrieve('user_enrolments', $sampleid);
 
         $key = $course->get_id();
index 9c187ca..966ceaf 100644 (file)
@@ -92,10 +92,15 @@ class course_completion extends course_enrolments {
      * @param \core_analytics\analysable $course
      * @param int $starttime
      * @param int $endtime
-     * @return float 0 -> course not completed, 1 -> course completed
+     * @return float|null 0 -> course not completed, 1 -> course completed
      */
     protected function calculate_sample($sampleid, \core_analytics\analysable $course, $starttime = false, $endtime = false) {
 
+        if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) {
+            // We should not use this sample as the analysis results could be misleading.
+            return null;
+        }
+
         $userenrol = $this->retrieve('user_enrolments', $sampleid);
 
         // We use completion as a success metric.
index 694b058..e271e82 100644 (file)
@@ -114,10 +114,15 @@ class course_dropout extends course_enrolments {
      * @param \core_analytics\analysable $course
      * @param int $starttime
      * @param int $endtime
-     * @return float 0 -> not at risk, 1 -> at risk
+     * @return float|null 0 -> not at risk, 1 -> at risk
      */
     protected function calculate_sample($sampleid, \core_analytics\analysable $course, $starttime = false, $endtime = false) {
 
+        if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) {
+            // We should not use this sample as the analysis results could be misleading.
+            return null;
+        }
+
         $userenrol = $this->retrieve('user_enrolments', $sampleid);
 
         // We use completion as a success metric only when it is enabled.
index bb2feb2..f2fc3e7 100644 (file)
@@ -35,6 +35,16 @@ defined('MOODLE_INTERNAL') || die();
  */
 abstract class course_enrolments extends \core_analytics\local\target\binary {
 
+    /**
+     * @var string
+     */
+    const MESSAGE_ACTION_NAME = 'studentmessage';
+
+    /**
+     * @var float
+     */
+    const ENROL_ACTIVE_PERCENT_REQUIRED = 0.7;
+
     /**
      * Students in the course.
      * @var int[]
@@ -146,6 +156,11 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
     /**
      * Discard student enrolments that are invalid.
      *
+     * Note that this method assumes that the target is only interested in enrolments that are/were active
+     * between the current course start and end times. Targets interested in predicting students at risk before
+     * their enrolment start and targets interested in getting predictions for students whose enrolment already
+     * finished should overwrite this method as these students are discarded by this method.
+     *
      * @param int $sampleid
      * @param \core_analytics\analysable $course
      * @param bool $fortraining
@@ -165,7 +180,7 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
         $limit = $course->get_start() - (YEARSECS + (WEEKSECS * 4));
         if (($userenrol->timestart && $userenrol->timestart < $limit) ||
                 (!$userenrol->timestart && $userenrol->timecreated < $limit)) {
-            // Following what we do in is_valid_sample, we will discard enrolments that last more than 1 academic year
+            // Following what we do in is_valid_analysable, we will discard enrolments that last more than 1 academic year
             // because they have incorrect start and end dates or because they are reused along multiple years
             // without removing previous academic years students. This may not be very accurate because some courses
             // can last just some months, but it is better than nothing.
@@ -175,7 +190,7 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
         if ($course->get_end()) {
             if (($userenrol->timestart && $userenrol->timestart > $course->get_end()) ||
                     (!$userenrol->timestart && $userenrol->timecreated > $course->get_end())) {
-                // Discard user enrolments that starts after the analysable official end.
+                // Discard user enrolments that start after the analysable official end.
                 return false;
             }
 
@@ -204,28 +219,159 @@ abstract class course_enrolments extends \core_analytics\local\target\binary {
      */
     public function prediction_actions(\core_analytics\prediction $prediction, $includedetailsaction = false,
             $isinsightuser = false) {
-        global $USER;
 
         $actions = array();
 
         $sampledata = $prediction->get_sample_data();
         $studentid = $sampledata['user']->id;
 
-        $attrs = array('target' => '_blank');
-
-        // Send a message.
-        $url = new \moodle_url('/message/index.php', array('user' => $USER->id, 'id' => $studentid));
-        $pix = new \pix_icon('t/message', get_string('sendmessage', 'message'));
-        $actions[] = new \core_analytics\prediction_action('studentmessage', $prediction, $url, $pix,
-                get_string('sendmessage', 'message'), false, $attrs);
-
         // View outline report.
         $url = new \moodle_url('/report/outline/user.php', array('id' => $studentid, 'course' => $sampledata['course']->id,
                 'mode' => 'outline'));
         $pix = new \pix_icon('i/report', get_string('outlinereport'));
         $actions[] = new \core_analytics\prediction_action('viewoutlinereport', $prediction, $url, $pix,
-                get_string('outlinereport'), false, $attrs);
+                get_string('outlinereport'), false, ['target' => '_blank']);
+
+        return array_merge(parent::prediction_actions($prediction, $includedetailsaction, $isinsightuser), $actions);
+    }
+
+    /**
+     * Suggested bulk actions for a user.
+     *
+     * @param  \core_analytics\prediction[]     $predictions List of predictions suitable for the bulk actions to use.
+     * @return \core_analytics\bulk_action[]                 The list of bulk actions.
+     */
+    public function bulk_actions(array $predictions) {
+
+        $actions = [];
+
+        $userids = [];
+        foreach ($predictions as $prediction) {
+            $sampledata = $prediction->get_sample_data();
+            $userid = $sampledata['user']->id;
+
+            // Indexed by prediction id because we want the predictionid-userid
+            // mapping later when sending the message.
+            $userids[$prediction->get_prediction_data()->id] = $userid;
+        }
+
+        // Send a message for all the students.
+        $attrs = array(
+            'data-bulk-sendmessage' => '1',
+            'data-prediction-to-user-id' => json_encode($userids)
+        );
+        $actions[] = new \core_analytics\bulk_action(self::MESSAGE_ACTION_NAME, new \moodle_url(''),
+            new \pix_icon('t/message', get_string('sendmessage', 'message')),
+            get_string('sendmessage', 'message'), true, $attrs);
+
+        return array_merge($actions, parent::bulk_actions($predictions));
+    }
+
+    /**
+     * Adds the JS required to run the bulk actions.
+     */
+    public function add_bulk_actions_js() {
+        global $PAGE;
+
+        $PAGE->requires->js_call_amd('report_insights/message_users', 'init',
+            ['.insights-bulk-actions', self::MESSAGE_ACTION_NAME]);
+        parent::add_bulk_actions_js();
+    }
 
-        return array_merge($actions, parent::prediction_actions($prediction, $includedetailsaction));
+    /**
+     * Is/was this user enrolment active during most of the analysis interval?
+     *
+     * This method discards enrolments that were not active during most of the analysis interval. It is
+     * important to discard these enrolments because the indicator calculations can lead to misleading
+     * results.
+     *
+     * Note that this method assumes that the target is interested in enrolments that are/were active
+     * during the analysis interval. Targets interested in predicting students at risk before
+     * their enrolment start should not call this method. Similarly, targets interested in getting
+     * predictions for students whose enrolment already finished should not call this method either.
+     *
+     * @param  int    $sampleid     The id of the sample that is being calculated
+     * @param  int    $starttime    The analysis interval start time
+     * @param  int    $endtime      The analysis interval end time
+     * @return bool
+     */
+    protected function enrolment_active_during_analysis_time(int $sampleid, int $starttime, int $endtime) {
+
+        $userenrol = $this->retrieve('user_enrolments', $sampleid);
+        $enrolstart = $userenrol->timestart ?? $userenrol->timecreated;
+        $enrolend = $userenrol->timeend ?? PHP_INT_MAX;
+
+        if ($endtime && $endtime < $enrolstart) {
+            /* The enrolment starts/ed after the analysis end time.
+             *   |=========|        |----------|
+             * A start    A end   E start     E end
+             */
+            return false;
+        }
+
+        if ($starttime && $enrolend < $starttime) {
+            /* The enrolment finishes/ed before the analysis start time.
+             *    |---------|        |==========|
+             * E start    E end   A start     A end
+             */
+            return false;
+        }
+
+        // Now we want to discard enrolments that were not active for most of the analysis interval. We
+        // need both a $starttime and an $endtime to calculate this.
+
+        if (!$starttime) {
+            // Early return. Nothing to discard if there is no start.
+            return true;
+        }
+
+        if (!$endtime) {
+            // We can not calculate in relative terms (percent) how far from the analysis start time
+            // this enrolment start is/was.
+            return true;
+        }
+
+        if ($enrolstart < $starttime && $endtime < $enrolend) {
+            /* The enrolment is active during all the analysis time.
+             *    |-----------------------------|
+             *               |========|
+             * E start    A start   A end     E end
+             */
+            return true;
+        }
+
+        // If we reach this point is because the enrolment is only active for a portion of the analysis interval.
+        // Therefore, we check that it was active for most of the analysis interval, a self::ENROL_ACTIVE_PERCENT_REQUIRED.
+
+        if ($starttime <= $enrolstart && $enrolend <= $endtime) {
+            /*    |=============================|
+             *               |--------|
+             * A start    E start   E end     A end
+             */
+            $activeenrolduration = $enrolend - $enrolstart;
+        } else if ($enrolstart <= $starttime && $enrolend <= $endtime) {
+            /*            |===================|
+             *    |------------------|
+             * E start  A start    E end    A end
+             */
+            $activeenrolduration = $enrolend - $starttime;
+        } else if ($starttime <= $enrolstart && $endtime <= $enrolend) {
+            /*   |===================|
+             *               |------------------|
+             * A start    E start  A end    E end
+             */
+            $activeenrolduration = $endtime - $enrolstart;
+        }
+
+        $analysisduration = $endtime - $starttime;
+
+        if (floatval($activeenrolduration) / floatval($analysisduration) < self::ENROL_ACTIVE_PERCENT_REQUIRED) {
+            // The student was not enroled in the course for most of the analysis interval.
+            return false;
+        }
+
+        // We happily return true if the enrolment was active for more than self::ENROL_ACTIVE_PERCENT_REQUIRED of
+        // the analysis interval.
+        return true;
     }
 }
index cb5727c..e3f588c 100644 (file)
@@ -160,10 +160,15 @@ class course_gradetopass extends course_enrolments {
      * @param \core_analytics\analysable $course
      * @param int $starttime
      * @param int $endtime
-     * @return float 0 -> course grade to pass achieved, 1 -> course grade to pass not achieved
+     * @return float|null 0 -> course grade to pass achieved, 1 -> course grade to pass not achieved
      */
     protected function calculate_sample($sampleid, \core_analytics\analysable $course, $starttime = false, $endtime = false) {
 
+        if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) {
+            // We should not use this sample as the analysis results could be misleading.
+            return null;
+        }
+
         $userenrol = $this->retrieve('user_enrolments', $sampleid);
 
         // Get course grade to pass.
index 4f7626b..836e1f8 100644 (file)
@@ -122,10 +122,15 @@ class no_recent_accesses extends course_enrolments {
      * @param \core_analytics\analysable $analysable
      * @param int $starttime
      * @param int $endtime
-     * @return float 0 -> accesses, 1 -> no accesses.
+     * @return float|null 0 -> accesses, 1 -> no accesses.
      */
     protected function calculate_sample($sampleid, \core_analytics\analysable $analysable, $starttime = false, $endtime = false) {
 
+        if (!$this->enrolment_active_during_analysis_time($sampleid, $starttime, $endtime)) {
+            // We should not use this sample as the analysis results could be misleading.
+            return null;
+        }
+
         $readactions = $this->retrieve('\core\analytics\indicator\any_course_access', $sampleid);
         if ($readactions == \core\analytics\indicator\any_course_access::get_min_value()) {
             return 1;
index 0c4f158..7af378a 100644 (file)
@@ -128,9 +128,7 @@ class no_teaching extends \core_analytics\local\target\binary {
                 $url, $pix, get_string('participants'));
         }
 
-        $parentactions = parent::prediction_actions($prediction, $includedetailsaction);
-        // No need to show details as there is only 1 indicator.
-        unset($parentactions[\core_analytics\prediction::ACTION_PREDICTION_DETAILS]);
+        $parentactions = parent::prediction_actions($prediction, $includedetailsaction, $isinsightuser);
 
         return array_merge($actions, $parentactions);
     }
index 69c2acd..9f666a5 100644 (file)
@@ -261,8 +261,8 @@ class core_course_external extends external_api {
                         $module['id'] = $cm->id;
                         $module['name'] = external_format_string($cm->name, $modcontext->id);
                         $module['instance'] = $cm->instance;
-                        $module['modname'] = $cm->modname;
-                        $module['modplural'] = $cm->modplural;
+                        $module['modname'] = (string) $cm->modname;
+                        $module['modplural'] = (string) $cm->modplural;
                         $module['modicon'] = $cm->get_icon_url()->out(false);
                         $module['indent'] = $cm->indent;
                         $module['onclick'] = $cm->onclick;
index 205472d..8e70e79 100644 (file)
@@ -559,12 +559,10 @@ function get_module_types_names($plural = false) {
         if ($allmods = $DB->get_records("modules")) {
             foreach ($allmods as $mod) {
                 if (file_exists("$CFG->dirroot/mod/$mod->name/lib.php") && $mod->visible) {
-                    $modnames[0][$mod->name] = get_string("modulename", "$mod->name");
-                    $modnames[1][$mod->name] = get_string("modulenameplural", "$mod->name");
+                    $modnames[0][$mod->name] = get_string("modulename", "$mod->name", null, true);
+                    $modnames[1][$mod->name] = get_string("modulenameplural", "$mod->name", null, true);
                 }
             }
-            core_collator::asort($modnames[0]);
-            core_collator::asort($modnames[1]);
         }
     }
     return $modnames[(int)$plural];
similarity index 75%
rename from lib/tests/targets_test.php
rename to course/tests/targets_test.php
index 2487374..7c9a1d1 100644 (file)
@@ -17,8 +17,7 @@
 /**
  * Unit tests for core targets.
  *
- * @package   core
- * @category  analytics
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -36,8 +35,7 @@ require_once($CFG->dirroot . '/lib/grade/constants.php');
 /**
  * Unit tests for core targets.
  *
- * @package   core
- * @category  analytics
+ * @package   core_course
  * @copyright 2019 Victor Deniz <victor@moodle.com>
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
@@ -186,6 +184,116 @@ class core_analytics_targets_testcase extends advanced_testcase {
         ];
     }
 
+    /**
+     * Provides enrolment params for the {@link self::test_core_target_course_completion_samples()} method.
+     *
+     * @return array
+     */
+    public function active_during_analysis_time_provider() {
+        $now = time();
+
+        return [
+            'enrol-after-end' => [
+                'starttime' => $now,
+                'endtime' => $now + WEEKSECS,
+                'timestart' => $now + (WEEKSECS * 2),
+                'timeend' => $now + (WEEKSECS * 3),
+                'nullcalculation' => true,
+            ],
+            'enrol-before-start' => [
+                'starttime' => $now + (WEEKSECS * 2),
+                'endtime' => $now + (WEEKSECS * 3),
+                'timestart' => $now,
+                'timeend' => $now + WEEKSECS,
+                'nullcalculation' => true,
+            ],
+            'enrol-active-exact-match' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 1),
+                'timestart' => $now,
+                'timeend' => $now + (WEEKSECS * 1),
+                'nullcalculation' => false,
+            ],
+            'enrol-active' => [
+                'starttime' => $now + WEEKSECS,
+                'endtime' => $now + (WEEKSECS * 2),
+                'timestart' => $now,
+                'timeend' => $now + (WEEKSECS * 3),
+                'nullcalculation' => false,
+            ],
+            'enrol-during-analysis-active-just-for-a-while' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => $now + WEEKSECS,
+                'timeend' => $now + (WEEKSECS * 2),
+                'nullcalculation' => true,
+            ],
+            'enrol-during-analysis-mostly-active' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 20),
+                'timestart' => $now + WEEKSECS,
+                'timeend' => $now + (WEEKSECS * 19),
+                'nullcalculation' => false,
+            ],
+            'enrol-partly-active-starts-before' => [
+                'starttime' => $now + WEEKSECS,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => $now,
+                'timeend' => $now + (WEEKSECS * 2),
+                'nullcalculation' => true,
+            ],
+            'enrol-mostly-active-starts-before' => [
+                'starttime' => $now + WEEKSECS,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => $now,
+                'timeend' => $now + (WEEKSECS * 9),
+                'nullcalculation' => false,
+            ],
+            'enrol-partly-active-ends-afterwards' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 9),
+                'timestart' => $now + (WEEKSECS * 10),
+                'timeend' => $now + (WEEKSECS * 11),
+                'nullcalculation' => true,
+            ],
+            'enrol-mostly-active-ends-afterwards' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => $now + WEEKSECS,
+                'timeend' => $now + (WEEKSECS * 11),
+                'nullcalculation' => false,
+            ],
+            'enrol-partly-active-no-enrolment-end' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => $now + (WEEKSECS * 9),
+                'timeend' => false,
+                'nullcalculation' => true,
+            ],
+            'enrol-mostly-active-no-enrolment-end' => [
+                'starttime' => $now,
+                'endtime' => $now + (WEEKSECS * 10),
+                'timestart' => $now + WEEKSECS,
+                'timeend' => false,
+                'nullcalculation' => true,
+            ],
+            'no-start' => [
+                'starttime' => 0,
+                'endtime' => $now + (WEEKSECS * 2),
+                'timestart' => $now + WEEKSECS,
+                'timeend' => $now + (WEEKSECS * 3),
+                'nullcalculation' => false,
+            ],
+            'no-end' => [
+                'starttime' => $now,
+                'endtime' => 0,
+                'timestart' => $now + (WEEKSECS * 2),
+                'timeend' => $now + (WEEKSECS * 3),
+                'nullcalculation' => false,
+            ]
+        ];
+    }
+
     /**
      * Test the conditions of a valid analysable, both common and specific to this target (course_completion).
      *
@@ -279,6 +387,48 @@ class core_analytics_targets_testcase extends advanced_testcase {
         $this->assertEquals($isvalidforprediction, $target->is_valid_sample($sampleid, $analysable, false));
     }
 
+    /**
+     * Test the conditions of a valid calculation (course_completion).
+     *
+     * @dataProvider active_during_analysis_time_provider
+     * @param int $starttime Analysis start time
+     * @param int $endtime Analysis end time
+     * @param int $timestart Enrol start date
+     * @param int $timeend Enrol end date
+     * @param boolean $nullcalculation Whether the calculation should be null or not
+     */
+    public function test_core_target_course_completion_active_during_analysis_time($starttime, $endtime, $timestart, $timeend,
+            $nullcalculation) {
+
+        $this->resetAfterTest(true);
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $this->getDataGenerator()->enrol_user($user->id, $course->id, null, 'manual', $timestart, $timeend);
+
+        $target = new \core_course\analytics\target\course_completion();
+        $analyser = new \core\analytics\analyser\student_enrolments(1, $target, [], [], []);
+        $analysable = new \core_analytics\course($course);
+
+        $class = new ReflectionClass('\core\analytics\analyser\student_enrolments');
+        $method = $class->getMethod('get_all_samples');
+        $method->setAccessible(true);
+
+        list($sampleids, $samplesdata) = $method->invoke($analyser, $analysable);
+        $target->add_sample_data($samplesdata);
+        $sampleid = reset($sampleids);
+
+        $reftarget = new ReflectionObject($target);
+        $refmethod = $reftarget->getMethod('calculate_sample');
+        $refmethod->setAccessible(true);
+
+        if ($nullcalculation) {
+            $this->assertNull($refmethod->invoke($target, $sampleid, $analysable, $starttime, $endtime));
+        } else {
+            $this->assertNotNull($refmethod->invoke($target, $sampleid, $analysable, $starttime, $endtime));
+        }
+    }
+
     /**
      * Setup user, framework, competencies and course competencies.
      */
index 45d50d1..2aa96e1 100644 (file)
@@ -37,6 +37,14 @@ use \core_customfield\category_controller;
  */
 class core_customfield_api_testcase extends advanced_testcase {
 
+    /**
+     * This method is called after the last test of this test class is run.
+     */
+    public static function tearDownAfterClass() {
+        $handler = core_course\customfield\course_handler::create();
+        $handler->delete_all();
+    }
+
     /**
      * Tests set up.
      */
index 656bc5b..56aa2d0 100644 (file)
@@ -36,6 +36,14 @@ use \core_customfield\field_controller;
  */
 class core_customfield_category_controller_testcase extends advanced_testcase {
 
+    /**
+     * This method is called after the last test of this test class is run.
+     */
+    public static function tearDownAfterClass() {
+        $handler = core_course\customfield\course_handler::create();
+        $handler->delete_all();
+    }
+
     /**
      * Tests set up.
      */
index ac5b405..7cf0b7e 100644 (file)
@@ -34,6 +34,14 @@ use core_customfield\data_controller;
  */
 class core_customfield_data_controller_testcase extends advanced_testcase {
 
+    /**
+     * This method is called after the last test of this test class is run.
+     */
+    public static function tearDownAfterClass() {
+        $handler = core_course\customfield\course_handler::create();
+        $handler->delete_all();
+    }
+
     /**
      * Tests set up.
      */
index b704469..e490ef1 100644 (file)
@@ -38,6 +38,14 @@ use \core_customfield\field_controller;
  */
 class core_customfield_field_controller_testcase extends advanced_testcase {
 
+    /**
+     * This method is called after the last test of this test class is run.
+     */
+    public static function tearDownAfterClass() {
+        $handler = core_course\customfield\course_handler::create();
+        $handler->delete_all();
+    }
+
     /**
      * Tests set up.
      */
index 4788d9c..059c868 100644 (file)
@@ -35,6 +35,14 @@ defined('MOODLE_INTERNAL') || die();
  */
 class core_customfield_generator_testcase extends advanced_testcase {
 
+    /**
+     * This method is called after the last test of this test class is run.
+     */
+    public static function tearDownAfterClass() {
+        $handler = core_course\customfield\course_handler::create();
+        $handler->delete_all();
+    }
+
     /**
      * Get generator
      * @return core_customfield_generator
index 9a15ced..69ea102 100644 (file)
@@ -45,6 +45,14 @@ class core_customfield_privacy_testcase extends provider_testcase {
     /** @var \core_customfield\field_controller[] */
     private $cffields = [];
 
+    /**
+     * This method is called after the last test of this test class is run.
+     */
+    public static function tearDownAfterClass() {
+        $handler = core_course\customfield\course_handler::create();
+        $handler->delete_all();
+    }
+
     /**
      * Set up
      */
index e44cef6..9604b19 100644 (file)
@@ -58,14 +58,17 @@ class core_enrollib_testcase extends advanced_testcase {
 
         $course1 = $this->getDataGenerator()->create_course(array(
             'shortname' => 'Z',
+            'idnumber' => '123',
             'category' => $category1->id,
         ));
         $course2 = $this->getDataGenerator()->create_course(array(
             'shortname' => 'X',
+            'idnumber' => '789',
             'category' => $category2->id,
         ));
         $course3 = $this->getDataGenerator()->create_course(array(
             'shortname' => 'Y',
+            'idnumber' => '456',
             'category' => $category2->id,
             'visible' => 0,
         ));
@@ -163,7 +166,7 @@ class core_enrollib_testcase extends advanced_testcase {
         $this->assertTrue(property_exists($course, 'timecreated'));
 
         $courses = enrol_get_all_users_courses($user2->id, false, null, 'id DESC');
-        $this->assertEquals(array($course3->id, $course2->id, $course1->id), array_keys($courses));
+        $this->assertEquals(array($course2->id, $course3->id, $course1->id), array_keys($courses));
 
         // Make sure that implicit sorting defined in navsortmycoursessort is respected.
 
@@ -175,7 +178,54 @@ class core_enrollib_testcase extends advanced_testcase {
         // But still the explicit sorting takes precedence over the implicit one.
 
         $courses = enrol_get_all_users_courses($user1->id, false, null, 'shortname DESC');
+        $this->assertEquals(array($course2->id, $course1->id, $course3->id), array_keys($courses));
+
+        // Make sure that implicit visibility sorting defined in navsortmycourseshiddenlast is respected for all course sortings.
+
+        $CFG->navsortmycoursessort = 'sortorder';
+        $CFG->navsortmycourseshiddenlast = true;
+        $courses = enrol_get_all_users_courses($user1->id);
+        $this->assertEquals(array($course2->id, $course1->id, $course3->id), array_keys($courses));
+
+        $CFG->navsortmycoursessort = 'sortorder';
+        $CFG->navsortmycourseshiddenlast = false;
+        $courses = enrol_get_all_users_courses($user1->id);
+        $this->assertEquals(array($course1->id, $course3->id, $course2->id), array_keys($courses));
+
+        $CFG->navsortmycoursessort = 'fullname';
+        $CFG->navsortmycourseshiddenlast = true;
+        $courses = enrol_get_all_users_courses($user1->id);
+        $this->assertEquals(array($course2->id, $course1->id, $course3->id), array_keys($courses));
+
+        $CFG->navsortmycoursessort = 'fullname';
+        $CFG->navsortmycourseshiddenlast = false;
+        $courses = enrol_get_all_users_courses($user1->id);
+        $this->assertEquals(array($course1->id, $course2->id, $course3->id), array_keys($courses));
+
+        $CFG->navsortmycoursessort = 'shortname';
+        $CFG->navsortmycourseshiddenlast = true;
+        $courses = enrol_get_all_users_courses($user1->id);
+        $this->assertEquals(array($course2->id, $course3->id, $course1->id), array_keys($courses));
+
+        $CFG->navsortmycoursessort = 'shortname';
+        $CFG->navsortmycourseshiddenlast = false;
+        $courses = enrol_get_all_users_courses($user1->id);
+        $this->assertEquals(array($course2->id, $course3->id, $course1->id), array_keys($courses));
+
+        $CFG->navsortmycoursessort = 'idnumber';
+        $CFG->navsortmycourseshiddenlast = true;
+        $courses = enrol_get_all_users_courses($user1->id);
+        $this->assertEquals(array($course2->id, $course1->id, $course3->id), array_keys($courses));
+
+        $CFG->navsortmycoursessort = 'idnumber';
+        $CFG->navsortmycourseshiddenlast = false;
+        $courses = enrol_get_all_users_courses($user1->id);
         $this->assertEquals(array($course1->id, $course3->id, $course2->id), array_keys($courses));
+
+        // But still the explicit visibility sorting takes precedence over the implicit one.
+
+        $courses = enrol_get_all_users_courses($user1->id, false, null, 'visible DESC, shortname DESC');
+        $this->assertEquals(array($course2->id, $course1->id, $course3->id), array_keys($courses));
     }
 
     public function test_enrol_user_sees_own_courses() {
diff --git a/filter/h5p/classes/privacy/provider.php b/filter/h5p/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..215facc
--- /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 filter_h5p.
+ *
+ * @package    filter_h5p
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace filter_h5p\privacy;
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * Privacy Subsystem for filter_h5p implementing null_provider.
+ *
+ * @copyright  2019 Victor Deniz <victor@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/filter/h5p/filter.php b/filter/h5p/filter.php
new file mode 100644 (file)
index 0000000..fcb35ec
--- /dev/null
@@ -0,0 +1,120 @@
+<?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 filter
+ *
+ * @package    filter_h5p
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+/**
+ * H5P filter
+ *
+ * This filter will replace any occurrence of H5P URLs with the corresponding H5P content embed code
+ *
+ * @package    filter_h5p
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filter_h5p extends moodle_text_filter {
+
+    /**
+     * @var boolean $loadresizerjs This is whether to request the resize.js script.
+     */
+    private static $loadresizerjs = true;
+
+    /**
+     * Function filter replaces any h5p-sources.
+     *
+     * @param  string $text    HTML content to process
+     * @param  array  $options options passed to the filters
+     * @return string
+     */
+    public function filter($text, array $options = array()) {
+
+        if (!is_string($text) or empty($text)) {
+            // Non string data can not be filtered anyway.
+            return $text;
+        }
+
+        if (stripos($text, 'http') === false) {
+            return $text;
+        }
+
+        $allowedsources = get_config('filter_h5p', 'allowedsources');
+        $allowedsources = array_filter(array_map('trim', explode("\n", $allowedsources)));
+        if (empty($allowedsources)) {
+            return $text;
+        }
+
+        $params = array(
+            'tagbegin' => "<iframe src=",
+            'tagend' => "</iframe>"
+        );
+
+        foreach ($allowedsources as $source) {
+            // It is needed to add "/embed" at the end of URLs like https:://*.h5p.com/content/12345 (H5P.com).
+            $params['urlmodifier'] = '';
+            if (!(stripos($source, 'embed'))) {
+                $params['urlmodifier'] = '/embed';
+            }
+
+            // Convert special chars.
+            $specialchars = ['*', '?', '&'];
+            $escapedspecialchars = ['[^.]+', '\?', '&amp;'];
+            $sourceid = str_replace('[id]', '[0-9]+', $source);
+            $escapechars = str_replace($specialchars, $escapedspecialchars, $sourceid);
+            $ultimatepattern = '#(' . $escapechars . ')#';
+
+            $h5pcontenturl = new filterobject($source, null, null, false,
+                   false, null, [$this, 'filterobject_prepare_replacement_callback'], $params);
+
+            $h5pcontenturl->workregexp = $ultimatepattern;
+            $h5pcontents[] = $h5pcontenturl;
+        }
+
+        return filter_phrases($text, $h5pcontents, null, null, false, true);
+    }
+
+    /**
+     * Callback used by filterobject / filter_phrases.
+     *
+     * @param string $tagbegin HTML to insert before any match
+     * @param string $tagend HTML to insert after any match
+     * @param string $urlmodifier string to add to the match URL
+     * @return array [$hreftagbegin, $hreftagend, $replacementphrase] for filterobject.
+     */
+    public function filterobject_prepare_replacement_callback($tagbegin, $tagend, $urlmodifier) {
+
+        $sourceurl = "$1";
+        if ($urlmodifier !== "") {
+            $sourceurl .= $urlmodifier;
+        }
+
+        $h5piframesrc = "\"".$sourceurl."\" width=\"100%\" height=\"637\" allowfullscreen=\"allowfullscreen\" style=\"border: 0;\">";
+
+        // We want to request the resizing script only once.
+        if (self::$loadresizerjs) {
+            $tagend .= '<script src="https://h5p.org/sites/all/modules/h5p/library/js/h5p-resizer.js"></script>';
+            self::$loadresizerjs = false;
+        }
+
+        return [$tagbegin, $tagend, $h5piframesrc];
+    }
+}
diff --git a/filter/h5p/lang/en/filter_h5p.php b/filter/h5p/lang/en/filter_h5p.php
new file mode 100644 (file)
index 0000000..38f4460
--- /dev/null
@@ -0,0 +1,34 @@
+<?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 filter_h5p
+ *
+ * @package    filter_h5p
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+$string['allowedsourceslist'] = 'Allowed sources';
+$string['allowedsourceslistdesc'] = 'List of sources from which users can embed H5P content. If empty, the filter won\'t convert any external URL.
+
+<b>[id]</b> is a placeholder for the H5P content id in the external source.
+
+<b>*</b> wildcard is supported. For example, *.example.com will embed H5P content from any subdomain of example.com, but not from the example.com domain.';
+$string['filtername'] = 'H5P';
+$string['privacy:metadata'] = 'This H5P filter does not store any personal data.';
diff --git a/filter/h5p/settings.php b/filter/h5p/settings.php
new file mode 100644 (file)
index 0000000..6102eeb
--- /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/>.
+
+/**
+ * H5P filter settings
+ *
+ * @package    filter_h5p
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die;
+
+if ($ADMIN->fulltree) {
+    $settings->add(new admin_setting_configtextarea('filter_h5p/allowedsources', get_string('allowedsourceslist', 'filter_h5p'),
+            get_string('allowedsourceslistdesc', 'filter_h5p'),
+            "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]"));
+}
diff --git a/filter/h5p/tests/filter_test.php b/filter/h5p/tests/filter_test.php
new file mode 100644 (file)
index 0000000..2e17c10
--- /dev/null
@@ -0,0 +1,87 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Unit tests for the filter_h5p
+ *
+ * @package    filter_h5p
+ * @category   test
+ * @copyright  2019 Victor Deniz <victor@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot.'/filter/h5p/filter.php');
+
+/**
+ * Unit tests for the H5P filter.
+ *
+ * @copyright 2019 Victor Deniz <victor@moodle.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class filter_h5p_testcase extends advanced_testcase {
+
+    public function setUp() {
+        parent::setUp();
+
+        $this->resetAfterTest(true);
+
+        set_config('allowedsources', "https://h5p.org/h5p/embed/[id]\nhttps://*.h5p.com/content/[id]/embed\nhttps://*.h5p.com/content/[id]
+                \nhttp