Merge branch 'MDL-64525' of https://github.com/NeillM/moodle into master
authorSara Arjona <sara@moodle.com>
Tue, 1 Sep 2020 12:17:48 +0000 (14:17 +0200)
committerSara Arjona <sara@moodle.com>
Tue, 1 Sep 2020 12:17:48 +0000 (14:17 +0200)
142 files changed:
admin/classes/task_log_table.php
admin/cli/adhoc_task.php
admin/cli/cron.php
admin/cli/restore_backup.php [new file with mode: 0644]
admin/cli/scheduled_task.php
admin/settings/plugins.php
admin/settings/server.php
admin/tool/dataprivacy/lang/en/tool_dataprivacy.php
admin/tool/log/upgrade.txt
admin/tool/lp/tests/behat/course_competencies.feature
admin/tool/task/classes/running_tasks_table.php [new file with mode: 0644]
admin/tool/task/lang/en/tool_task.php
admin/tool/task/renderer.php
admin/tool/task/runningtasks.php [new file with mode: 0644]
admin/tool/task/scheduledtasks.php
admin/tool/task/settings.php
admin/tool/task/styles.css
admin/tool/task/tests/behat/cron_disabled.feature [new file with mode: 0644]
admin/tool/task/tests/behat/running_tasks.feature [new file with mode: 0644]
admin/tool/task/tests/generator/behat_tool_task_generator.php [new file with mode: 0644]
admin/tool/task/tests/generator/lib.php [new file with mode: 0644]
admin/tool/task/version.php
admin/tool/usertours/classes/manager.php
auth/tests/behat/login.feature
cache/upgrade.txt
calendar/upgrade.txt
cohort/index.php
competency/classes/api.php
contentbank/classes/content.php
contentbank/classes/contentbank.php
contentbank/classes/contenttype.php
contentbank/contenttype/h5p/classes/contenttype.php
contentbank/contenttype/h5p/tests/behat/admin_replace_content.feature [new file with mode: 0644]
contentbank/contenttype/h5p/tests/behat/teacher_replace_content.feature [new file with mode: 0644]
contentbank/contenttype/h5p/tests/contenttype_h5p_test.php
contentbank/files_form.php
contentbank/tests/behat/download_content.feature [new file with mode: 0644]
contentbank/tests/content_test.php
contentbank/tests/contentbank_test.php
contentbank/tests/contenttype_test.php
contentbank/upload.php
contentbank/view.php
course/upgrade.txt
customfield/field/select/classes/data_controller.php
customfield/field/select/classes/field_controller.php
grade/grading/form/upgrade.txt
grade/report/singleview/classes/local/screen/grade.php
grade/report/singleview/classes/local/ui/finalgrade.php
grade/report/singleview/tests/behat/bulk_insert_grades.feature
grade/report/singleview/tests/behat/singleview.feature
h5p/classes/api.php
h5p/classes/editor_framework.php
h5p/classes/framework.php
h5p/tests/generator_test.php
h5p/upgrade.txt
install/lang/eu/admin.php
install/lang/kaa/langconfig.php [new file with mode: 0644]
install/lang/prs/moodle.php [new file with mode: 0644]
lang/en/admin.php
lang/en/antivirus.php
lang/en/backup.php
lang/en/badges.php
lang/en/contentbank.php
lang/en/moodle.php
lang/en/role.php
lib/antivirus/clamav/classes/scanner.php
lib/classes/antivirus/manager.php
lib/classes/antivirus/quarantine.php [new file with mode: 0644]
lib/classes/antivirus/scanner.php
lib/classes/event/virus_infected_data_detected.php [new file with mode: 0644]
lib/classes/event/virus_infected_file_detected.php [new file with mode: 0644]
lib/classes/plugin_manager.php
lib/classes/task/antivirus_cleanup_task.php [new file with mode: 0644]
lib/classes/task/backup_cleanup_task.php
lib/classes/task/database_logger.php
lib/classes/task/manager.php
lib/classes/task/task_base.php
lib/cronlib.php
lib/db/access.php
lib/db/install.xml
lib/db/messages.php
lib/db/tasks.php
lib/db/upgrade.php
lib/form/duration.php
lib/form/tests/duration_test.php
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js
lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js
lib/form/yui/src/dateselector/js/calendar.js
lib/moodlelib.php
lib/outputlib.php
lib/pagelib.php
lib/templates/infected_file_email.mustache [new file with mode: 0644]
lib/tests/antivirus_test.php
lib/tests/moodle_page_test.php
lib/tests/moodlelib_test.php
lib/tests/scheduled_task_test.php
lib/tests/task_running_test.php [new file with mode: 0644]
lib/upgrade.txt
mod/assign/feedback/file/importziplib.php
mod/assign/feedback/file/tests/importziplib_test.php [new file with mode: 0644]
mod/choice/lang/en/choice.php
mod/lesson/lang/en/lesson.php
mod/lti/view.php
pix/i/completion-auto-enabled.png
pix/i/completion-auto-enabled.svg
pix/i/completion-auto-fail.png
pix/i/completion-auto-fail.svg
pix/i/completion-auto-n.png
pix/i/completion-auto-n.svg
pix/i/completion-auto-pass.png
pix/i/completion-auto-pass.svg
pix/i/completion-auto-y-override.png
pix/i/completion-auto-y-override.svg
pix/i/completion-auto-y.png
pix/i/completion-auto-y.svg
pix/i/completion-manual-enabled.png
pix/i/completion-manual-enabled.svg
pix/i/completion-manual-n.png
pix/i/completion-manual-n.svg
pix/i/completion-manual-y-override.png
pix/i/completion-manual-y-override.svg
pix/i/completion-manual-y.png
pix/i/completion-manual-y.svg
question/behaviour/upgrade.txt
report/infectedfiles/classes/output/renderer.php [new file with mode: 0644]
report/infectedfiles/classes/privacy/provider.php [new file with mode: 0644]
report/infectedfiles/classes/table/infectedfiles_table.php [new file with mode: 0644]
report/infectedfiles/index.php [new file with mode: 0644]
report/infectedfiles/lang/en/report_infectedfiles.php [new file with mode: 0644]
report/infectedfiles/settings.php [new file with mode: 0644]
report/infectedfiles/version.php [new file with mode: 0644]
report/insights/classes/output/insights_list.php
repository/equella/lib.php
search/upgrade.txt
theme/boost/scss/moodle/calendar.scss
theme/boost/scss/preset/default.scss
theme/boost/style/moodle.css
theme/classic/scss/preset/default.scss
theme/classic/style/moodle.css
theme/upgrade.txt
version.php

index 949e757..673e9a1 100644 (file)
@@ -57,6 +57,8 @@ class task_log_table extends \table_sql {
             'userid'     => get_string('user', 'admin'),
             'timestart'  => get_string('task_starttime', 'admin'),
             'duration'   => get_string('task_duration', 'admin'),
+            'hostname'   => get_string('hostname', 'tool_task'),
+            'pid'        => get_string('pid', 'tool_task'),
             'db'         => get_string('task_dbstats', 'admin'),
             'result'     => get_string('task_result', 'admin'),
             'actions'    => '',
@@ -132,6 +134,7 @@ class task_log_table extends \table_sql {
 
         $sql = "SELECT
                     tl.id, tl.type, tl.component, tl.classname, tl.userid, tl.timestart, tl.timeend,
+                    tl.hostname, tl.pid,
                     tl.dbreads, tl.dbwrites, tl.result,
                     tl.dbreads + tl.dbwrites AS db,
                     tl.timeend - tl.timestart AS duration,
index b0ed21d..dd1cc14 100644 (file)
@@ -37,11 +37,13 @@ list($options, $unrecognized) = cli_get_params(
         'showsql' => false,
         'showdebugging' => false,
         'ignorelimits' => false,
+        'force' => false,
     ], [
         'h' => 'help',
         'e' => 'execute',
         'k' => 'keep-alive',
         'i' => 'ignorelimits',
+        'f' => 'force',
     ]
 );
 
@@ -61,6 +63,7 @@ Options:
  -e, --execute             Run all queued adhoc tasks
  -k, --keep-alive=N        Keep this script alive for N seconds and poll for new adhoc tasks
  -i  --ignorelimits        Ignore task_adhoc_concurrency_limit and task_adhoc_max_runtime limits
+ -f, --force               Run even if cron is disabled
 
 Example:
 \$sudo -u www-data /usr/bin/php admin/cli/adhoc_task.php --execute
@@ -92,6 +95,12 @@ if (moodle_needs_upgrading()) {
 if (empty($options['execute'])) {
     exit(0);
 }
+
+if (!get_config('core', 'cron_enabled') && !$options['force']) {
+    mtrace('Cron is disabled. Use --force to override.');
+    exit(1);
+}
+
 if (empty($options['keep-alive'])) {
     $options['keep-alive'] = 0;
 }
index fe72683..958062a 100644 (file)
@@ -36,14 +36,23 @@ require_once($CFG->libdir.'/cronlib.php');
 
 // now get cli options
 list($options, $unrecognized) = cli_get_params(
-    array(
+    [
         'help' => false,
         'stop' => false,
-    ),
-    array(
+        'list' => false,
+        'force' => false,
+        'enable' => false,
+        'disable' => false,
+        'disable-wait' => false,
+    ], [
         'h' => 'help',
         's' => 'stop',
-    )
+        'l' => 'list',
+        'f' => 'force',
+        'e' => 'enable',
+        'd' => 'disable',
+        'w' => 'disable-wait',
+    ]
 );
 
 if ($unrecognized) {
@@ -56,8 +65,13 @@ if ($options['help']) {
 "Execute periodic cron actions.
 
 Options:
--h, --help            Print out this help
--s, --stop            Notify all other running cron processes to stop after the current task
+-h, --help               Print out this help
+-s, --stop               Notify all other running cron processes to stop after the current task
+-l, --list               Show the list of currently running tasks and how long they have been running
+-f, --force              Execute task even if cron is disabled
+-e, --enable             Enable cron
+-d, --disable            Disable cron
+-w, --disable-wait=600   Disable cron and wait until all tasks finished or fail after N seconds (optional param)
 
 Example:
 \$sudo -u www-data /usr/bin/php admin/cli/cron.php
@@ -74,6 +88,91 @@ if ($options['stop']) {
     die;
 }
 
+if ($options['enable']) {
+    set_config('cron_enabled', 1);
+    mtrace('Cron has been enabled for the site.');
+    exit(0);
+}
+
+if ($options['disable']) {
+    set_config('cron_enabled', 0);
+    \core\task\manager::clear_static_caches();
+    mtrace('Cron has been disabled for the site.');
+    exit(0);
+}
+
+if ($options['list']) {
+    $tasks = \core\task\manager::get_running_tasks();
+    mtrace('The list of currently running tasks:');
+    $format = "%7s %-12s %-9s %-20s %-52s\n";
+    printf ($format,
+        'PID',
+        'HOST',
+        'TYPE',
+        'TIME',
+        'CLASSNAME'
+    );
+    foreach ($tasks as $task) {
+        printf ($format,
+            $task->pid,
+            substr($task->hostname, 0, 12),
+            $task->type,
+            format_time(time() - $task->timestarted),
+            substr($task->classname, 0, 52)
+        );
+    }
+    exit(0);
+}
+
+if ($wait = $options['disable-wait']) {
+    $started = time();
+    if (true === $wait) {
+        // Default waiting time.
+        $waitsec = 600;
+    } else {
+        $waitsec = $wait;
+        $wait = true;
+    }
+
+    set_config('cron_enabled', 0);
+    \core\task\manager::clear_static_caches();
+    mtrace('Cron has been disabled for the site.');
+    mtrace('Allocating '. format_time($waitsec) . ' for the tasks to finish.');
+
+    $lastcount = 0;
+    while ($wait) {
+        $tasks = \core\task\manager::get_running_tasks();
+
+        if (count($tasks) == 0) {
+            mtrace('');
+            mtrace('All scheduled and adhoc tasks finished.');
+            exit(0);
+        }
+
+        if (time() - $started >= $waitsec) {
+            mtrace('');
+            mtrace('Wait time ('. format_time($waitsec) . ') elapsed, but ' . count($tasks) . ' task(s) still running.');
+            mtrace('Exiting with code 1.');
+            exit(1);
+        }
+
+        if (count($tasks) !== $lastcount) {
+            mtrace('');
+            mtrace(count($tasks) . " tasks currently running.", '');
+            $lastcount = count($tasks);
+        } else {
+            mtrace('.', '');
+        }
+
+        sleep(1);
+    }
+}
+
+if (!get_config('core', 'cron_enabled') && !$options['force']) {
+    mtrace('Cron is disabled. Use --force to override.');
+    exit(1);
+}
+
 \core\local\cli\shutdown::script_supports_graceful_exit();
 
 cron_run();
diff --git a/admin/cli/restore_backup.php b/admin/cli/restore_backup.php
new file mode 100644 (file)
index 0000000..6f5de42
--- /dev/null
@@ -0,0 +1,109 @@
+<?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/>.
+
+/**
+ * This script allows to restore a course from CLI.
+ *
+ * @package    core
+ * @subpackage cli
+ * @copyright  2020 Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+define('CLI_SCRIPT', 1);
+
+require(__DIR__ . '/../../config.php');
+require_once($CFG->libdir . '/clilib.php');
+require_once($CFG->dirroot . "/backup/util/includes/restore_includes.php");
+
+list($options, $unrecognized) = cli_get_params([
+    'file' => '',
+    'categoryid' => '',
+    'showdebugging' => false,
+    'help' => false,
+], [
+    'f' => 'file',
+    'c' => 'categoryid',
+    's' => 'showdebugging',
+    'h' => 'help',
+]);
+
+if ($unrecognized) {
+    $unrecognized = implode("\n  ", $unrecognized);
+    cli_error(get_string('cliunknowoption', 'admin', $unrecognized));
+}
+
+if ($options['help'] || !($options['file']) || !($options['categoryid'])) {
+    $help = <<<EOL
+Restore backup into provided category.
+
+Options:
+-f, --file=STRING           Path to the backup file.
+-c, --categoryid=INT        ID of the category to restore too.
+-s, --showdebugging         Show developer level debugging information
+-h, --help                  Print out this help.
+
+Example:
+\$sudo -u www-data /usr/bin/php admin/cli/restore_backup.php --file=/path/to/backup/file.mbz --categoryid=1\n
+EOL;
+
+    echo $help;
+    exit(0);
+}
+
+if ($options['showdebugging']) {
+    set_debugging(DEBUG_DEVELOPER, true);
+}
+
+if (!$admin = get_admin()) {
+    print_error('noadmins');
+}
+
+if (!file_exists($options['file'])) {
+    print_error('filenotfound');
+}
+
+if (!$category = $DB->get_record('course_categories', ['id' => $options['categoryid']], 'id')) {
+    print_error('invalidcategoryid');
+}
+
+$backupdir = "restore_" . uniqid();
+$path = $CFG->tempdir . DIRECTORY_SEPARATOR . "backup" . DIRECTORY_SEPARATOR . $backupdir;
+
+cli_heading(get_string('extractingbackupfileto', 'backup', $path));
+$fp = get_file_packer('application/vnd.moodle.backup');
+$fp->extract_to_pathname($options['file'], $path);
+
+cli_heading(get_string('preprocessingbackupfile'));
+try {
+    list($fullname, $shortname) = restore_dbops::calculate_course_names(0, get_string('restoringcourse', 'backup'),
+        get_string('restoringcourseshortname', 'backup'));
+
+    $courseid = restore_dbops::create_new_course($fullname, $shortname, $category->id);
+
+    $rc = new restore_controller($backupdir, $courseid, backup::INTERACTIVE_NO,
+        backup::MODE_GENERAL, $admin->id, backup::TARGET_NEW_COURSE);
+    $rc->execute_precheck();
+    $rc->execute_plan();
+    $rc->destroy();
+} catch (Exception $e) {
+    cli_heading(get_string('cleaningtempdata'));
+    fulldelete($path);
+    print_error('generalexceptionmessage', 'error', '', $e->getMessage());
+}
+
+cli_heading(get_string('restoredcourseid', 'backup', $courseid));
+exit(0);
index f825f46..b181b15 100644 (file)
@@ -30,8 +30,17 @@ require_once("$CFG->libdir/clilib.php");
 require_once("$CFG->libdir/cronlib.php");
 
 list($options, $unrecognized) = cli_get_params(
-    array('help' => false, 'list' => false, 'execute' => false, 'showsql' => false, 'showdebugging' => false),
-    array('h' => 'help')
+    [
+        'help' => false,
+        'list' => false,
+        'execute' => false,
+        'showsql' => false,
+        'showdebugging' => false,
+        'force' => false,
+    ], [
+        'h' => 'help',
+        'f' => 'force',
+    ]
 );
 
 if ($unrecognized) {
@@ -49,6 +58,7 @@ if ($options['help'] or (!$options['list'] and !$options['execute'])) {
     --showsql             Show sql queries before they are executed
     --showdebugging       Show developer level debugging information
     -h, --help            Print out this help
+    -f, --force           Execute task even if cron is disabled
 
     Example:
     \$sudo -u www-data /usr/bin/php admin/cli/scheduled_task.php --execute=\\core\\task\\session_cleanup_task
@@ -121,6 +131,13 @@ if ($execute = $options['execute']) {
         exit(1);
     }
 
+    if (!get_config('core', 'cron_enabled') && !$options['force']) {
+        mtrace('Cron is disabled. Use --force to override.');
+        exit(1);
+    }
+
+    \core\task\manager::scheduled_task_starting($task);
+
     // Increase memory limit.
     raise_memory_limit(MEMORY_EXTRA);
 
index 3e7c68c..249bd53 100644 (file)
@@ -167,6 +167,42 @@ if ($hassiteconfig) {
     $ADMIN->add('modules', new admin_category('antivirussettings', new lang_string('antiviruses', 'antivirus')));
     $temp = new admin_settingpage('manageantiviruses', new lang_string('antivirussettings', 'antivirus'));
     $temp->add(new admin_setting_manageantiviruses());
+
+    // Common settings.
+    $temp->add(new admin_setting_heading('antiviruscommonsettings', new lang_string('antiviruscommonsettings', 'antivirus'), ''));
+
+    // Alert email.
+    $temp->add(
+        new admin_setting_configtext(
+            'antivirus/notifyemail',
+            new lang_string('notifyemail', 'antivirus'),
+            new lang_string('notifyemail_help', 'antivirus'),
+            '',
+            PARAM_EMAIL
+        )
+    );
+
+    // Enable quarantine.
+    $temp->add(
+        new admin_setting_configcheckbox(
+            'antivirus/enablequarantine',
+            new lang_string('enablequarantine', 'antivirus'),
+            new lang_string('enablequarantine_help', 'antivirus',
+            \core\antivirus\quarantine::DEFAULT_QUARANTINE_FOLDER),
+            0
+        )
+    );
+
+    // Quarantine time.
+    $temp->add(
+        new admin_setting_configduration(
+            'antivirus/quarantinetime',
+            new lang_string('quarantinetime', 'antivirus'),
+            new lang_string('quarantinetime_desc', 'antivirus'),
+            \core\antivirus\quarantine::DEFAULT_QUARANTINE_TIME
+        )
+    );
+
     $ADMIN->add('antivirussettings', $temp);
     $plugins = core_plugin_manager::instance()->get_plugins_of_type('antivirus');
     core_collator::asort_objects_by_property($plugins, 'displayname');
index e86bbc9..e9fac46 100644 (file)
@@ -216,6 +216,16 @@ $ADMIN->add('server', $temp);
 
 $ADMIN->add('server', new admin_category('taskconfig', new lang_string('taskadmintitle', 'admin')));
 $temp = new admin_settingpage('taskprocessing', new lang_string('taskprocessing','admin'));
+
+$setting = new admin_setting_configcheckbox(
+    'cron_enabled',
+    new lang_string('cron_enabled', 'admin'),
+    new lang_string('cron_enabled_desc', 'admin'),
+    1
+);
+$setting->set_updatedcallback('theme_reset_static_caches');
+$temp->add($setting);
+
 $temp->add(
     new admin_setting_configtext(
         'task_scheduled_concurrency_limit',
index 63d33fe..54d9c5b 100644 (file)
@@ -135,7 +135,7 @@ $string['effectiveretentionperioduser'] = '{$a} (since the last time the user ac
 $string['emailsalutation'] = 'Dear {$a},';
 $string['errorcannotrequestdeleteforself'] = 'You don\'t have permission to create deletion request for yourself.';
 $string['errorcannotrequestdeleteforother'] = 'You don\'t have permission to create deletion request for this user.';
-$string['errorinvalidrequestcomments'] = 'Please ensure your comment contains plain text only.';
+$string['errorinvalidrequestcomments'] = 'The comments field may contain plain text only.';
 $string['errorinvalidrequestcreationmethod'] = 'Invalid request creation method!';
 $string['errorinvalidrequeststatus'] = 'Invalid request status!';
 $string['errorinvalidrequesttype'] = 'Invalid request type!';
index abaf3e2..cf9126a 100644 (file)
@@ -11,5 +11,5 @@ information provided here is intended especially for developers.
 
 === 3.6 ===
 
-* The legacy log store is in its first stage of deprecation and is due for removal in Moodle 4.0. Please use one of
+* The legacy log store is in its first stage of deprecation and is due for removal in Moodle 3.10. Please use one of
   the other log stores such as "standard" and "database".
index f765213..bb57d22 100644 (file)
@@ -12,8 +12,8 @@ Feature: See the competencies for an activity on the course competencies page.
       | Test-Comp1 | ID-FW1 |
       | Test-Comp2 | ID-FW1 |
     Given the following "courses" exist:
-      | shortname | fullname   |
-      | C1        | Course 1 |
+      | shortname | fullname   | enablecompletion |
+      | C1        | Course 1   | 1                |
     And the following "users" exist:
       | username | firstname | lastname | email |
       | student1 | Student | 1 | student1@example.com |
@@ -21,9 +21,9 @@ Feature: See the competencies for an activity on the course competencies page.
       | user | course | role |
       | student1 | C1 | student |
     And the following "activities" exist:
-      | activity | name       | intro      | course | idnumber |
-      | page     | PageName1  | PageDesc1  | C1     | PAGE1    |
-      | page     | PageName2  | PageDesc2  | C1     | PAGE2    |
+      | activity | name       | intro      | course | idnumber | completion | completionview |
+      | page     | PageName1  | PageDesc1  | C1     | PAGE1    | 1          | 1              |
+      | page     | PageName2  | PageDesc2  | C1     | PAGE2    | 1          | 1              |
     And I log in as "admin"
     And I am on site homepage
     And I follow "Course 1"
@@ -61,3 +61,15 @@ Feature: See the competencies for an activity on the course competencies page.
     And I should not see "Test-Comp1"
     And I should not see "Test-Comp2"
     And I should see "No competencies have been linked to this activity or resource."
+
+  @javascript
+  Scenario: None course competencies page.
+    When I log in as "student1"
+    And I am on site homepage
+    And I follow "Course 1"
+    And I follow "PageName1"
+    Then I should see "Test page content"
+    And I am on site homepage
+    And I follow "Course 1"
+    And I follow "PageName1"
+    Then I should see "Test page content"
diff --git a/admin/tool/task/classes/running_tasks_table.php b/admin/tool/task/classes/running_tasks_table.php
new file mode 100644 (file)
index 0000000..d08f458
--- /dev/null
@@ -0,0 +1,141 @@
+<?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/>.
+
+/**
+ * Running tasks table.
+ *
+ * @package    tool_task
+ * @copyright  2019 The Open University
+ * @copyright  2020 Mikhail Golenkov <golenkovm@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace tool_task;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once($CFG->libdir . '/tablelib.php');
+
+/**
+ * Table to display list of running task.
+ *
+ * @copyright  2019 The Open University
+ * @copyright  2020 Mikhail Golenkov <golenkovm@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class running_tasks_table extends \table_sql {
+
+    /**
+     * Constructor for the running tasks table.
+     */
+    public function __construct() {
+        parent::__construct('runningtasks');
+
+        $columnheaders = [
+            'classname'    => get_string('classname', 'tool_task'),
+            'type'         => get_string('tasktype', 'admin'),
+            'time'         => get_string('time'),
+            'timestarted'  => get_string('started', 'tool_task'),
+            'hostname'     => get_string('hostname', 'tool_task'),
+            'pid'          => get_string('pid', 'tool_task'),
+        ];
+        $this->define_columns(array_keys($columnheaders));
+        $this->define_headers(array_values($columnheaders));
+
+        // The name column is a header.
+        $this->define_header_column('classname');
+
+        // This table is not collapsible.
+        $this->collapsible(false);
+
+        // Allow pagination.
+        $this->pageable(true);
+    }
+
+    /**
+     * Query the db. Store results in the table object for use by build_table.
+     *
+     * @param int $pagesize size of page for paginated displayed table.
+     * @param bool $useinitialsbar do you want to use the initials bar. Bar
+     * will only be used if there is a fullname column defined for the table.
+     * @throws \dml_exception
+     */
+    public function query_db($pagesize, $useinitialsbar = true) {
+        $sort = $this->get_sql_sort();
+        $this->rawdata = \core\task\manager::get_running_tasks($sort);
+    }
+
+    /**
+     * Format the classname cell.
+     *
+     * @param   \stdClass $row
+     * @return  string
+     */
+    public function col_classname($row) : string {
+        $output = $row->classname;
+        if ($row->type == 'scheduled') {
+            if (class_exists($row->classname)) {
+                $task = new $row->classname;
+                if ($task instanceof \core\task\scheduled_task) {
+                    $output .= \html_writer::tag('div', $task->get_name(), ['class' => 'task-class']);
+                }
+            }
+        } else if ($row->type == 'adhoc') {
+            $output .= \html_writer::tag('div',
+                get_string('adhoctaskid', 'tool_task', $row->id), ['class' => 'task-class']);
+        }
+        return $output;
+    }
+
+    /**
+     * Format the type cell.
+     *
+     * @param   \stdClass $row
+     * @return  string
+     * @throws  \coding_exception
+     */
+    public function col_type($row) : string {
+        if ($row->type == 'scheduled') {
+            $output = \html_writer::span(get_string('scheduled', 'tool_task'), 'badge badge-primary');
+        } else if ($row->type == 'adhoc') {
+            $output = \html_writer::span(get_string('adhoc', 'tool_task'), 'badge badge-warning');
+        } else {
+            // This shouldn't ever happen.
+            $output = '';
+        }
+        return $output;
+    }
+
+    /**
+     * Format the time cell.
+     *
+     * @param   \stdClass $row
+     * @return  string
+     */
+    public function col_time($row) : string {
+        return format_time($row->time);
+    }
+
+    /**
+     * Format the timestarted cell.
+     *
+     * @param   \stdClass $row
+     * @return  string
+     */
+    public function col_timestarted($row) : string {
+        return userdate($row->timestarted);
+    }
+}
index 299603f..08cf819 100644 (file)
@@ -22,6 +22,9 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+$string['adhoc'] = 'Ad-hoc';
+$string['adhoctaskid'] = 'Ad-hoc task id: {$a}';
+$string['adhoctasks'] = 'Ad-hoc tasks';
 $string['asap'] = 'ASAP';
 $string['adhocempty'] = 'Ad hoc task queue is empty';
 $string['adhocqueuesize'] = 'Ad hoc task queue has {$a} tasks';
@@ -32,9 +35,11 @@ $string['cannotfindthepathtothecli'] = 'Cannot find the path to the PHP CLI exec
 $string['checkadhocqueue'] = 'Ad hoc task queue';
 $string['checkcronrunning'] = 'Cron running';
 $string['checkmaxfaildelay'] = 'Tasks max fail delay';
+$string['classname'] = 'Class name';
 $string['clearfaildelay_confirm'] = 'Are you sure you want to clear the fail delay for task \'{$a}\'? After clearing the delay, the task will run according to its normal schedule.';
 $string['component'] = 'Component';
 $string['corecomponent'] = 'Core';
+$string['crondisabled'] = 'Cron is disabled. No new tasks will be started. The system will not operate properly until it is enabled again.';
 $string['cronok'] = 'Cron is running frequently';
 $string['default'] = 'Default';
 $string['defaultx'] = 'Default: {$a}';
@@ -45,18 +50,24 @@ $string['enablerunnow'] = 'Allow \'Run now\' for scheduled tasks';
 $string['enablerunnow_desc'] = 'Allows administrators to run a single scheduled task immediately, rather than waiting for it to run as scheduled. The feature requires \'Path to PHP CLI\' (pathtophp) to be set in System paths. The task runs on the web server, so you may wish to disable this feature to avoid potential performance issues.';
 $string['faildelay'] = 'Fail delay';
 $string['fromcomponent'] = 'From component: {$a}';
+$string['hostname'] = 'Host name';
 $string['lastruntime'] = 'Last run';
+$string['lastupdated'] = 'Last updated {$a}.';
 $string['nextruntime'] = 'Next run';
+$string['pid'] = 'PID';
 $string['plugindisabled'] = 'Plugin disabled';
 $string['pluginname'] = 'Scheduled task configuration';
 $string['resettasktodefaults'] = 'Reset task schedule to defaults';
 $string['resettasktodefaults_help'] = 'This will discard any local changes and revert the schedule for this task back to its original settings.';
+$string['runningtasks'] = 'Tasks running now';
 $string['runnow'] = 'Run now';
 $string['runagain'] = 'Run again';
 $string['runnow_confirm'] = 'Are you sure you want to run this task \'{$a}\' now? The task will run on the web server and may take some time to complete.';
 $string['runpattern'] = 'Run pattern';
+$string['scheduled'] = 'Scheduled';
 $string['scheduledtasks'] = 'Scheduled tasks';
 $string['scheduledtaskchangesdisabled'] = 'Modifications to the list of scheduled tasks have been prevented in Moodle configuration';
+$string['started'] = 'Started';
 $string['taskdisabled'] = 'Task disabled';
 $string['taskfailures'] = '{$a} task(s) failing';
 $string['tasklogs'] = 'Task logs';
index ec0bb0c..ec85231 100644 (file)
@@ -261,6 +261,16 @@ class tool_task_renderer extends plugin_renderer_base {
         return $cell;
     }
 
+    /**
+     * Displays a warning on the page if cron is disabled.
+     *
+     * @return string HTML code for information about cron being disabled
+     * @throws moodle_exception
+     */
+    public function cron_disabled(): string {
+        return $this->output->notification(get_string('crondisabled', 'tool_task'), 'warning');
+    }
+
     /**
      * Renders a link back to the scheduled tasks page (used from the 'run now' screen).
      *
diff --git a/admin/tool/task/runningtasks.php b/admin/tool/task/runningtasks.php
new file mode 100644 (file)
index 0000000..05829a3
--- /dev/null
@@ -0,0 +1,51 @@
+<?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/>.
+
+/**
+ * Running task admin page.
+ *
+ * @package    tool_task
+ * @copyright  2019 The Open University
+ * @copyright  2020 Mikhail Golenkov <golenkovm@gmail.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+require_once(__DIR__ . '/../../../config.php');
+require_once($CFG->libdir.'/adminlib.php');
+require_once($CFG->libdir.'/tablelib.php');
+
+$pageurl = new \moodle_url('/admin/tool/task/runningtasks.php');
+$heading = get_string('runningtasks', 'tool_task');
+$PAGE->set_url($pageurl);
+$PAGE->set_context(context_system::instance());
+$PAGE->set_pagelayout('admin');
+$PAGE->set_title($heading);
+$PAGE->set_heading($heading);
+
+admin_externalpage_setup('runningtasks');
+
+echo $OUTPUT->header();
+
+if (!get_config('core', 'cron_enabled')) {
+    $renderer = $PAGE->get_renderer('tool_task');
+    echo $renderer->cron_disabled();
+}
+
+$table = new \tool_task\running_tasks_table();
+$table->baseurl = $pageurl;
+$table->out(100, false);
+
+echo $OUTPUT->footer();
index d256f3d..243039e 100644 (file)
@@ -95,6 +95,9 @@ if ($mform && ($mform->is_cancelled() || !empty($CFG->preventscheduledtaskchange
 
 } else {
     echo $OUTPUT->header();
+    if (!get_config('core', 'cron_enabled')) {
+        echo $renderer->cron_disabled();
+    }
     $tasks = core\task\manager::get_all_scheduled_tasks();
     echo $renderer->scheduled_tasks_table($tasks, $lastchanged);
     echo $OUTPUT->footer();
index ac9858e..3fd9669 100644 (file)
@@ -33,4 +33,13 @@ if ($hassiteconfig) {
             "$CFG->wwwroot/$CFG->admin/tool/task/scheduledtasks.php"
         )
     );
+
+    $ADMIN->add(
+        'taskconfig',
+        new admin_externalpage(
+            'runningtasks',
+            new lang_string('runningtasks', 'tool_task'),
+            "$CFG->wwwroot/$CFG->admin/tool/task/runningtasks.php"
+        )
+    );
 }
index 0e846ce..f297fd6 100644 (file)
@@ -1,4 +1,5 @@
-#page-admin-tool-task-scheduledtasks .task-class {
+#page-admin-tool-task-scheduledtasks .task-class,
+#page-admin-tool-task-runningtasks .task-class {
     display: block;
     padding: 0 0.5em;
     color: #888;
diff --git a/admin/tool/task/tests/behat/cron_disabled.feature b/admin/tool/task/tests/behat/cron_disabled.feature
new file mode 100644 (file)
index 0000000..24c7e87
--- /dev/null
@@ -0,0 +1,20 @@
+@tool @tool_task
+Feature: See warning message if cron is disabled
+  In order to manage scheduled tasks
+  As a Moodle Administrator
+  I need to be able to view a warning message if cron is disabled
+
+  Background:
+    Given I log in as "admin"
+
+  Scenario: If cron is disabled, I should see the message
+    When the following config values are set as admin:
+      | cron_enabled | 0 |
+    And I navigate to "Server > Tasks > Scheduled tasks" in site administration
+    Then I should see "Cron is disabled"
+
+  Scenario: If cron is enabled, I should not see the message
+    When the following config values are set as admin:
+      | cron_enabled | 1 |
+    And I navigate to "Server > Tasks > Scheduled tasks" in site administration
+    Then I should not see "Cron is disabled"
diff --git a/admin/tool/task/tests/behat/running_tasks.feature b/admin/tool/task/tests/behat/running_tasks.feature
new file mode 100644 (file)
index 0000000..e5db69c
--- /dev/null
@@ -0,0 +1,40 @@
+@tool @tool_task
+Feature: See running scheduled tasks
+  In order to configure scheduled tasks
+  As an admin
+  I need to see if tasks are running
+
+  Background:
+    Given I log in as "admin"
+
+  Scenario: If no task is running, I should see the corresponding message
+    Given I navigate to "Server > Tasks > Tasks running now" in site administration
+    Then I should see "Nothing to display"
+
+  Scenario: If tasks are running, I should see task details
+    Given the following "tool_task > scheduled tasks" exist:
+      | classname                            | seconds | hostname     | pid  |
+      | \core\task\automated_backup_task     | 121     | c69335460f7f | 1914 |
+    And the following "tool_task > adhoc tasks" exist:
+      | classname                            | seconds | hostname     | pid  |
+      | \core\task\asynchronous_backup_task  | 7201    | c69335460f7f | 1915 |
+      | \core\task\asynchronous_restore_task | 172800  | c69335460f7f | 1916 |
+    And I navigate to "Server > Tasks > Tasks running now" in site administration
+
+    # Check the scheduled task details.
+    Then I should see "Scheduled" in the "\core\task\automated_backup_task" "table_row"
+    And I should see "2 mins" in the "Automated backups" "table_row"
+    And I should see "c69335460f7f" in the "Automated backups" "table_row"
+    And I should see "1914" in the "Automated backups" "table_row"
+
+    # Check the "asynchronous_backup_task" adhoc task details.
+    And I should see "Ad-hoc" in the "\core\task\asynchronous_backup_task" "table_row"
+    And I should see "2 hours" in the "core\task\asynchronous_backup_task" "table_row"
+    And I should see "c69335460f7f" in the "core\task\asynchronous_backup_task" "table_row"
+    And I should see "1915" in the "core\task\asynchronous_backup_task" "table_row"
+
+    # Check the "asynchronous_restore_task" adhoc task details.
+    And I should see "Ad-hoc" in the "\core\task\asynchronous_restore_task" "table_row"
+    And I should see "2 days" in the "core\task\asynchronous_restore_task" "table_row"
+    And I should see "c69335460f7f" in the "core\task\asynchronous_restore_task" "table_row"
+    And I should see "1916" in the "core\task\asynchronous_restore_task" "table_row"
diff --git a/admin/tool/task/tests/generator/behat_tool_task_generator.php b/admin/tool/task/tests/generator/behat_tool_task_generator.php
new file mode 100644 (file)
index 0000000..307ad81
--- /dev/null
@@ -0,0 +1,57 @@
+<?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/>.
+
+/**
+ * Behat data generator for tool_task.
+ *
+ * @package   tool_task
+ * @category  test
+ * @copyright 2020 Mikhail Golenkov <golenkovm@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Behat data generator for tool_task.
+ *
+ * @package   tool_task
+ * @category  test
+ * @copyright 2020 Mikhail Golenkov <golenkovm@gmail.com>
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_tool_task_generator extends behat_generator_base {
+
+    /**
+     * Get a list of the entities that can be created.
+
+     * @return array entity name => information about how to generate.
+     */
+    protected function get_creatable_entities(): array {
+        return [
+            'scheduled tasks' => [
+                'singular' => 'scheduled task',
+                'datagenerator' => 'scheduled_tasks',
+                'required' => ['classname', 'seconds', 'hostname', 'pid'],
+            ],
+            'adhoc tasks' => [
+                'singular' => 'adhoc task',
+                'datagenerator' => 'adhoc_tasks',
+                'required' => ['classname', 'seconds', 'hostname', 'pid'],
+            ],
+        ];
+    }
+}
diff --git a/admin/tool/task/tests/generator/lib.php b/admin/tool/task/tests/generator/lib.php
new file mode 100644 (file)
index 0000000..b9324c5
--- /dev/null
@@ -0,0 +1,69 @@
+<?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/>.
+
+/**
+ * Tool task test data generator class
+ *
+ * @package tool_task
+ * @copyright 2020 Mikhail Golenkov <golenkovm@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Tool task test data generator class
+ *
+ * @package tool_task
+ * @copyright 2020 Mikhail Golenkov <golenkovm@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class tool_task_generator extends testing_module_generator {
+
+    /**
+     * Mark a scheduled task as running.
+     *
+     * @param array $data Scheduled task properties
+     * @throws dml_exception
+     */
+    public function create_scheduled_tasks($data) {
+        global $DB;
+        $conditions = ['classname' => $data['classname']];
+        $record = $DB->get_record('task_scheduled', $conditions, '*', MUST_EXIST);
+        $record->timestarted = time() - $data['seconds'];
+        $record->hostname = $data['hostname'];
+        $record->pid = $data['pid'];
+        $DB->update_record('task_scheduled', $record);
+    }
+
+    /**
+     * Mark an adhoc task as running.
+     *
+     * @param array $data Adhoc task properties
+     * @throws dml_exception
+     */
+    public function create_adhoc_tasks($data) {
+        global $DB;
+        $adhoctask = (object)[
+            'classname' => $data['classname'],
+            'nextruntime' => 0,
+            'timestarted' => time() - $data['seconds'],
+            'hostname' => $data['hostname'],
+            'pid' => $data['pid'],
+        ];
+        $DB->insert_record('task_adhoc', $adhoctask);
+    }
+}
index 1823273..151504c 100644 (file)
@@ -24,7 +24,7 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2021052500; // The current plugin version (Date: YYYYMMDDXX)
+$plugin->version   = 2021052501; // The current plugin version (Date: YYYYMMDDXX)
 $plugin->requires  = 2021052500; // Requires this Moodle version
 $plugin->component = 'tool_task'; // Full name of the plugin (used for diagnostics)
 
index 4a9f5cf..6741568 100644 (file)
@@ -138,13 +138,28 @@ class manager {
      */
     const CONFIG_SHIPPED_VERSION = 'shipped_version';
 
+    /**
+     * Helper method to initialize admin page, setting appropriate extra URL parameters
+     *
+     * @param string $action
+     */
+    protected function setup_admin_externalpage(string $action): void {
+        admin_externalpage_setup('tool_usertours/tours', '', array_filter([
+            'action' => $action,
+            'id' => optional_param('id', 0, PARAM_INT),
+            'tourid' => optional_param('tourid', 0, PARAM_INT),
+            'direction' => optional_param('direction', 0, PARAM_INT),
+        ]));
+    }
+
     /**
      * This is the entry point for this controller class.
      *
      * @param   string  $action     The action to perform.
      */
     public function execute($action) {
-        admin_externalpage_setup('tool_usertours/tours');
+        $this->setup_admin_externalpage($action);
+
         // Add the main content.
         switch($action) {
             case self::ACTION_NEWTOUR:
index 5ebb83a..8397ac4 100644 (file)
@@ -61,10 +61,10 @@ Feature: Authentication
     # The following tests are all provided to ensure that the accessibility tests themselves are tested.
     # In normal tests only one of the following is required.
     Then the page should meet accessibility standards
-    And the page should meet "wcag131, wcag412" accessibility standards
-    And the page should meet accessibility standards with "wcag131, wcag412" extra tests
+    And the page should meet "wcag131, wcag141, wcag412" accessibility standards
+    And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
 
     And I follow "Log in"
     And the page should meet accessibility standards
-    And the page should meet "wcag131, wcag412" accessibility standards
-    And the page should meet accessibility standards with "wcag131, wcag412" extra tests
+    And the page should meet "wcag131, wcag141, wcag412" accessibility standards
+    And the page should meet accessibility standards with "wcag131, wcag141, wcag412" extra tests
index 7563077..d92a97e 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /cache/stores/* - cache store plugins.
 Information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 * The function supports_recursion() from the lock_factory interface has been deprecated including the related implementations.
 * The function extend_lock() from the lock_factory interface has been deprecated without replacement including the related
   implementations.
index 5e9f641..35fda4d 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /calendar/* ,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 * The core_calendar\local\event\value_objects\times_interface class now has new method get_usermidnight_time() which
   returns the user midnight time for a given event.
 
index f611f98..0341bf9 100644 (file)
@@ -154,7 +154,7 @@ foreach($cohorts['cohorts'] as $cohort) {
         $cohortmanager = has_capability('moodle/cohort:manage', $cohortcontext);
         $cohortcanassign = has_capability('moodle/cohort:assign', $cohortcontext);
 
-        $urlparams = array('id' => $cohort->id, 'returnurl' => $baseurl->out_as_local_url());
+        $urlparams = array('id' => $cohort->id, 'returnurl' => $baseurl->out_as_local_url(false));
         $showhideurl = new moodle_url('/cohort/edit.php', $urlparams + array('sesskey' => sesskey()));
         if ($cohortmanager) {
             if ($cohort->visible) {
index ba22662..f5df09e 100644 (file)
@@ -4660,6 +4660,9 @@ class api {
                 $recommend = false;
                 $strdesc = 'evidence_coursemodulecompleted';
 
+                if ($outcome == course_module_competency::OUTCOME_NONE) {
+                    continue;
+                }
                 if ($outcome == course_module_competency::OUTCOME_EVIDENCE) {
                     $action = evidence::ACTION_LOG;
 
@@ -4720,6 +4723,9 @@ class api {
             $recommend = false;
             $strdesc = 'evidence_coursecompleted';
 
+            if ($outcome == course_module_competency::OUTCOME_NONE) {
+                continue;
+            }
             if ($outcome == course_competency::OUTCOME_EVIDENCE) {
                 $action = evidence::ACTION_LOG;
 
index 37e3385..227294b 100644 (file)
@@ -28,6 +28,7 @@ use core_text;
 use stored_file;
 use stdClass;
 use coding_exception;
+use context;
 use moodle_url;
 use core\event\contentbank_content_updated;
 
@@ -85,6 +86,17 @@ abstract class content {
         return $this->content->contenttype;
     }
 
+    /**
+     * Return the contenttype instance of this content.
+     *
+     * @return contenttype The content type instance
+     */
+    public function get_content_type_instance(): contenttype {
+        $context = context::instance_by_id($this->content->contextid);
+        $contenttypeclass = "\\{$this->content->contenttype}\\contenttype";
+        return new $contenttypeclass($context);
+    }
+
     /**
      * Returns $this->content->timemodified.
      *
index 307b949..1e5c934 100644 (file)
@@ -334,4 +334,18 @@ class contentbank {
 
         return $contenttypes;
     }
+
+    /**
+     * Return a content class form a content id.
+     *
+     * @throws coding_exception if the ID is not valid or some class does no exists
+     * @param int $id the content id
+     * @return content the content class instance
+     */
+    public function get_content_from_id(int $id): content {
+        global $DB;
+        $record = $DB->get_record('contentbank_content', ['id' => $id], '*', MUST_EXIST);
+        $contentclass = "\\$record->contenttype\\content";
+        return new $contentclass($record);
+    }
 }
index 699cee0..8f9fec3 100644 (file)
@@ -40,12 +40,18 @@ use moodle_url;
  */
 abstract class contenttype {
 
-    /** Plugin implements uploading feature */
+    /** @var string Constant representing whether the plugin implements uploading feature */
     const CAN_UPLOAD = 'upload';
 
-    /** Plugin implements edition feature */
+    /** @var string Constant representing whether the plugin implements edition feature */
     const CAN_EDIT = 'edit';
 
+    /**
+     * @var string Constant representing whether the plugin implements download feature
+     * @since  Moodle 3.10
+     */
+    const CAN_DOWNLOAD = 'download';
+
     /** @var \context This contenttype's context. **/
     protected $context = null;
 
@@ -115,6 +121,21 @@ abstract class contenttype {
         return $content;
     }
 
+    /**
+     * Replace a content using an uploaded file.
+     *
+     * @throws file_exception If file operations fail
+     * @throws dml_exception if the content creation fails
+     * @param stored_file $file the uploaded file
+     * @param content $content the original content record
+     * @return content Object with the updated content bank information.
+     */
+    public function replace_content(stored_file $file, content $content): content {
+        $content->import_file($file);
+        $content->update_content();
+        return $content;
+    }
+
     /**
      * Delete this content from the content_bank.
      * This method can be overwritten by the plugins if they need to delete specific information.
@@ -220,6 +241,31 @@ abstract class contenttype {
         return '';
     }
 
+    /**
+     * Returns the URL to download the content.
+     *
+     * @since  Moodle 3.10
+     * @param  content $content The content to be downloaded.
+     * @return string           URL with the content to download.
+     */
+    public function get_download_url(content $content): string {
+        $downloadurl = '';
+        $file = $content->get_file();
+        if (!empty($file)) {
+            $url = \moodle_url::make_pluginfile_url(
+                $file->get_contextid(),
+                $file->get_component(),
+                $file->get_filearea(),
+                $file->get_itemid(),
+                $file->get_filepath(),
+                $file->get_filename()
+            );
+            $downloadurl = $url->out(false);
+        }
+
+        return $downloadurl;
+    }
+
     /**
      * Returns the HTML code to render the icon for content bank contents.
      *
@@ -392,6 +438,38 @@ abstract class contenttype {
         return true;
     }
 
+    /**
+     * Returns whether or not the user has permission to download the content.
+     *
+     * @since  Moodle 3.10
+     * @param  content $content The content to be downloaded.
+     * @return bool    True if the user can download the content. False otherwise.
+     */
+    final public function can_download(content $content): bool {
+        if (!$this->is_feature_supported(self::CAN_DOWNLOAD)) {
+            return false;
+        }
+
+        if (!$this->can_access()) {
+            return false;
+        }
+
+        $hascapability = has_capability('moodle/contentbank:downloadcontent', $this->context);
+        return $hascapability && $this->is_download_allowed($content);
+    }
+
+    /**
+     * Returns plugin allows downloading.
+     *
+     * @since  Moodle 3.10
+     * @param  content $content The content to be downloaed.
+     * @return bool    True if plugin allows downloading. False otherwise.
+     */
+    protected function is_download_allowed(content $content): bool {
+        // Plugins can overwrite this function to add any check they need.
+        return true;
+    }
+
     /**
      * Returns the plugin supports the feature.
      *
index 1c1f2ea..e16a30e 100644 (file)
@@ -110,7 +110,7 @@ class contenttype extends \core_contentbank\contenttype {
      * @return array
      */
     protected function get_implemented_features(): array {
-        return [self::CAN_UPLOAD, self::CAN_EDIT];
+        return [self::CAN_UPLOAD, self::CAN_EDIT, self::CAN_DOWNLOAD];
     }
 
     /**
diff --git a/contentbank/contenttype/h5p/tests/behat/admin_replace_content.feature b/contentbank/contenttype/h5p/tests/behat/admin_replace_content.feature
new file mode 100644 (file)
index 0000000..bc5202c
--- /dev/null
@@ -0,0 +1,30 @@
+@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+Feature: Replace H5P file from an existing content
+  In order to replace an H5P content from the content bank
+  As an admin
+  I need to be able to replace the content with a new .h5p file
+
+  Background:
+    Given the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user  | contentname       | filepath                              |
+      | System       |           | contenttype_h5p | admin | filltheblanks.h5p | /h5p/tests/fixtures/filltheblanks.h5p |
+    And I log in as "admin"
+    And I press "Customise this page"
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+
+  Scenario: Admins can replace the original .h5p file with a new one
+    Given I click on "filltheblanks.h5p" "link"
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I should see "Of which countries"
+    And I switch to the main frame
+    When I open the action menu in "region-main-settings-menu" "region"
+    And I choose "Replace with file" in the open action menu
+    And I upload "h5p/tests/fixtures/ipsums.h5p" file to "Upload content" filemanager
+    And I click on "Save changes" "button"
+    Then I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I should see "Lorum ipsum"
+    And I switch to the main frame
diff --git a/contentbank/contenttype/h5p/tests/behat/teacher_replace_content.feature b/contentbank/contenttype/h5p/tests/behat/teacher_replace_content.feature
new file mode 100644 (file)
index 0000000..5a53f5c
--- /dev/null
@@ -0,0 +1,67 @@
+@core @core_contentbank @contenttype_h5p @_file_upload @_switch_iframe @javascript
+Feature: Replace H5P file from an existing content requires special capabilities
+  In order replace an H5P content from the content bank
+  As a teacher
+  I need to be able to replace the content only if certain capabilities are allowed
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+    And the following "categories" exist:
+      | name  | category | idnumber |
+      | Cat 1 | 0        | CAT1     |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | CAT1     |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user     | contentname       | filepath                              |
+      | Course       | C1        | contenttype_h5p | admin    | admincontent      | /h5p/tests/fixtures/ipsums.h5p        |
+      | Course       | C1        | contenttype_h5p | teacher1 | teachercontent    | /h5p/tests/fixtures/filltheblanks.h5p |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add the "Navigation" block if not present
+    And I expand "Site pages" node
+    And I click on "Content bank" "link"
+    # Force the content deploy
+    And I click on "admincontent" "link"
+    And I click on "Content bank" "link"
+
+  Scenario: Teacher can replace its own H5P files
+    Given I click on "teachercontent" "link"
+    When I open the action menu in "region-main-settings-menu" "region"
+    And I choose "Replace with file" in the open action menu
+    And I upload "h5p/tests/fixtures/ipsums.h5p" file to "Upload content" filemanager
+    And I click on "Save changes" "button"
+    Then I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I should see "Lorum ipsum"
+    And I switch to the main frame
+
+  Scenario: Teacher cannot replace another user's H5P files
+    When I click on "admincontent" "link"
+    Then "region-main-settings-menu" "region" should not exist
+
+  Scenario: Teacher cannot replace a content without having upload capability
+    Given the following "permission overrides" exist:
+      | capability                | permission | role           | contextlevel | reference |
+      | moodle/contentbank:upload | Prevent    | editingteacher | Course       | C1        |
+    When I click on "teachercontent" "link"
+    Then "region-main-settings-menu" "region" should not exist
+
+  Scenario: Teacher cannot replace a content without having the H5P upload capability
+    Given the following "permission overrides" exist:
+      | capability             | permission | role           | contextlevel | reference |
+      | contenttype/h5p:upload | Prevent    | editingteacher | Course       | C1        |
+    When I click on "teachercontent" "link"
+    Then "region-main-settings-menu" "region" should not exist
+
+  Scenario: Teacher cannot replace a content without having the manage own content capability
+    Given the following "permission overrides" exist:
+      | capability                          | permission | role           | contextlevel | reference |
+      | moodle/contentbank:manageowncontent | Prevent    | editingteacher | Course       | C1        |
+    When I click on "teachercontent" "link"
+    Then "region-main-settings-menu" "region" should not exist
index c708516..c806134 100644 (file)
@@ -147,4 +147,40 @@ class contenttype_h5p_contenttype_plugin_testcase extends advanced_testcase {
         $this->assertNotEquals($defaulticon, $findicon);
         $this->assertContains('find', $findicon, '', true);
     }
+
+    /**
+     * Tests get_download_url result.
+     *
+     * @covers ::get_download_url
+     */
+    public function test_get_download_url() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $systemcontext = context_system::instance();
+        $this->setAdminUser();
+        $contenttype = new contenttype_h5p\contenttype($systemcontext);
+
+        // Add an H5P fill the blanks file to the content bank.
+        $filename = 'filltheblanks.h5p';
+        $filepath = $CFG->dirroot . '/h5p/tests/fixtures/' . $filename;
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_h5p', 1, 0, $systemcontext, true, $filepath);
+        $filltheblanks = array_shift($contents);
+
+        // Check before deploying the URL is returned OK.
+        $url1 = $contenttype->get_download_url($filltheblanks);
+        $this->assertNotEmpty($url1);
+        $this->assertContains($filename, $url1);
+
+        // Deploy the contents though the player to create the H5P DB entries and know specific content type.
+        $h5pplayer = new \core_h5p\player($filltheblanks->get_file_url(), new \stdClass(), true);
+        $h5pplayer->add_assets_to_page();
+        $h5pplayer->output();
+
+        // Once the H5P has been deployed, the URL is still the same.
+        $url2 = $contenttype->get_download_url($filltheblanks);
+        $this->assertNotEmpty($url2);
+        $this->assertEquals($url1, $url2);
+    }
 }
index d94f61f..6a001f0 100644 (file)
@@ -44,6 +44,11 @@ class contentbank_files_form extends moodleform {
         $mform->addElement('hidden', 'contextid', $this->_customdata['contextid']);
         $mform->setType('contextid', PARAM_INT);
 
+        if (!empty($this->_customdata['id'])) {
+            $mform->addElement('hidden', 'id', $this->_customdata['id']);
+            $mform->setType('id', PARAM_INT);
+        }
+
         $options = $this->_customdata['options'];
         $mform->addElement('filepicker', 'file', get_string('file', 'core_contentbank'), null, $options);
         $mform->addHelpButton('file', 'file', 'core_contentbank');
diff --git a/contentbank/tests/behat/download_content.feature b/contentbank/tests/behat/download_content.feature
new file mode 100644 (file)
index 0000000..9fca557
--- /dev/null
@@ -0,0 +1,61 @@
+@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+Feature: Download H5P content from the content bank
+  In order export H5P content from the content bank
+  As an admin
+  I need to be able to download any H5P content from the content bank
+
+  Background:
+    Given the following "users" exist:
+      | username    | firstname | lastname | email              |
+      | manager     | Max       | Manager  | man@example.com    |
+    And the following "role assigns" exist:
+      | user        | role      | contextlevel  | reference     |
+      | manager     | manager   | System        |               |
+    And the following "contentbank content" exist:
+      | contextlevel | reference | contenttype     | user    | contentname              | filepath                               |
+      | System       |           | contenttype_h5p | admin   | filltheblanksadmin.h5p   | /h5p/tests/fixtures/filltheblanks.h5p  |
+      | System       |           | contenttype_h5p | manager | filltheblanksmanager.h5p | /h5p/tests/fixtures/filltheblanks.h5p  |
+    And I log in as "admin"
+    And I am on site homepage
+    And I turn editing mode on
+    And I add the "Navigation" block if not present
+    And I configure the "Navigation" block
+    And I set the following fields to these values:
+      | Page contexts | Display throughout the entire site |
+    And I press "Save changes"
+
+  Scenario: Admins can download content from the content bank
+    Given I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Content bank" "link" in the "Navigation" "block"
+    And I follow "filltheblanksmanager.h5p"
+    And I open the action menu in "region-main-settings-menu" "region"
+    And I should see "Download"
+    When I choose "Download" in the open action menu
+    Then I should see "filltheblanksmanager.h5p"
+
+  Scenario: Users can download content created by different users
+    Given the following "permission overrides" exist:
+      | capability                            | permission | role    | contextlevel | reference |
+      | moodle/contentbank:manageanycontent   | Prohibit   | manager | System       |           |
+    And I log out
+    And I log in as "manager"
+    When I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Content bank" "link" in the "Navigation" "block"
+    And I should see "filltheblanksadmin.h5p"
+    And I follow "filltheblanksadmin.h5p"
+    And I open the action menu in "region-main-settings-menu" "region"
+    Then I should see "Download"
+    And I should not see "Rename"
+
+  Scenario: Users without the required capability cannot download content
+    Given the following "permission overrides" exist:
+      | capability                            | permission | role    | contextlevel | reference |
+      | moodle/contentbank:downloadcontent    | Prohibit   | manager | System       |           |
+    And I log out
+    And I log in as "manager"
+    When I click on "Site pages" "list_item" in the "Navigation" "block"
+    And I click on "Content bank" "link" in the "Navigation" "block"
+    And I should see "filltheblanksmanager.h5p"
+    And I follow "filltheblanksmanager.h5p"
+    And I open the action menu in "region-main-settings-menu" "region"
+    Then I should not see "Download"
index ddfff94..4c7ec2b 100644 (file)
@@ -275,4 +275,27 @@ class core_contenttype_content_testcase extends \advanced_testcase {
         $contentfile = $content->get_file($file);
         $this->assertEquals($importedfile->get_id(), $contentfile->get_id());
     }
+
+    /**
+     * Tests for 'get_content_type_instance'
+     *
+     * @covers ::get_content_type_instance
+     */
+    public function test_get_content_type_instance(): void {
+        global $USER;
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        $type = new contenttype($context);
+        $record = (object)[
+            'name' => 'content name',
+            'usercreated' => $USER->id,
+        ];
+        $content = $type->create_content($record);
+
+        $contenttype = $content->get_content_type_instance();
+
+        $this->assertInstanceOf(get_class($type), $contenttype);
+    }
 }
index cd22e80..3d6a703 100644 (file)
@@ -31,6 +31,7 @@ use advanced_testcase;
 use context_course;
 use context_coursecat;
 use context_system;
+use Exception;
 
 global $CFG;
 require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
@@ -603,4 +604,31 @@ class core_contentbank_testcase extends advanced_testcase {
         $actual = $cb->get_contenttypes_with_capability_feature('test2', null, $enabled);
         $this->assertEquals($contenttypescanfeature, array_values($actual));
     }
+
+    /**
+     * Test the behaviour of get_content_from_id()
+     *
+     * @covers  ::get_content_from_id
+     */
+    public function test_get_content_from_id() {
+
+        $this->resetAfterTest();
+        $cb = new \core_contentbank\contentbank();
+
+        // Create a category and two courses.
+        $systemcontext = context_system::instance();
+
+        // Add some content to the content bank.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data(null, 3, 0, $systemcontext);
+        $content = reset($contents);
+
+        // Get the content instance form id.
+        $newinstance = $cb->get_content_from_id($content->get_id());
+        $this->assertEquals($content->get_id(), $newinstance->get_id());
+
+        // Now produce and exception with an innexistent id.
+        $this->expectException(Exception::class);
+        $cb->get_content_from_id(0);
+    }
 }
index c8ae908..1995357 100644 (file)
@@ -290,6 +290,84 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->assertEquals(1, $DB->count_records('files', ['contenthash' => $dummyfile->get_contenthash()]));
     }
 
+    /**
+     * Tests for behaviour of replace_content() using a dummy file.
+     *
+     * @covers ::replace_content
+     */
+    public function test_replace_content(): void {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        // Add some content to the content bank.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 3, 0, $context);
+        $content = reset($contents);
+
+        $dummy = [
+            'contextid' => context_user::instance($USER->id)->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'file.h5p',
+            'userid' => $USER->id,
+        ];
+        $fs = get_file_storage();
+        $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+        $contenttype = new contenttype(context_system::instance());
+        $content = $contenttype->replace_content($dummyfile, $content);
+
+        $this->assertEquals('contenttype_testable', $content->get_content_type());
+        $this->assertInstanceOf('\\contenttype_testable\\content', $content);
+
+        $file = $content->get_file();
+        $this->assertEquals($dummyfile->get_userid(), $file->get_userid());
+        $this->assertEquals($dummyfile->get_contenthash(), $file->get_contenthash());
+        $this->assertEquals('contentbank', $file->get_component());
+        $this->assertEquals('public', $file->get_filearea());
+        $this->assertEquals('/', $file->get_filepath());
+    }
+
+    /**
+     * Tests for behaviour of replace_content() using an error file.
+     *
+     * @covers ::replace_content
+     */
+    public function test_replace_content_exception(): void {
+        global $USER;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $context = context_system::instance();
+
+        // Add some content to the content bank.
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 3, 0, $context);
+        $content = reset($contents);
+
+        $dummy = [
+            'contextid' => context_user::instance($USER->id)->id,
+            'component' => 'user',
+            'filearea' => 'draft',
+            'itemid' => 1,
+            'filepath' => '/',
+            'filename' => 'error.txt',
+            'userid' => $USER->id,
+        ];
+        $fs = get_file_storage();
+        $dummyfile = $fs->create_file_from_string($dummy, 'Dummy content');
+
+        $contenttype = new contenttype(context_system::instance());
+
+        $this->expectException(Exception::class);
+        $content = $contenttype->replace_content($dummyfile, $content);
+    }
+
     /**
      * Test the behaviour of can_delete().
      */
@@ -348,7 +426,7 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
     /**
      * Helper function to setup 3 users (manager1, manager2 and user) and 4 contents (3 created by manager1 and 1 by user).
      */
-    protected function contenttype_setup_scenario_data(): void {
+    protected function contenttype_setup_scenario_data(string $contenttype = 'contenttype_testable'): void {
         global $DB;
         $systemcontext = context_system::instance();
 
@@ -358,14 +436,17 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->managerroleid = $DB->get_field('role', 'id', array('shortname' => 'manager'));
         $this->getDataGenerator()->role_assign($this->managerroleid, $this->manager1->id);
         $this->getDataGenerator()->role_assign($this->managerroleid, $this->manager2->id);
+        $editingteacherrolerid = $DB->get_field('role', 'id', ['shortname' => 'editingteacher']);
         $this->user = $this->getDataGenerator()->create_user();
+        $this->getDataGenerator()->role_assign($editingteacherrolerid, $this->user->id);
 
         // Add some content to the content bank.
         $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
-        $this->contents[$this->manager1->id] = $generator->generate_contentbank_data(null, 3, $this->manager1->id);
-        $this->contents[$this->user->id] = $generator->generate_contentbank_data(null, 1, $this->user->id);
+        $this->contents[$this->manager1->id] = $generator->generate_contentbank_data($contenttype, 3, $this->manager1->id);
+        $this->contents[$this->user->id] = $generator->generate_contentbank_data($contenttype, 1, $this->user->id);
 
-        $this->contenttype = new \contenttype_testable\contenttype($systemcontext);
+        $contenttypeclass = "\\$contenttype\\contenttype";
+        $this->contenttype = new $contenttypeclass($systemcontext);
     }
 
     /**
@@ -510,4 +591,70 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
         $this->assertFalse($contenttype->can_manage($contentbyteacher));
         $this->assertFalse($contenttype->can_manage($contentbyadmin));
     }
+
+    /**
+     * Test the behaviour of can_download().
+     *
+     * @covers ::can_download
+     */
+    public function test_can_download() {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->contenttype_setup_scenario_data('contenttype_h5p');
+
+        $managercontent = array_shift($this->contents[$this->manager1->id]);
+        $usercontent = array_shift($this->contents[$this->user->id]);
+
+        // Check the content has been created as expected.
+        $records = $DB->count_records('contentbank_content');
+        $this->assertEquals(4, $records);
+
+        // Check user can download content created by anybody.
+        $this->setUser($this->user);
+        $this->assertTrue($this->contenttype->can_download($usercontent));
+        $this->assertTrue($this->contenttype->can_download($managercontent));
+
+        // Check manager can download all the content too.
+        $this->setUser($this->manager1);
+        $this->assertTrue($this->contenttype->can_download($managercontent));
+        $this->assertTrue($this->contenttype->can_download($usercontent));
+
+        // Unassign capability to manager role and check she cannot download content anymore.
+        unassign_capability('moodle/contentbank:downloadcontent', $this->managerroleid);
+        $this->assertFalse($this->contenttype->can_download($managercontent));
+        $this->assertFalse($this->contenttype->can_download($usercontent));
+    }
+
+    /**
+     * Tests get_download_url result.
+     *
+     * @covers ::get_download_url
+     */
+    public function test_get_download_url() {
+        global $CFG;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+        $systemcontext = context_system::instance();
+
+        // Add some content to the content bank.
+        $filename = 'filltheblanks.h5p';
+        $filepath = $CFG->dirroot . '/h5p/tests/fixtures/' . $filename;
+        $generator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+        $contents = $generator->generate_contentbank_data('contenttype_testable', 1, 0, $systemcontext, true, $filepath);
+        $content = array_shift($contents);
+
+        // Check the URL is returned OK for a content with file.
+        $contenttype = new contenttype($systemcontext);
+        $url = $contenttype->get_download_url($content);
+        $this->assertNotEmpty($url);
+        $this->assertContains($filename, $url);
+
+        // Check the URL is empty when the content hasn't any file.
+        $record = new stdClass();
+        $content = $contenttype->create_content($record);
+        $url = $contenttype->get_download_url($content);
+        $this->assertEmpty($url);
+    }
 }
index 00cc40c..4410de4 100644 (file)
@@ -34,6 +34,17 @@ $context = context::instance_by_id($contextid, MUST_EXIST);
 
 require_capability('moodle/contentbank:upload', $context);
 
+$cb = new \core_contentbank\contentbank();
+
+$id = optional_param('id', null, PARAM_INT);
+if ($id) {
+    $content = $cb->get_content_from_id($id);
+    $contenttype = $content->get_content_type_instance();
+    if (!$contenttype->can_manage($content) || !$contenttype->can_upload()) {
+        print_error('nopermissions', 'error', $returnurl, get_string('replacecontent', 'contentbank'));
+    }
+}
+
 $title = get_string('contentbank');
 \core_contentbank\helper::get_page_ready($context, $title, true);
 if ($PAGE->course) {
@@ -55,8 +66,12 @@ if (has_capability('moodle/user:ignoreuserquota', $context)) {
     $maxareabytes = FILE_AREA_MAX_BYTES_UNLIMITED;
 }
 
-$cb = new \core_contentbank\contentbank();
-$accepted = $cb->get_supported_extensions_as_string($context);
+if ($id) {
+    $extensions = $contenttype->get_manageable_extensions();
+    $accepted = implode(',', $extensions);
+} else {
+    $accepted = $cb->get_supported_extensions_as_string($context);
+}
 
 $data = new stdClass();
 $options = array(
@@ -68,7 +83,7 @@ $options = array(
 );
 file_prepare_standard_filemanager($data, 'files', $options, $context, 'contentbank', 'public', 0);
 
-$mform = new contentbank_files_form(null, ['contextid' => $contextid, 'data' => $data, 'options' => $options]);
+$mform = new contentbank_files_form(null, ['contextid' => $contextid, 'data' => $data, 'options' => $options, 'id' => $id]);
 
 $error = '';
 
@@ -82,7 +97,11 @@ if ($mform->is_cancelled()) {
     $files = $fs->get_area_files($usercontext->id, 'user', 'draft', $formdata->file, 'itemid, filepath, filename', false);
     if (!empty($files)) {
         $file = reset($files);
-        $content = $cb->create_content_from_file($context, $USER->id, $file);
+        if ($id) {
+            $content = $contenttype->replace_content($file, $content);
+        } else {
+            $content = $cb->create_content_from_file($context, $USER->id, $file);
+        }
         $viewurl = new \moodle_url('/contentbank/view.php', ['id' => $content->get_id(), 'contextid' => $contextid]);
         redirect($viewurl);
     } else {
index 5fd66d1..4c84b1b 100644 (file)
@@ -83,6 +83,24 @@ if ($contenttype->can_manage($content)) {
         false,
         $attributes
     ));
+
+    if ($contenttype->can_upload()) {
+        $actionmenu->add_secondary_action(new action_menu_link(
+            new moodle_url('/contentbank/upload.php', ['contextid' => $context->id, 'id' => $content->get_id()]),
+            new pix_icon('i/upload', get_string('upload')),
+            get_string('replacecontent', 'contentbank'),
+            false
+        ));
+    }
+}
+if ($contenttype->can_download($content)) {
+    // Add the download content item to the menu.
+    $actionmenu->add_secondary_action(new action_menu_link(
+        new moodle_url($contenttype->get_download_url($content)),
+        new pix_icon('t/download', get_string('download')),
+        get_string('download'),
+        false
+    ));
 }
 if ($contenttype->can_delete($content)) {
     // Add the delete content item to the menu.
index 16e84d3..4af9f85 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /course/*,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 
 * The function make_categories_options() has now been deprecated. Please use \core_course_category::make_categories_list() instead.
 
index 682441e..73116b7 100644 (file)
@@ -24,8 +24,6 @@
 
 namespace customfield_select;
 
-use core_customfield\api;
-
 defined('MOODLE_INTERNAL') || die;
 
 /**
@@ -53,8 +51,7 @@ class data_controller extends \core_customfield\data_controller {
     public function get_default_value() {
         $defaultvalue = $this->get_field()->get_configdata_property('defaultvalue');
         if ('' . $defaultvalue !== '') {
-            $options = field_controller::get_options_array($this->get_field());
-            $key = array_search($defaultvalue, $options);
+            $key = array_search($defaultvalue, $this->get_field()->get_options());
             if ($key !== false) {
                 return $key;
             }
@@ -70,7 +67,7 @@ class data_controller extends \core_customfield\data_controller {
     public function instance_form_definition(\MoodleQuickForm $mform) {
         $field = $this->get_field();
         $config = $field->get('configdata');
-        $options = field_controller::get_options_array($field);
+        $options = $field->get_options();
         $formattedoptions = array();
         $context = $this->get_field()->get_handler()->get_configuration_context();
         foreach ($options as $key => $option) {
@@ -120,7 +117,7 @@ class data_controller extends \core_customfield\data_controller {
             return null;
         }
 
-        $options = field_controller::get_options_array($this->get_field());
+        $options = $this->get_field()->get_options();
         if (array_key_exists($value, $options)) {
             return format_string($options[$value], true,
                 ['context' => $this->get_field()->get_handler()->get_configuration_context()]);
index c47f05c..6f198f7 100644 (file)
@@ -60,10 +60,24 @@ class field_controller extends \core_customfield\field_controller {
      *
      * @param \core_customfield\field_controller $field
      * @return array
+     *
+     * @deprecated since Moodle 3.10 - MDL-68569 please use $field->get_options
      */
     public static function get_options_array(\core_customfield\field_controller $field) : array {
-        if ($field->get_configdata_property('options')) {
-            $options = preg_split("/\s*\n\s*/", trim($field->get_configdata_property('options')));
+        debugging('get_options_array() is deprecated, please use $field->get_options() instead', DEBUG_DEVELOPER);
+
+        return $field->get_options();
+    }
+
+    /**
+     * Return configured field options
+     *
+     * @return array
+     */
+    public function get_options(): array {
+        $optionconfig = $this->get_configdata_property('options');
+        if ($optionconfig) {
+            $options = preg_split("/\s*\n\s*/", trim($optionconfig));
         } else {
             $options = array();
         }
@@ -108,7 +122,7 @@ class field_controller extends \core_customfield\field_controller {
      * @return array
      */
     public function course_grouping_format_values($values): array {
-        $options = self::get_options_array($this);
+        $options = $this->get_options();
         $ret = [];
         foreach ($values as $value) {
             if (isset($options[$value])) {
@@ -127,6 +141,6 @@ class field_controller extends \core_customfield\field_controller {
      * @return int
      */
     public function parse_value(string $value) {
-        return (int) array_search($value, self::get_options_array($this));
+        return (int) array_search($value, $this->get_options());
     }
 }
\ No newline at end of file
index 7a8be2d..d3621a9 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in /grade/grading/form/* - Advanced grading methods
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 
 * Removed gradingform_provider.
 * Removed the following deprecated functions:
index f3b4ad7..999fc6f 100644 (file)
@@ -164,7 +164,7 @@ class grade extends tablelike implements selectable_items, filterable_items {
     public function original_headers() {
         return array(
             '', // For filter icon.
-            get_string('firstname') . ' (' . get_string('alternatename') . ') ' . get_string('lastname'),
+            get_string('fullnameuser', 'core'),
             get_string('range', 'grades'),
             get_string('grade', 'grades'),
             get_string('feedback', 'grades'),
@@ -198,8 +198,8 @@ class grade extends tablelike implements selectable_items, filterable_items {
             $lockicon = $OUTPUT->pix_icon('t/locked', 'grade is locked') . ' ';
         }
 
-        if (!empty($item->alternatename)) {
-            $fullname = $lockicon . $item->alternatename . ' (' . $item->firstname . ') ' . $item->lastname;
+        if (has_capability('moodle/site:viewfullnames', \context_course::instance($this->courseid))) {
+            $fullname = $lockicon . fullname($item, true);
         } else {
             $fullname = $lockicon . fullname($item);
         }
index fd5e026..5921fc2 100644 (file)
@@ -161,12 +161,12 @@ class finalgrade extends grade_attribute_format implements unique_value, be_disa
         }
 
         if ($errorstr) {
-            $user = $DB->get_record('user', array('id' => $userid), 'id, firstname, alternatename, lastname');
+            $user = get_complete_user_data('id', $userid);
             $gradestr = new stdClass;
-            if (!empty($user->alternatename)) {
-                $gradestr->username = $user->alternatename . ' (' . $user->firstname . ') ' . $user->lastname;
+            if (has_capability('moodle/site:viewfullnames', \context_course::instance($gradeitem->courseid))) {
+                $gradestr->username = fullname($user, true);
             } else {
-                $gradestr->username = $user->firstname . ' ' . $user->lastname;
+                $gradestr->username = fullname($user);
             }
             $gradestr->itemname = $this->grade->grade_item->get_name();
             $errorstr = get_string($errorstr, 'grades', $gradestr);
index fd1153a..faa5ba2 100644 (file)
@@ -42,33 +42,33 @@ Feature: We can bulk insert grades for students in a course
     And I am on "Course 1" course homepage
     And I navigate to "View > Grader report" in the course gradebook
     And I follow "Single view for Test assignment one"
-    Then the field "Grade for james (Student) 1" matches value "50.00"
-    And the field "Override for james (Student) 1" matches value "0"
+    Then the field "Grade for Student 1" matches value "50.00"
+    And the field "Override for Student 1" matches value "0"
     And I set the field "Perform bulk insert" to "1"
     And I set the field "Insert value" to "1.0"
     And I press "Save"
     And I press "Continue"
-    And the field "Grade for james (Student) 1" matches value "50.00"
-    And the field "Override for james (Student) 1" matches value "0"
-    And the field "Grade for holly (Student) 2" matches value "1.00"
-    And the field "Override for holly (Student) 2" matches value "1"
-    And the field "Grade for anna (Student) 3" matches value "1.00"
-    And the field "Override for anna (Student) 3" matches value "1"
-    And the field "Grade for zac (Student) 4" matches value "1.00"
-    And the field "Override for zac (Student) 4" matches value "1"
+    And the field "Grade for Student 1" matches value "50.00"
+    And the field "Override for Student 1" matches value "0"
+    And the field "Grade for Student 2" matches value "1.00"
+    And the field "Override for Student 2" matches value "1"
+    And the field "Grade for Student 3" matches value "1.00"
+    And the field "Override for Student 3" matches value "1"
+    And the field "Grade for Student 4" matches value "1.00"
+    And the field "Override for Student 4" matches value "1"
     And I set the field "For" to "All grades"
     And I set the field "Perform bulk insert" to "1"
     And I set the field "Insert value" to "2.0"
     And I press "Save"
     And I press "Continue"
-    And the field "Grade for james (Student) 1" matches value "2.00"
-    And the field "Override for james (Student) 1" matches value "1"
-    And the field "Grade for holly (Student) 2" matches value "2.00"
-    And the field "Override for holly (Student) 2" matches value "1"
-    And the field "Grade for anna (Student) 3" matches value "2.00"
-    And the field "Override for anna (Student) 3" matches value "1"
-    And the field "Grade for zac (Student) 4" matches value "2.00"
-    And the field "Override for zac (Student) 4" matches value "1"
+    And the field "Grade for Student 1" matches value "2.00"
+    And the field "Override for Student 1" matches value "1"
+    And the field "Grade for Student 2" matches value "2.00"
+    And the field "Override for Student 2" matches value "1"
+    And the field "Grade for Student 3" matches value "2.00"
+    And the field "Override for Student 3" matches value "1"
+    And the field "Grade for Student 4" matches value "2.00"
+    And the field "Override for Student 4" matches value "1"
 
   Scenario: I can bulk insert grades and check their override flags for user view.
     Given I log in as "teacher1"
@@ -110,8 +110,8 @@ Feature: We can bulk insert grades for students in a course
     And I set the field "Perform bulk insert" to "1"
     When I set the field "Insert value" to "-1"
     And I press "Save"
-    Then I should see "The grade entered for Test assignment one for james (Student) 1 is less than the minimum allowed"
-    And I should see "The grade entered for Test assignment one for holly (Student) 2 is less than the minimum allowed"
-    And I should see "The grade entered for Test assignment one for anna (Student) 3 is less than the minimum allowed"
-    And I should see "The grade entered for Test assignment one for zac (Student) 4 is less than the minimum allowed"
+    Then I should see "The grade entered for Test assignment one for Student 1 is less than the minimum allowed"
+    And I should see "The grade entered for Test assignment one for Student 2 is less than the minimum allowed"
+    And I should see "The grade entered for Test assignment one for Student 3 is less than the minimum allowed"
+    And I should see "The grade entered for Test assignment one for Student 4 is less than the minimum allowed"
     And I should see "Grades were set for 0 items"
index e18eb5c..b04c8a7 100644 (file)
@@ -9,13 +9,13 @@ Feature: We can use Single view
       | fullname | shortname | category |
       | Course 1 | C1 | 0 |
     And the following "users" exist:
-      | username | firstname | lastname | email | idnumber | alternatename |
-      | teacher1 | Teacher | 1 | teacher1@example.com | t1 | fred |
-      | teacher2 | No edit | 1 | teacher2@example.com | t2 | nick |
-      | student1 | Student | 1 | student1@example.com | s1 | james |
-      | student2 | Student | 2 | student1@example.com | s2 | holly |
-      | student3 | Student | 3 | student1@example.com | s3 | anna |
-      | student4 | Student | 4 | student1@example.com | s4 | zac |
+      | username | firstname | lastname    | email                | idnumber | middlename | alternatename | firstnamephonetic | lastnamephonetic |
+      | teacher1 | Teacher   | 1           | teacher1@example.com | t1       |            | fred          |                   |                  |
+      | teacher2 | No edit   | 1           | teacher2@example.com | t2       |            | nick          |                   |                  |
+      | student1 | Grainne   | Beauchamp   | student1@example.com | s1       | Ann        | Jill          | Gronya            | Beecham          |
+      | student2 | Niamh     | Cholmondely | student2@example.com | s2       | Jane       | Nina          | Nee               | Chumlee          |
+      | student3 | Siobhan   | Desforges   | student3@example.com | s3       | Sarah      | Sev           | Shevon            | De-forjay        |
+      | student4 | Student   | 4           | student4@example.com | s4       |            | zac           |                   |                  |
     And the following "scales" exist:
       | name | scale |
       | Test Scale | Disappointing, Good, Very good, Excellent |
@@ -50,6 +50,9 @@ Feature: We can use Single view
       | capability                  | permission | role     | contextlevel  | reference |
       | moodle/grade:edit           | Allow      | teacher  | Course        | C1        |
       | gradereport/singleview:view | Allow      | teacher  | Course        | C1        |
+    And the following config values are set as admin:
+      | fullnamedisplay | firstnamephonetic,lastnamephonetic |
+      | alternativefullnameformat | middlename, alternatename, firstname, lastname |
     And I log in as "teacher1"
     And I am on "Course 1" course homepage
     Given I navigate to "View > Grader report" in the course gradebook
@@ -57,7 +60,7 @@ Feature: We can use Single view
   @javascript
   Scenario: I can update grades, add feedback and exclude grades.
     Given I navigate to "View > Single view" in the course gradebook
-    And I select "Student 4" from the "Select user..." singleselect
+    And I select "Student" from the "Select user..." singleselect
     And I set the field "Override for Test assignment one" to "1"
     When I set the following fields to these values:
         | Grade for Test assignment one | 10.00 |
@@ -76,29 +79,29 @@ Feature: We can use Single view
     And the field "Grade for Test grade item" matches value "45.00"
     And the field "Grade for Course total" matches value "55.00"
     And I click on "Show grades for Test assignment three" "link"
-    And I click on "Override for james (Student) 1" "checkbox"
+    And I click on "Override for Ann, Jill, Grainne, Beauchamp" "checkbox"
     And I set the following fields to these values:
-        | Grade for james (Student) 1 | 12.05 |
-        | Feedback for james (Student) 1 | test data2 |
-    And I set the field "Exclude for holly (Student) 2" to "1"
+        | Grade for Ann, Jill, Grainne, Beauchamp | 12.05 |
+        | Feedback for Ann, Jill, Grainne, Beauchamp | test data2 |
+    And I set the field "Exclude for Jane, Nina, Niamh, Cholmondely" to "1"
     And I press "Save"
     Then I should see "Grades were set for 2 items"
     And I press "Continue"
-    And the field "Grade for james (Student) 1" matches value "12.05"
-    And the field "Exclude for holly (Student) 2" matches value "1"
+    And the field "Grade for Ann, Jill, Grainne, Beauchamp" matches value "12.05"
+    And the field "Exclude for Jane, Nina, Niamh, Cholmondely" matches value "1"
     And I select "new grade item 1" from the "Select grade item..." singleselect
-    And I set the field "Grade for james (Student) 1" to "Very good"
+    And I set the field "Grade for Ann, Jill, Grainne, Beauchamp" to "Very good"
     And I press "Save"
     Then I should see "Grades were set for 1 items"
     And I press "Continue"
     And the following should exist in the "generaltable" table:
         | First name (Alternate name) Surname | Grade |
-        | james (Student) 1 | Very good |
+        | Ann, Jill, Grainne, Beauchamp | Very good |
     And I log out
     And I log in as "teacher2"
     And I am on "Course 1" course homepage
     Given I navigate to "View > Single view" in the course gradebook
-    And I select "Student 4" from the "Select user..." singleselect
+    And I select "Student" from the "Select user..." singleselect
     And the "Exclude for Test assignment one" "checkbox" should be disabled
     And the "Override for Test assignment one" "checkbox" should be enabled
 
@@ -106,12 +109,12 @@ Feature: We can use Single view
     Given I follow "Single view for Test assignment one"
     Then I should see "Test assignment one"
     Then I navigate to "View > Grader report" in the course gradebook
-    And I follow "Single view for Student 1"
-    Then I should see "Student 1"
+    And I follow "Single view for Ann, Jill, Grainne, Beauchamp"
+    Then I should see "Gronya,Beecham"
 
   Scenario: I can bulk update grades.
-    Given I follow "Single view for Student 1"
-    Then I should see "Student 1"
+    Given I follow "Single view for Ann, Jill, Grainne, Beauchamp"
+    Then I should see "Gronya,Beecham"
     When I set the field "For" to "All grades"
     And I set the field "Insert value" to "1.0"
     And I set the field "Perform bulk insert" to "1"
@@ -119,12 +122,12 @@ Feature: We can use Single view
     Then I should see "Grades were set for 6 items"
 
   Scenario: Navigation works in the Single view.
-    Given I follow "Single view for Student 1"
-    Then I should see "Student 1"
-    And I follow "Student 2"
-    Then I should see "Student 2"
-    And I follow "Student 1"
-    Then I should see "Student 1"
+    Given I follow "Single view for Ann, Jill, Grainne, Beauchamp"
+    Then I should see "Gronya,Beecham"
+    And I follow "Nee,Chumlee"
+    Then I should see "Nee,Chumlee"
+    And I follow "Gronya,Beecham"
+    Then I should see "Gronya,Beecham"
     And I click on "Show grades for Test assignment four" "link"
     Then I should see "Test assignment four"
     And I follow "Test assignment three"
@@ -134,7 +137,7 @@ Feature: We can use Single view
 
   Scenario: Activities are clickable only when
     it has a valid activity page.
-    Given I follow "Single view for Student 1"
+    Given I follow "Single view for Ann, Jill, Grainne, Beauchamp"
     And "new grade item 1" "link" should not exist in the "//tbody//tr[position()=1]//td[position()=2]" "xpath_element"
     Then "Category total" "link" should not exist in the "//tbody//tr[position()=2]//td[position()=2]" "xpath_element"
     And "Course total" "link" should not exist in the "//tbody//tr[position()=last()]//td[position()=2]" "xpath_element"
index 6cd3484..cab3476 100644 (file)
@@ -164,6 +164,7 @@ class api {
             unset($library->major_version);
             $library->minorVersion = (int) $library->minorversion;
             unset($library->minorversion);
+            $library->metadataSettings = json_decode($library->metadatasettings);
 
             // If we already add this library means that it is an old version,as the previous query was sorted by version.
             if (isset($added[$library->name])) {
index c4b575c..8d7f9b4 100644 (file)
@@ -228,7 +228,7 @@ class editor_framework implements H5peditorStorage {
         if ($libraries !== null) {
             // Get details for the specified libraries.
             $librariesin = [];
-            $fields = 'title, runnable';
+            $fields = 'title, runnable, metadatasettings';
 
             foreach ($libraries as $library) {
                 $params = [
@@ -242,11 +242,12 @@ class editor_framework implements H5peditorStorage {
                 if ($details) {
                     $library->title = $details->title;
                     $library->runnable = $details->runnable;
+                    $library->metadataSettings = json_decode($details->metadatasettings);
                     $librariesin[] = $library;
                 }
             }
         } else {
-            $fields = 'id, machinename as name, title, majorversion, minorversion';
+            $fields = 'id, machinename as name, title, majorversion, minorversion, metadatasettings';
             $librariesin = api::get_contenttype_libraries($fields);
         }
 
index 2e4a2ca..4b05422 100644 (file)
@@ -685,6 +685,9 @@ class framework implements \H5PFrameworkInterface {
      *                           - dropLibraryCss(optional): list of associative arrays containing:
      *                             - machineName: machine name for the librarys that are to drop their css
      *                           - semantics(optional): Json describing the content structure for the library
+     *                           - metadataSettings(optional): object containing:
+     *                             - disable: 1 if metadata is disabled completely
+     *                             - disableExtraTitleField: 1 if the title field is hidden in the form
      * @param bool $new Whether it is a new or existing library.
      */
     public function saveLibraryData(&$librarydata, $new = true) {
@@ -722,6 +725,7 @@ class framework implements \H5PFrameworkInterface {
             'addto' => isset($librarydata['addTo']) ? json_encode($librarydata['addTo']) : null,
             'coremajor' => isset($librarydata['coreApi']['majorVersion']) ? $librarydata['coreApi']['majorVersion'] : null,
             'coreminor' => isset($librarydata['coreApi']['majorVersion']) ? $librarydata['coreApi']['minorVersion'] : null,
+            'metadatasettings' => isset($librarydata['metadataSettings']) ? $librarydata['metadataSettings'] : null,
         );
 
         if ($new) {
index 478ab57..dc266f5 100644 (file)
@@ -246,6 +246,7 @@ class generator_testcase extends \advanced_testcase {
             'addto' => '/regex11/',
             'coremajor' => null,
             'coreminor' => null,
+            'metadatasettings' => null,
         ];
 
         $this->assertEquals($expected, $data);
index 59eb0cb..ee5271c 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 * Added a new cache for h5p_library_files (MDL-69207)
 
 === 3.9 ===
index 796e496..6c8b91c 100644 (file)
@@ -41,5 +41,5 @@ $string['cliunknowoption'] = 'Aukera ezezagunak:
 Mesedez, erabili --help aukera.';
 $string['cliyesnoprompt'] = 'idatzi b (bai esateko) edo e (ez esateko)';
 $string['environmentrequireinstall'] = 'derrigorrezkoa da instalatuta eta gaituta izatea';
-$string['environmentrequireversion'] = '{$a->needed} bertsioa beharrezkoa da eta zu {$a->current} ari zara egikaritzen';
+$string['environmentrequireversion'] = '{$a->needed} bertsioa beharrezkoa da eta zu {$a->current} ari zara exekutatzen';
 $string['upgradekeyset'] = 'Eguneraketa-kodea (utzi hutsik kodea erabili nahi ez baduzu)';
diff --git a/install/lang/kaa/langconfig.php b/install/lang/kaa/langconfig.php
new file mode 100644 (file)
index 0000000..4740f55
--- /dev/null
@@ -0,0 +1,33 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['thislanguage'] = 'Qaraqalpaq tili';
diff --git a/install/lang/prs/moodle.php b/install/lang/prs/moodle.php
new file mode 100644 (file)
index 0000000..f7b2fbd
--- /dev/null
@@ -0,0 +1,37 @@
+<?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/>.
+
+/**
+ * Automatically generated strings for Moodle installer
+ *
+ * Do not edit this file manually! It contains just a subset of strings
+ * needed during the very first steps of installation. This file was
+ * generated automatically by export-installer.php (which is part of AMOS
+ * {@link http://docs.moodle.org/dev/Languages/AMOS}) using the
+ * list of strings defined in /install/stringnames.txt.
+ *
+ * @package   installer
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+$string['language'] = 'زبان';
+$string['moodlelogo'] = 'لوگوی مودل (Moodle)';
+$string['next'] = 'بعدی';
+$string['previous'] = 'قبلی';
+$string['reload'] = 'بارگیری مجدد';
index 5cb42af..f93e14c 100644 (file)
@@ -228,7 +228,7 @@ $string['configeditorfontlist'] = 'Select the fonts that should appear in the ed
 $string['configemailchangeconfirmation'] = 'Require an email confirmation step when users change their email address in their profile.';
 $string['configemailfromvia'] = 'Add via information in the "From" section of outgoing email. This informs the recipient from where this email came from and also helps combat recipients accidentally replying to no-reply email addresses.';
 $string['configemailsubjectprefix'] = 'Text to be prefixed to the subject line of all outgoing mail.';
-$string['configemailheaders'] = 'Raw email headers to be added verbatum to all outgoing email.';
+$string['configemailheaders'] = 'Raw email headers to be added verbatim to all outgoing email.';
 $string['configenablecalendarexport'] = 'Enable exporting or subscribing to calendars.';
 $string['configenablecomments'] = 'Enable comments';
 $string['configenablecourserequests'] = 'If enabled, users with the capability to request new courses (moodle/course:request) will have the option to request a course. This capability is not allowed for any of the default roles. It may be applied in the system or category context.';
@@ -424,6 +424,8 @@ $string['courseswithsummarieslimit'] = 'Courses with summaries limit';
 $string['creatornewroleid'] = 'Creators\' role in new courses';
 $string['creatornewroleid_help'] = 'If the user does not already have the permission to manage the new course, the user is automatically enrolled using this role.';
 $string['cron'] = 'Cron';
+$string['cron_enabled'] = 'Enable cron';
+$string['cron_enabled_desc'] = 'If disabled prevents the system from starting new background tasks. This option is intended for temporary use only, e.g. before a restart. Leaving it off for a long time will prevent important functionality from working.';
 $string['cron_help'] = 'The cron.php script runs a number of tasks at different scheduled intervals, such as sending forum post notification emails. The script should be run regularly - ideally every minute.';
 $string['cron_link'] = 'admin/cron';
 $string['cronclionly'] = 'Cron execution via command line only';
@@ -467,7 +469,7 @@ $string['debugsqltrace'] = 'Show origin of SQL calls';
 $string['debugsqltrace1'] = 'Show only a single calling line';
 $string['debugsqltrace2'] = 'Show 2 lines of stack trace';
 $string['debugsqltrace100'] = 'Show full stack trace';
-$string['debugsqltrace_desc'] = 'If enabled adds either partial or full PHP stacktrace into the SQL as a comment';
+$string['debugsqltrace_desc'] = 'If enabled, a partial or full PHP stack trace is added into the SQL as a comment.';
 $string['debugstringids'] = 'Show origin of languages strings';
 $string['debugstringids_desc'] = 'If enabled, language string components and identifiers are displayed when ?strings=1 or &strings=1 is appended to the page URL.';
 $string['debugvalidators'] = 'Show validator links';
@@ -1123,7 +1125,7 @@ $string['searchincludeallcourses'] = 'Include all visible courses';
 $string['searchincludeallcourses_desc'] = 'If enabled, search results will include course information (name and summary) of courses which are visible to the user, even if they don\'t have access to the course content.';
 $string['searchalldeleted'] = 'All indexed contents have been deleted';
 $string['searchbannerenable'] = 'Display search information';
-$string['searchbannerenable_desc'] = 'If enabled, the below text will display at the top of the search screen for all users. This can be used to keep users informed while maintenance is being carried out on the search system.';
+$string['searchbannerenable_desc'] = 'If enabled, the text below will be displayed at the top of the search screen for all users. This can be used to inform users when search engine maintenance is being carried out.';
 $string['searchbanner'] = 'Search information';
 $string['searchareaenabled'] = 'Search area enabled';
 $string['searchareadisabled'] = 'Search area disabled';
index 634b0c7..907bf31 100644 (file)
 
 $string['actantivirushdr'] = 'Available antivirus plugins';
 $string['antiviruses'] = 'Antivirus plugins';
+$string['antiviruscommonsettings'] = 'Common antivirus settings';
 $string['antivirussettings'] = 'Manage antivirus plugins';
 $string['configantivirusplugins'] = 'Please choose the antivirus plugins you wish to use and arrange them in order of being applied.';
 $string['datastream'] = 'Data';
+$string['datainfecteddesc'] = 'Infected data was detected.';
+$string['datainfectedname'] = 'Data infected';
+$string['emailadditionalinfo'] = 'Additional details returned from the virus engine: ';
+$string['emailauthor'] = 'Uploaded by: ';
+$string['emailcontenthash'] = 'Content hash: ';
+$string['emailcontenttype'] = 'Content type: ';
+$string['emaildate'] = 'Date uploaded: ';
+$string['emailfilename'] = 'Filename: ';
+$string['emailfilesize'] = 'File size: ';
+$string['emailgeoinfo'] = 'Geolocation: ';
+$string['emailinfectedfiledetected'] = 'Infected file detected';
+$string['emailipaddress'] = 'IP Address: ';
+$string['emailreferer'] = 'Referer: ';
+$string['emailreport'] = 'Report: ';
+$string['emailscanner'] = 'Scanner: ';
+$string['emailscannererrordetected'] = 'A scanner error occured';
 $string['emailsubject'] = '{$a} :: Antivirus notification';
+$string['enablequarantine'] = 'Enable quarantine';
+$string['enablequarantine_help'] = 'When quarantine is enabled, any files which are detected as viruses will be kept in a quarantine folder for later inspection ([dataroot]/{$a}).
+The upload into Moodle will still fail.
+If you have any file system level virus scanning in place, the quarantine folder should be excluded from the antivirus check to avoid detecting the quarantined files.';
+$string['fileinfecteddesc'] = 'An infected file was detected.';
+$string['fileinfectedname'] = 'File infected';
+$string['notifyemail'] = 'Antivirus alert email';
+$string['notifyemail_help'] = 'If set, then only the specified email will be notified when a virus is detected.
+If blank, then all site admins will be notified by email when a virus is detected.';
 $string['privacy:metadata'] = 'The Antivirus system does not store any personal data.';
+$string['quarantinedisabled'] = 'Quarantine disabled, file not stored.';
+$string['quarantinedfiles'] = 'Antivirus quarantined files';
+$string['quarantinetime'] = 'Maximum quarantine time';
+$string['quarantinetime_desc'] = 'Quarantined files older than specified period will be removed.';
+$string['taskcleanup'] = 'Clean up quarantined files.';
+$string['unknown'] = 'Unknown';
 $string['virusfound'] = '{$a->item} has been scanned by a virus checker and found to be infected!';
index 4ff7fce..4bf99b4 100644 (file)
@@ -200,6 +200,7 @@ $string['errorinvalidformat'] = 'Unknown backup format';
 $string['errorinvalidformatinfo'] = 'The selected file is not a valid Moodle backup file and can\'t be restored.';
 $string['errorrestorefrontpagebackup'] = 'You can only restore front page backups on the front page';
 $string['executionsuccess'] = 'The backup file was successfully created.';
+$string['extractingbackupfileto'] = 'Extracting backup file to: {$a}';
 $string['failed'] = 'Backup failed';
 $string['filename'] = 'Filename';
 $string['filealiasesrestorefailures'] = 'Aliases restore failures';
@@ -302,6 +303,7 @@ $string['questionegorycannotberestored'] = 'The questions "{$a->name}" cannot be
 $string['restoreactivity'] = 'Restore activity';
 $string['restorecourse'] = 'Restore course';
 $string['restorecoursesettings'] = 'Course settings';
+$string['restoredcourseid'] = 'Restored course id: {$a}';
 $string['restoreexecutionsuccess'] = 'The course was restored successfully, clicking the continue button below will take you to view the course you restored.';
 $string['restorefileweremissing'] = 'Some files could not be restored because they were missing in the backup.';
 $string['restorenewcoursefullname'] = 'New course name';
index f0343ee..62e4b4f 100644 (file)
@@ -304,7 +304,7 @@ $string['error:invalidexpiredate'] = 'Expiry date has to be in the future.';
 $string['error:invalidexpireperiod'] = 'Expiry period cannot be negative or equal 0.';
 $string['error:invalidparambadge'] = 'Badge does not exist. ';
 $string['error:noactivities'] = 'There are no activities with completion criteria enabled in this course.';
-$string['error:nobadges'] = 'There are no course or site badges with access enabled to be added as criteria.';
+$string['error:nobadges'] = 'There are currently no badges with access enabled to be added as criteria. A site badge can only have other site badges as criteria. A course badge can have other course badges or site badges as criteria.';
 $string['error:invalidparamcohort'] = 'Cohort does not exist. ';
 $string['error:noactivities'] = 'There are no activities with completion criteria enabled in this course.';
 $string['error:nocohorts'] = 'No cohorts';
index 6411ece..6535c93 100644 (file)
@@ -36,11 +36,12 @@ $string['contenttypenoedit'] = 'You can not edit this content';
 $string['emptynamenotallowed'] = 'Empty name is not allowed';
 $string['eventcontentcreated'] = 'Content created';
 $string['eventcontentdeleted'] = 'Content deleted';
+$string['eventcontentreplaced'] = 'Content replaced with file';
 $string['eventcontentupdated'] = 'Content updated';
 $string['eventcontentuploaded'] = 'Content uploaded';
 $string['eventcontentviewed'] = 'Content viewed';
 $string['errordeletingcontentfromcategory'] = 'Error deleting content from category {$a}.';
-$string['errornofile'] = 'A compatible file is needed to create a content';
+$string['errornofile'] = 'A compatible file is needed to create content.';
 $string['deletecontent'] = 'Delete content';
 $string['deletecontentconfirm'] = 'Are you sure you want to delete the content <em>\'{$a->name}\'</em> and all associated files? This action cannot be undone.';
 $string['displaydetails'] = 'Display content bank with file details';
@@ -64,6 +65,7 @@ $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying c
 $string['privacy:request:preference:set'] = 'The value of the setting \'{$a->name}\' was \'{$a->value}\'';
 $string['rename'] = 'Rename';
 $string['renamecontent'] = 'Rename content';
+$string['replacecontent'] = 'Replace with file';
 $string['searchcontentbankbyname'] = 'Search for content by name';
 $string['size'] = 'Size';
 $string['timecreated'] = 'Time created';
index 24d2c03..9d7065e 100644 (file)
@@ -1240,6 +1240,7 @@ $string['messageprovider:gradenotifications'] = 'Grade notifications';
 $string['messageprovider:messagecontactrequests'] = 'Message contact requests notification';
 $string['messageprovider:notices'] = 'Notices about minor problems';
 $string['messageprovider:notices_help'] = 'These are notices that an administrator might be interested in seeing.';
+$string['messageprovider:infected'] = 'Antivirus failure notifications.';
 $string['messageprovider:insights'] = 'Insights generated by prediction models';
 $string['messageprovider:instantmessage'] = 'Personal messages between users';
 $string['messageprovider:instantmessage_help'] = 'This section configures what happens to messages that are sent to you directly from other users on this site.';
index 06ef6bb..625c0c0 100644 (file)
@@ -153,6 +153,7 @@ $string['confirmunassignno'] = 'Cancel';
 $string['contentbank:access'] = 'Access the content bank';
 $string['contentbank:deleteanycontent'] = 'Delete any content from the content bank';
 $string['contentbank:deleteowncontent'] = 'Delete content from own content bank';
+$string['contentbank:downloadcontent'] = 'Download a content from the content bank';
 $string['contentbank:manageanycontent'] = 'Manage any content from the content bank';
 $string['contentbank:manageowncontent'] = 'Manage content from own content bank';
 $string['contentbank:upload'] = 'Upload new content to the content bank';
index 047cb49..7242743 100644 (file)
@@ -228,6 +228,9 @@ class scanner extends \core\antivirus\scanner {
             $notice .= "\n\n". implode("\n", $output);
             $this->set_scanning_notice($notice);
             return self::SCAN_RESULT_ERROR;
+        } else {
+            $notice = "\n\n". implode("\n", $output);
+            $this->set_scanning_notice($notice);
         }
 
         return (int)$return;
@@ -384,6 +387,8 @@ class scanner extends \core\antivirus\scanner {
             $parts = explode(' ', $message);
             $status = array_pop($parts);
             if ($status === 'FOUND') {
+                $notice = "\n\n" . $output;
+                $this->set_scanning_notice($notice);
                 return self::SCAN_RESULT_FOUND;
             } else {
                 $notice = get_string('clamfailed', 'antivirus_clamav', $this->get_clam_error_code(2));
index 0f78513..d17c42e 100644 (file)
@@ -67,15 +67,50 @@ class manager {
      * @return void
      */
     public static function scan_file($file, $filename, $deleteinfected) {
+        global $USER;
         $antiviruses = self::get_enabled();
         foreach ($antiviruses as $antivirus) {
-            $result = $antivirus->scan_file($file, $filename);
+            // Attempt to scan, catching internal exceptions.
+            try {
+                $result = $antivirus->scan_file($file, $filename);
+            } catch (\core\antivirus\scanner_exception $e) {
+                // If there was a scanner exception (such as ClamAV denying upload), send messages and rethrow.
+                $notice = $antivirus->get_scanning_notice();
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+                throw $e;
+            }
+
+            $notice = $antivirus->get_scanning_notice();
             if ($result === $antivirus::SCAN_RESULT_FOUND) {
-                // Infection found.
+                // Infection found, send notification.
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                // Move to quarantine folder.
+                $zipfile = \core\antivirus\quarantine::quarantine_file($file, $filename, $incidentdetails, $notice);
+                // If file not stored due to disabled quarantine, store a message.
+                if (empty($zipfile)) {
+                    $zipfile = get_string('quarantinedisabled', 'antivirus');
+                }
+
+                // Log file infected event.
+                $params = [
+                    'context' => \context_system::instance(),
+                    'relateduserid' => $USER->id,
+                    'other' => ['filename' => $filename, 'zipfile' => $zipfile, 'incidentdetails' => $incidentdetails],
+                ];
+                $event = \core\event\virus_infected_file_detected::create($params);
+                $event->trigger();
+
                 if ($deleteinfected) {
                     unlink($file);
                 }
                 throw new \core\antivirus\scanner_exception('virusfound', '', array('item' => $filename));
+            } else if ($result === $antivirus::SCAN_RESULT_ERROR) {
+                // Here we need to generate a different incident based on an error.
+                $incidentdetails = $antivirus->get_incident_details($file, $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
             }
         }
     }
@@ -83,16 +118,56 @@ class manager {
     /**
      * Scan data steam using all enabled antiviruses, throws exception in case of infected data.
      *
-     * @param string $data The varaible containing the data to scan.
+     * @param string $data The variable containing the data to scan.
      * @throws \core\antivirus\scanner_exception If data is infected.
      * @return void
      */
     public static function scan_data($data) {
+        global $USER;
         $antiviruses = self::get_enabled();
         foreach ($antiviruses as $antivirus) {
-            $result = $antivirus->scan_data($data);
+            // Attempt to scan, catching internal exceptions.
+            try {
+                $result = $antivirus->scan_data($data);
+            } catch (\core\antivirus\scanner_exception $e) {
+                // If there was a scanner exception (such as ClamAV denying upload), send messages and rethrow.
+                $notice = $antivirus->get_scanning_notice();
+                $filename = get_string('datastream', 'antivirus');
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                throw $e;
+            }
+
+            $filename = get_string('datastream', 'antivirus');
+            $notice = $antivirus->get_scanning_notice();
+
             if ($result === $antivirus::SCAN_RESULT_FOUND) {
+                // Infection found, send notification.
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
+
+                // Copy data to quarantine folder.
+                $zipfile = \core\antivirus\quarantine::quarantine_data($data, $filename, $incidentdetails, $notice);
+                // If file not stored due to disabled quarantine, store a message.
+                if (empty($zipfile)) {
+                    $zipfile = get_string('quarantinedisabled', 'antivirus');
+                }
+
+                // Log file infected event.
+                $params = [
+                    'context' => \context_system::instance(),
+                    'relateduserid' => $USER->id,
+                    'other' => ['filename' => $filename, 'zipfile' => $zipfile, 'incidentdetails' => $incidentdetails],
+                ];
+                $event = \core\event\virus_infected_data_detected::create($params);
+                $event->trigger();
+
                 throw new \core\antivirus\scanner_exception('virusfound', '', array('item' => get_string('datastream', 'antivirus')));
+            } else if ($result === $antivirus::SCAN_RESULT_ERROR) {
+                // Here we need to generate a different incident based on an error.
+                $incidentdetails = $antivirus->get_incident_details('', $filename, $notice, false);
+                self::send_antivirus_messages($antivirus, $incidentdetails);
             }
         }
     }
@@ -125,4 +200,53 @@ class manager {
         }
         return $antiviruses;
     }
+
+    /**
+     * This function puts all relevant information into the messages required, and sends them.
+     *
+     * @param \core\antivirus\scanner $antivirus the scanner engine.
+     * @param string $incidentdetails details of the incident.
+     * @return void
+     */
+    public static function send_antivirus_messages(\core\antivirus\scanner $antivirus, string $incidentdetails) {
+        $messages = $antivirus->get_messages();
+
+        // If there is no messages, and a virus is found, we should generate one, then send it.
+        if (empty($messages)) {
+            $antivirus->message_admins($antivirus->get_scanning_notice(), FORMAT_MOODLE, 'infected');
+            $messages = $antivirus->get_messages();
+        }
+
+        foreach ($messages as $message) {
+
+            // Check if the information is already in the current scanning notice.
+            if (!empty($antivirus->get_scanning_notice()) &&
+                strpos($antivirus->get_scanning_notice(), $message->fullmessage) === false) {
+                // This is some extra information. We should append this to the end of the incident details.
+                $incidentdetails .= \html_writer::tag('pre', $message->fullmessage);
+            }
+
+            // Now update the message to the detailed version, and format.
+            $message->name = 'infected';
+            $message->fullmessagehtml = $incidentdetails;
+            $message->fullmessageformat = FORMAT_MOODLE;
+            $message->fullmessage = format_text_email($incidentdetails, $message->fullmessageformat);
+
+            // Now we must check if message is going to a real account.
+            // It may be an email that needs to be sent to non-user address.
+            if ($message->userto->id === -1) {
+                // If this doesnt exist, send a regular email.
+                email_to_user(
+                    $message->userto,
+                    get_admin(),
+                    $message->subject,
+                    $message->fullmessage,
+                    $message->fullmessagehtml
+                );
+            } else {
+                // And now we can send.
+                message_send($message);
+            }
+        }
+    }
 }
diff --git a/lib/classes/antivirus/quarantine.php b/lib/classes/antivirus/quarantine.php
new file mode 100644 (file)
index 0000000..e3136ff
--- /dev/null
@@ -0,0 +1,326 @@
+<?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/>.
+
+/**
+ * Quarantine file
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\antivirus;
+
+defined('MOODLE_INTERNAL') || die();
+require_once($CFG->libdir.'/filelib.php');
+
+/**
+ * Quarantine file
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class quarantine {
+
+    /** Default quarantine folder */
+    const DEFAULT_QUARANTINE_FOLDER = 'antivirus_quarantine';
+
+    /** Zip infected file  */
+    const FILE_ZIP_INFECTED = '_infected_file.zip';
+
+    /** Zip all infected file */
+    const FILE_ZIP_ALL_INFECTED = '_all_infected_files.zip';
+
+    /** Incident details file */
+    const FILE_HTML_DETAILS = '_details.html';
+
+    /** Incident details file */
+    const DEFAULT_QUARANTINE_TIME = DAYSECS * 28;
+
+    /** Date format in filename */
+    const FILE_NAME_DATE_FORMAT = '%Y%m%d%H%M%S';
+
+    /**
+     * Move the infected file to the quarantine folder.
+     *
+     * @param string $file infected file.
+     * @param string $filename infected file name.
+     * @param string $incidentdetails incident details.
+     * @param string $notice notice details.
+     * @return string|null the name of the newly created quarantined file.
+     * @throws \dml_exception
+     */
+    public static function quarantine_file(string $file, string $filename, string $incidentdetails, string $notice) : ?string {
+        if (!self::is_quarantine_enabled()) {
+            return null;
+        }
+        // Generate file names.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT) . "_" . rand();
+        $zipfilepath = self::get_quarantine_folder() . $date . self::FILE_ZIP_INFECTED;
+        $detailsfilename = $date . self::FILE_HTML_DETAILS;
+
+        // Create Zip file.
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            $ziparchive->add_file_from_string($detailsfilename, format_text($incidentdetails, FORMAT_MOODLE));
+            $ziparchive->add_file_from_pathname($filename, $file);
+            $ziparchive->close();
+        }
+        $zipfile = basename($zipfilepath);
+        self::create_infected_file_record($filename, $zipfile, $notice);
+        return $zipfile;
+    }
+
+    /**
+     * Move the infected file to the quarantine folder.
+     *
+     * @param string $data data which is infected.
+     * @param string $filename infected file name.
+     * @param string $incidentdetails incident details.
+     * @param string $notice notice details.
+     * @return string|null the name of the newly created quarantined file.
+     * @throws \dml_exception
+     */
+    public static function quarantine_data(string $data, string $filename, string $incidentdetails, string $notice) : ?string {
+        if (!self::is_quarantine_enabled()) {
+            return null;
+        }
+        // Generate file names.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT) . "_" . rand();
+        $zipfilepath = self::get_quarantine_folder() . $date . self::FILE_ZIP_INFECTED;
+        $detailsfilename = $date . self::FILE_HTML_DETAILS;
+
+        // Create Zip file.
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            $ziparchive->add_file_from_string($detailsfilename, format_text($incidentdetails, FORMAT_MOODLE));
+            $ziparchive->add_file_from_string($filename, $data);
+            $ziparchive->close();
+        }
+        $zipfile = basename($zipfilepath);
+        self::create_infected_file_record($filename, $zipfile, $notice);
+        return $zipfile;
+    }
+
+    /**
+     * Check if the virus quarantine is allowed
+     *
+     * @return bool
+     * @throws \dml_exception
+     */
+    public static function is_quarantine_enabled() : bool {
+        return !empty(get_config("antivirus", "enablequarantine"));
+    }
+
+    /**
+     * Get quarantine folder
+     *
+     * @return string path of quarantine folder
+     */
+    private static function get_quarantine_folder() : string {
+        global $CFG;
+        $quarantinefolder = $CFG->dataroot . DIRECTORY_SEPARATOR . self::DEFAULT_QUARANTINE_FOLDER;
+        if (!file_exists($quarantinefolder)) {
+            make_upload_directory(self::DEFAULT_QUARANTINE_FOLDER);
+        }
+        return $quarantinefolder . DIRECTORY_SEPARATOR;
+    }
+
+    /**
+     * Checks whether a file exists inside the antivirus quarantine folder.
+     *
+     * @param string $filename the filename to check.
+     * @return boolean whether file exists.
+     */
+    public static function quarantined_file_exists(string $filename) : bool {
+        $folder = self::get_quarantine_folder();
+        return file_exists($folder . $filename);
+    }
+
+    /**
+     * Download quarantined file.
+     *
+     * @param int $fileid the id of file to be downloaded.
+     */
+    public static function download_quarantined_file(int $fileid) {
+        global $DB;
+
+        // Get the filename to be downloaded.
+        $filename = $DB->get_field('infected_files', 'quarantinedfile', ['id' => $fileid], IGNORE_MISSING);
+        // If file record isnt found, user might be doing something naughty in params, or a stale request.
+        if (empty($filename)) {
+            return;
+        }
+
+        $file = self::get_quarantine_folder() . $filename;
+        send_file($file, $filename);
+    }
+
+    /**
+     * Delete quarantined file.
+     *
+     * @param int $fileid id of file to be deleted.
+     */
+    public static function delete_quarantined_file(int $fileid) {
+        global $DB;
+
+        // Get the filename to be deleted.
+        $filename = $DB->get_field('infected_files', 'quarantinedfile', ['id' => $fileid], IGNORE_MISSING);
+        // If file record isnt found, user might be doing something naughty in params, or a stale request.
+        if (empty($filename)) {
+            return;
+        }
+
+        // Delete the file from the folder.
+        $file = self::get_quarantine_folder() . $filename;
+        if (file_exists($file)) {
+            unlink($file);
+        }
+
+        // Now we are finished with the record, delete the quarantine information.
+        self::delete_infected_file_record($fileid);
+    }
+
+    /**
+     * Download all quarantined files.
+     *
+     * @return void
+     */
+    public static function download_all_quarantined_files() {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        // Add all infected files to a zip file.
+        $date = userdate(time(), self::FILE_NAME_DATE_FORMAT);
+        $zipfilename = $date . self::FILE_ZIP_ALL_INFECTED;
+        $zipfilepath = self::get_quarantine_folder() . DIRECTORY_SEPARATOR . $zipfilename;
+        $tempfilestocleanup = [];
+
+        $ziparchive = new \zip_archive();
+        if ($ziparchive->open($zipfilepath, \file_archive::CREATE)) {
+            foreach ($files as $file) {
+                if (!$file->isDot()) {
+                    // Only send the actual files.
+                    $filename = $file->getFilename();
+                    $filepath = $file->getPathname();
+                    $ziparchive->add_file_from_pathname($filename, $filepath);
+                }
+            }
+            $ziparchive->close();
+        }
+
+        // Clean up temp files.
+        foreach ($tempfilestocleanup as $tempfile) {
+            if (file_exists($tempfile)) {
+                unlink($tempfile);
+            }
+        }
+
+        send_temp_file($zipfilepath, $zipfilename);
+    }
+
+    /**
+     * Return array of quarantined files.
+     *
+     * @return array list of quarantined files.
+     */
+    public static function get_quarantined_files() : array {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        $filestosort = [];
+
+        // Grab all files that match the naming structure.
+        foreach ($files as $file) {
+            $filename = $file->getFilename();
+            if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) {
+                $filestosort[$filename] = $file->getPathname();
+            }
+        }
+
+        krsort($filestosort, SORT_NATURAL);
+        return $filestosort;
+    }
+
+    /**
+     * Clean up quarantine folder
+     *
+     * @param int $timetocleanup time to clean up
+     */
+    public static function clean_up_quarantine_folder(int $timetocleanup) {
+        $files = new \DirectoryIterator(self::get_quarantine_folder());
+        // Clean up the folder.
+        foreach ($files as $file) {
+            $filename = $file->getFilename();
+
+            // Only delete files that match the correct name structure.
+            if (!$file->isDot() && strpos($filename, self::FILE_ZIP_INFECTED) !== false) {
+                $modifiedtime = $file->getMTime();
+
+                if ($modifiedtime <= $timetocleanup) {
+                    unlink($file->getPathname());
+                }
+            }
+        }
+
+        // Lastly cleanup the infected files table as well.
+        self::clean_up_infected_records($timetocleanup);
+    }
+
+    /**
+     * This function removes any stale records from the infected files table.
+     *
+     * @param int $timetocleanup the time to cleanup from
+     * @return void
+     */
+    private static function clean_up_infected_records(int $timetocleanup) {
+        global $DB;
+
+        $select = "timecreated <= ?";
+        $DB->delete_records_select('infected_files', $select, [$timetocleanup]);
+    }
+
+    /**
+     * Create an infected file record
+     *
+     * @param string $filename original file name
+     * @param string $zipfile quarantined file name
+     * @param string $reason failure reason
+     * @throws \dml_exception
+     */
+    private static function create_infected_file_record(string $filename, string $zipfile, string $reason) {
+        global $DB, $USER;
+
+        $record = new \stdClass();
+        $record->filename = $filename;
+        $record->quarantinedfile = $zipfile;
+        $record->userid = $USER->id;
+        $record->reason = $reason;
+        $record->timecreated = time();
+
+        $DB->insert_record('infected_files', $record);
+    }
+
+    /**
+     * Delete the database record for an infected file.
+     *
+     * @param int $fileid quarantined file id
+     * @throws \dml_exception
+     */
+    private static function delete_infected_file_record(int $fileid) {
+        global $DB;
+        $DB->delete_records('infected_files', ['id' => $fileid]);
+    }
+}
index d42185c..462684f 100644 (file)
@@ -25,6 +25,7 @@
 namespace core\antivirus;
 
 defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '../../../../iplookup/lib.php');
 
 /**
  * Base abstract antivirus scanner class.
@@ -46,6 +47,8 @@ abstract class scanner {
     protected $config;
     /** @var string scanning notice */
     protected $scanningnotice = '';
+    /** @var array any admin messages generated by a plugin. */
+    protected $messages = [];
 
     /**
      * Class constructor.
@@ -130,30 +133,109 @@ abstract class scanner {
     }
 
     /**
-     * Email admins about antivirus scan outcomes.
+     * This function pushes given messages into the message queue, which will be sent by the antivirus manager.
      *
      * @param string $notice The body of the email to be sent.
+     * @param string $format The body format.
+     * @param string $eventname event name
      * @return void
+     * @throws \coding_exception
+     * @throws \moodle_exception
      */
-    public function message_admins($notice) {
-
+    public function message_admins($notice, $format = FORMAT_PLAIN, $eventname = 'errors') {
+        $noticehtml = $format !== FORMAT_PLAIN ? format_text($notice, $format) : '';
         $site = get_site();
 
         $subject = get_string('emailsubject', 'antivirus', format_string($site->fullname));
-        $admins = get_admins();
+        $notifyemail = get_config('antivirus', 'notifyemail');
+        // If one email address is specified, construct a message to fake account.
+        if (!empty($notifyemail)) {
+            $user = new \stdClass();
+            $user->id = -1;
+            $user->email = $notifyemail;
+            $user->mailformat = 1;
+            $admins = [$user];
+        } else {
+            // Otherwise, we message all admins.
+            $admins = get_admins();
+        }
+
         foreach ($admins as $admin) {
             $eventdata = new \core\message\message();
             $eventdata->courseid          = SITEID;
             $eventdata->component         = 'moodle';
-            $eventdata->name              = 'errors';
+            $eventdata->name              = $eventname;
             $eventdata->userfrom          = get_admin();
             $eventdata->userto            = $admin;
             $eventdata->subject           = $subject;
             $eventdata->fullmessage       = $notice;
-            $eventdata->fullmessageformat = FORMAT_PLAIN;
-            $eventdata->fullmessagehtml   = '';
+            $eventdata->fullmessageformat = $format;
+            $eventdata->fullmessagehtml   = $noticehtml;
             $eventdata->smallmessage      = '';
-            message_send($eventdata);
+
+            // Now add the message to an array to be sent by the antivirus manager.
+            $this->messages[] = $eventdata;
         }
     }
-}
\ No newline at end of file
+
+    /**
+     * Return incident details
+     *
+     * @param string $file full path to the file
+     * @param string $filename original name of the file
+     * @param string $notice notice from antivirus
+     * @param string $virus if this template is due to a virus found.
+     * @return string the incident details
+     * @throws \coding_exception
+     */
+    public function get_incident_details($file = '', $filename = '', $notice = '', $virus = true) {
+        global $OUTPUT, $USER;
+        if (empty($notice)) {
+            $notice = $this->get_scanning_notice();
+        }
+        $classname = get_class($this);
+        $component = explode('\\', $classname)[0];
+
+        $content = new \stdClass();
+        $unknown = get_string('unknown', 'antivirus');
+        $content->header = get_string('emailinfectedfiledetected', 'antivirus');
+        $content->filename = !empty($filename) ? $filename : $unknown;
+        $content->scanner = $component;
+        // Check for empty file, or file not uploaded.
+        if (!empty($file) && filesize($file) !== false) {
+            $content->filesize = display_size(filesize($file));
+            $content->contenthash = \file_storage::hash_from_string(file_get_contents($file));
+            $content->contenttype = mime_content_type($file);
+        } else {
+            $content->filesize = $unknown;
+            $content->contenthash = $unknown;
+            $content->contenttype = $unknown;
+        }
+
+        $content->author = \core_user::is_real_user($USER->id) ? fullname($USER) . " ($USER->username)" : $unknown;
+        $content->ipaddress = getremoteaddr();
+        $geoinfo = iplookup_find_location(getremoteaddr());
+        $content->geoinfo = $geoinfo['city'] . ', ' . $geoinfo['country'];
+        $content->date = userdate(time(), get_string('strftimedatetimeshort'));
+        $content->referer = isset($_SERVER['HTTP_REFERER']) ? $_SERVER['HTTP_REFERER'] : $unknown;
+        $content->notice = $notice;
+        $report = new \moodle_url('/report/infectedfiles/index.php');
+        $content->report = $report->out();
+
+        // If this is not due to a virus, we need to change the header line.
+        if (!$virus) {
+            $content->header = get_string('emailscannererrordetected', 'antivirus');
+        }
+
+        return $OUTPUT->render_from_template('core/infected_file_email', $content);
+    }
+
+    /**
+     * Getter method for messages queued by the antivirus scanner.
+     *
+     * @return array
+     */
+    public function get_messages() : array {
+        return $this->messages;
+    }
+}
diff --git a/lib/classes/event/virus_infected_data_detected.php b/lib/classes/event/virus_infected_data_detected.php
new file mode 100644 (file)
index 0000000..66d3fe3
--- /dev/null
@@ -0,0 +1,78 @@
+<?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/>.
+
+/**
+ * Data infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Data infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class virus_infected_data_detected extends \core\event\base {
+    /**
+     * Event data
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return event description
+     *
+     * @return string description
+     * @throws \coding_exception
+     */
+    public function get_description() {
+        if (isset($this->other['incidentdetails'])) {
+            return format_text($this->other['incidentdetails'], FORMAT_MOODLE);
+        } else {
+            return get_string('datainfecteddesc', 'antivirus');
+        }
+    }
+
+    /**
+     * Return event name
+     *
+     * @return string name
+     * @throws \coding_exception
+     */
+    public static function get_name() {
+        return get_string('datainfectedname', 'antivirus');
+    }
+
+    /**
+     * Return event report link
+     * @return \moodle_url
+     * @throws \moodle_exception
+     */
+    public function get_url() {
+        return new \moodle_url('/report/infectedfiles/index.php');
+    }
+}
diff --git a/lib/classes/event/virus_infected_file_detected.php b/lib/classes/event/virus_infected_file_detected.php
new file mode 100644 (file)
index 0000000..3acd52a
--- /dev/null
@@ -0,0 +1,78 @@
+<?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/>.
+
+/**
+ * Fle infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\event;
+
+defined('MOODLE_INTERNAL') || die();
+/**
+ * Fle infected event
+ *
+ * @package    core
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class virus_infected_file_detected extends \core\event\base {
+    /**
+     * Event data
+     */
+    protected function init() {
+        $this->data['crud'] = 'c';
+        $this->data['edulevel'] = self::LEVEL_OTHER;
+    }
+
+    /**
+     * Return event description
+     *
+     * @return string description
+     * @throws \coding_exception
+     */
+    public function get_description() {
+        if (isset($this->other['incidentdetails'])) {
+            return format_text($this->other['incidentdetails'], FORMAT_MOODLE);
+        } else {
+            return get_string('fileinfecteddesc', 'antivirus');
+        }
+    }
+
+    /**
+     * Return event name
+     *
+     * @return string name
+     * @throws \coding_exception
+     */
+    public static function get_name() {
+        return get_string('fileinfectedname', 'antivirus');
+    }
+
+    /**
+     * Return event report link
+     * @return \moodle_url
+     * @throws \moodle_exception
+     */
+    public function get_url() {
+        return new \moodle_url('/report/infectedfiles/index.php');
+    }
+}
index 4d69a12..886b442 100644 (file)
@@ -1966,8 +1966,8 @@ class core_plugin_manager {
 
             'report' => array(
                 'backups', 'competency', 'completion', 'configlog', 'courseoverview', 'eventlist',
-                'insights', 'log', 'loglive', 'outline', 'participation', 'progress', 'questioninstances',
-                'security', 'stats', 'status', 'performance', 'usersessions'
+                'infectedfiles', 'insights', 'log', 'loglive', 'outline', 'participation', 'progress',
+                'questioninstances', 'security', 'stats', 'status', 'performance', 'usersessions'
             ),
 
             'repository' => array(
diff --git a/lib/classes/task/antivirus_cleanup_task.php b/lib/classes/task/antivirus_cleanup_task.php
new file mode 100644 (file)
index 0000000..6a321d3
--- /dev/null
@@ -0,0 +1,62 @@
+<?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/>.
+
+/**
+ * Clean up task for core antivirus
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Clean up task for core antivirus
+ *
+ * @package    core_antivirus
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class antivirus_cleanup_task extends scheduled_task {
+
+    /**
+     * Get a descriptive name for this task.
+     *
+     * @return string
+     */
+    public function get_name() {
+        return get_string('taskcleanup', 'antivirus');
+    }
+
+    /**
+     * Processes workflows.
+     */
+    public function execute() {
+        $quarantinetime = get_config('antivirus', 'quarantinetime');
+        if (empty($quarantinetime)) {
+            $quarantinetime = \core\antivirus\quarantine::DEFAULT_QUARANTINE_TIME;
+            set_config('quarantinetime', $quarantinetime, 'antivirus');
+        }
+        $timetocleanup = time() - $quarantinetime;
+        \core\antivirus\quarantine::clean_up_quarantine_folder($timetocleanup);
+    }
+
+}
index 1df8fbb..6876b98 100644 (file)
@@ -44,24 +44,27 @@ class backup_cleanup_task extends scheduled_task {
     public function execute() {
         global $DB;
 
-        $timenow = time();
-
-        // Delete old backup_controllers and logs.
         $loglifetime = get_config('backup', 'loglifetime');
-        if (!empty($loglifetime)) {  // Value in days.
-            $loglifetime = $timenow - ($loglifetime * 3600 * 24);
-            // Delete child records from backup_logs.
-            $DB->execute("DELETE FROM {backup_logs}
-                           WHERE EXISTS (
-                               SELECT 'x'
-                                 FROM {backup_controllers} bc
-                                WHERE bc.backupid = {backup_logs}.backupid
-                                  AND bc.timecreated < ?)", array($loglifetime));
-            // Delete records from backup_controllers.
-            $DB->execute("DELETE FROM {backup_controllers}
-                          WHERE timecreated < ?", array($loglifetime));
+
+        if (empty($loglifetime)) {
+            throw new coding_exception('The \'loglifetime\' config is not set. Can\'t proceed and delete old backup records.');
         }
 
+        // First, get the list of all backupids older than loglifetime.
+        $timecreated = time() - ($loglifetime * DAYSECS);
+        $records = $DB->get_records_select('backup_controllers', 'timecreated < ?', array($timecreated), 'id', 'id, backupid');
+
+        foreach ($records as $record) {
+            // Check if there is no incomplete adhoc task relying on the given backupid.
+            $params = array('%' . $record->backupid . '%');
+            $select = $DB->sql_like('customdata', '?', false);
+            $count = $DB->count_records_select('task_adhoc',  $select, $params);
+            if ($count === 0) {
+                // Looks like there is no adhoc task, so we can delete logs and controllers for this backupid.
+                $DB->delete_records('backup_logs', array('backupid' => $record->backupid));
+                $DB->delete_records('backup_controllers', array('backupid' => $record->backupid));
+            }
+        }
     }
 
 }
index 4e25171..d1978fd 100644 (file)
@@ -75,6 +75,8 @@ class database_logger implements task_logger {
             'dbwrites' => $dbwrites,
             'result' => (int) $failed,
             'output' => file_get_contents($logpath),
+            'hostname' => $task->get_hostname(),
+            'pid' => $task->get_pid(),
         ];
 
         if (is_a($task, adhoc_task::class) && $userid = $task->get_userid()) {
index 552cd49..08b6242 100644 (file)
@@ -255,6 +255,9 @@ class manager {
         $record->dayofweek = $task->get_day_of_week();
         $record->month = $task->get_month();
         $record->disabled = $task->get_disabled();
+        $record->timestarted = $task->get_timestarted();
+        $record->hostname = $task->get_hostname();
+        $record->pid = $task->get_pid();
 
         return $record;
     }
@@ -276,6 +279,9 @@ class manager {
         $record->customdata = $task->get_custom_data_as_string();
         $record->userid = $task->get_userid();
         $record->timecreated = time();
+        $record->timestarted = $task->get_timestarted();
+        $record->hostname = $task->get_hostname();
+        $record->pid = $task->get_pid();
 
         return $record;
     }
@@ -313,6 +319,15 @@ class manager {
         if (isset($record->userid)) {
             $task->set_userid($record->userid);
         }
+        if (isset($record->timestarted)) {
+            $task->set_timestarted($record->timestarted);
+        }
+        if (isset($record->hostname)) {
+            $task->set_hostname($record->hostname);
+        }
+        if (isset($record->pid)) {
+            $task->set_pid($record->pid);
+        }
 
         return $task;
     }
@@ -367,6 +382,15 @@ class manager {
         if (isset($record->disabled)) {
             $task->set_disabled($record->disabled);
         }
+        if (isset($record->timestarted)) {
+            $task->set_timestarted($record->timestarted);
+        }
+        if (isset($record->hostname)) {
+            $task->set_hostname($record->hostname);
+        }
+        if (isset($record->pid)) {
+            $task->set_pid($record->pid);
+        }
 
         return $task;
     }
@@ -709,6 +733,9 @@ class manager {
      */
     public static function adhoc_task_failed(adhoc_task $task) {
         global $DB;
+        // Finalise the log output.
+        logmanager::finalise_log(true);
+
         $delay = $task->get_fail_delay();
 
         // Reschedule task with exponential fall off for failing tasks.
@@ -724,6 +751,9 @@ class manager {
         }
 
         // Reschedule and then release the locks.
+        $task->set_timestarted();
+        $task->set_hostname();
+        $task->set_pid();
         $task->set_next_run_time(time() + $delay);
         $task->set_fail_delay($delay);
         $record = self::record_from_adhoc_task($task);
@@ -734,9 +764,31 @@ class manager {
             $task->get_cron_lock()->release();
         }
         $task->get_lock()->release();
+    }
 
-        // Finalise the log output.
-        logmanager::finalise_log(true);
+    /**
+     * Records that a adhoc task is starting to run.
+     *
+     * @param adhoc_task $task Task that is starting
+     * @param int $time Start time (leave blank for now)
+     * @throws \dml_exception
+     * @throws \coding_exception
+     */
+    public static function adhoc_task_starting(adhoc_task $task, int $time = 0) {
+        global $DB;
+        $pid = (int)getmypid();
+        $hostname = (string)gethostname();
+
+        if (empty($time)) {
+            $time = time();
+        }
+
+        $task->set_timestarted($time);
+        $task->set_hostname($hostname);
+        $task->set_pid($pid);
+
+        $record = self::record_from_adhoc_task($task);
+        $DB->update_record('task_adhoc', $record);
     }
 
     /**
@@ -749,6 +801,9 @@ class manager {
 
         // Finalise the log output.
         logmanager::finalise_log();
+        $task->set_timestarted();
+        $task->set_hostname();
+        $task->set_pid();
 
         // Delete the adhoc task record - it is finished.
         $DB->delete_records('task_adhoc', array('id' => $task->get_id()));
@@ -768,6 +823,8 @@ class manager {
      */
     public static function scheduled_task_failed(scheduled_task $task) {
         global $DB;
+        // Finalise the log output.
+        logmanager::finalise_log(true);
 
         $delay = $task->get_fail_delay();
 
@@ -783,20 +840,24 @@ class manager {
             $delay = 86400;
         }
 
+        $task->set_timestarted();
+        $task->set_hostname();
+        $task->set_pid();
+
         $classname = self::get_canonical_class_name($task);
 
         $record = $DB->get_record('task_scheduled', array('classname' => $classname));
         $record->nextruntime = time() + $delay;
         $record->faildelay = $delay;
+        $record->timestarted = null;
+        $record->hostname = null;
+        $record->pid = null;
         $DB->update_record('task_scheduled', $record);
 
         if ($task->is_blocking()) {
             $task->get_cron_lock()->release();
         }
         $task->get_lock()->release();
-
-        // Finalise the log output.
-        logmanager::finalise_log(true);
     }
 
     /**
@@ -816,6 +877,34 @@ class manager {
         $DB->update_record('task_scheduled', $record);
     }
 
+    /**
+     * Records that a scheduled task is starting to run.
+     *
+     * @param scheduled_task $task Task that is starting
+     * @param int $time Start time (0 = current)
+     * @throws \dml_exception If the task doesn't exist
+     */
+    public static function scheduled_task_starting(scheduled_task $task, int $time = 0) {
+        global $DB;
+        $pid = (int)getmypid();
+        $hostname = (string)gethostname();
+
+        if (!$time) {
+            $time = time();
+        }
+
+        $task->set_timestarted($time);
+        $task->set_hostname($hostname);
+        $task->set_pid($pid);
+
+        $classname = self::get_canonical_class_name($task);
+        $record = $DB->get_record('task_scheduled', ['classname' => $classname], '*', MUST_EXIST);
+        $record->timestarted = $time;
+        $record->hostname = $hostname;
+        $record->pid = $pid;
+        $DB->update_record('task_scheduled', $record);
+    }
+
     /**
      * This function indicates that a scheduled task was completed successfully and should be rescheduled.
      *
@@ -826,6 +915,9 @@ class manager {
 
         // Finalise the log output.
         logmanager::finalise_log();
+        $task->set_timestarted();
+        $task->set_hostname();
+        $task->set_pid();
 
         $classname = self::get_canonical_class_name($task);
         $record = $DB->get_record('task_scheduled', array('classname' => $classname));
@@ -833,6 +925,9 @@ class manager {
             $record->lastruntime = time();
             $record->faildelay = 0;
             $record->nextruntime = $task->get_next_scheduled_time();
+            $record->timestarted = null;
+            $record->hostname = null;
+            $record->pid = null;
 
             $DB->update_record('task_scheduled', $record);
         }
@@ -844,6 +939,47 @@ class manager {
         $task->get_lock()->release();
     }
 
+    /**
+     * Gets a list of currently-running tasks.
+     *
+     * @param  string $sort Sorting method
+     * @return array Array of scheduled and adhoc tasks
+     * @throws \dml_exception
+     */
+    public static function get_running_tasks($sort = ''): array {
+        global $DB;
+        if (empty($sort)) {
+            $sort = 'timestarted ASC, classname ASC';
+        }
+        $params = ['now1' => time(), 'now2' => time()];
+
+        $sql = "SELECT subquery.*
+                  FROM (SELECT concat('s', ts.id) as uniqueid,
+                               ts.id,
+                               'scheduled' as type,
+                               ts.classname,
+                               (:now1 - ts.timestarted) as time,
+                               ts.timestarted,
+                               ts.hostname,
+                               ts.pid
+                          FROM {task_scheduled} ts
+                         WHERE ts.timestarted IS NOT NULL
+                         UNION ALL
+                        SELECT concat('a', ta.id) as uniqueid,
+                               ta.id,
+                               'adhoc' as type,
+                               ta.classname,
+                               (:now2 - ta.timestarted) as time,
+                               ta.timestarted,
+                               ta.hostname,
+                               ta.pid
+                          FROM {task_adhoc} ta
+                         WHERE ta.timestarted IS NOT NULL) subquery
+              ORDER BY " . $sort;
+
+        return $DB->get_records_sql($sql, $params);
+    }
+
     /**
      * This function is used to indicate that any long running cron processes should exit at the
      * next opportunity and restart. This is because something (e.g. DB changes) has changed and
@@ -959,7 +1095,7 @@ class manager {
 
             // Shell-escaped task name.
             $classname = get_class($task);
-            $taskarg   = escapeshellarg("--execute={$classname}");
+            $taskarg   = escapeshellarg("--execute={$classname}") . " " . escapeshellarg("--force");
 
             // Build the CLI command.
             $command = "{$phpbinary} {$scriptpath} {$taskarg}";
index 05e68a5..bffd736 100644 (file)
@@ -50,6 +50,15 @@ abstract class task_base {
     /** @var int $nextruntime - When this task is due to run next */
     private $nextruntime = 0;
 
+    /** @var int $timestarted - When this task was started */
+    private $timestarted = null;
+
+    /** @var string $hostname - Hostname where this task was started and PHP process ID */
+    private $hostname = null;
+
+    /** @var int $pid - PHP process ID that is running the task */
+    private $pid = null;
+
     /**
      * Set the current lock for this task.
      * @param \core\lock\lock $lock
@@ -151,4 +160,52 @@ abstract class task_base {
      * Throw exceptions on errors (the job will be retried).
      */
     public abstract function execute();
+
+    /**
+     * Setter for $timestarted.
+     * @param int $timestarted
+     */
+    public function set_timestarted($timestarted = null) {
+        $this->timestarted = $timestarted;
+    }
+
+    /**
+     * Getter for $timestarted.
+     * @return int
+     */
+    public function get_timestarted() {
+        return $this->timestarted;
+    }
+
+    /**
+     * Setter for $hostname.
+     * @param string $hostname
+     */
+    public function set_hostname($hostname = null) {
+        $this->hostname = $hostname;
+    }
+
+    /**
+     * Getter for $hostname.
+     * @return string
+     */
+    public function get_hostname() {
+        return $this->hostname;
+    }
+
+    /**
+     * Setter for $pid.
+     * @param int $pid
+     */
+    public function set_pid($pid = null) {
+        $this->pid = $pid;
+    }
+
+    /**
+     * Getter for $pid.
+     * @return int
+     */
+    public function get_pid() {
+        return $this->pid;
+    }
 }
index 74a83d5..4190bf2 100644 (file)
@@ -237,6 +237,7 @@ function cron_run_adhoc_tasks(int $timenow, $keepalive = 0, $checklimits = true)
 function cron_run_inner_scheduled_task(\core\task\task_base $task) {
     global $CFG, $DB;
 
+    \core\task\manager::scheduled_task_starting($task);
     \core\task\logmanager::start_logging($task);
 
     $fullname = $task->get_name() . ' (' . get_class($task) . ')';
@@ -295,6 +296,7 @@ function cron_run_inner_scheduled_task(\core\task\task_base $task) {
 function cron_run_inner_adhoc_task(\core\task\adhoc_task $task) {
     global $DB, $CFG;
 
+    \core\task\manager::adhoc_task_starting($task);
     \core\task\logmanager::start_logging($task);
 
     mtrace("Execute adhoc task: " . get_class($task));
index 3de202d..5078f4d 100644 (file)
@@ -2565,4 +2565,15 @@ $capabilities = array(
             'editingteacher' => CAP_ALLOW,
         )
     ],
+
+    // Allow users to download content.
+    'moodle/contentbank:downloadcontent' => [
+        'captype' => 'read',
+        'contextlevel' => CONTEXT_COURSE,
+        'archetypes' => [
+            'manager' => CAP_ALLOW,
+            'coursecreator' => CAP_ALLOW,
+            'editingteacher' => CAP_ALLOW,
+        ]
+    ],
 );
index e50236e..43e82bc 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="lib/db" VERSION="20200707" COMMENT="XMLDB file for core Moodle tables"
+<XMLDB PATH="lib/db" VERSION="20200804" COMMENT="XMLDB file for core Moodle tables"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../lib/xmldb/xmldb.xsd"
 >
         <FIELD NAME="faildelay" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="customised" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Used on upgrades to prevent overwriting custom schedules."/>
         <FIELD NAME="disabled" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="1 means do not run from cron"/>
+        <FIELD NAME="timestarted" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="Time when the task was started"/>
+        <FIELD NAME="hostname" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Hostname where the task is running"/>
+        <FIELD NAME="pid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="PHP process ID that is running the task"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false"/>
         <FIELD NAME="blocking" TYPE="int" LENGTH="2" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="Timestamp of adhoc task creation"/>
+        <FIELD NAME="timestarted" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="Time when the task was started"/>
+        <FIELD NAME="hostname" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Hostname where the task is running"/>
+        <FIELD NAME="pid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="PHP process ID that is running the task"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <FIELD NAME="dbwrites" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The number of DB writes performed during the task."/>
         <FIELD NAME="result" TYPE="int" LENGTH="2" NOTNULL="true" SEQUENCE="false" COMMENT="Whether the task was successful or not. 0 = pass; 1 = fail."/>
         <FIELD NAME="output" TYPE="text" NOTNULL="true" SEQUENCE="false"/>
+        <FIELD NAME="hostname" TYPE="char" LENGTH="255" NOTNULL="false" SEQUENCE="false" COMMENT="Hostname where the task was executed"/>
+        <FIELD NAME="pid" TYPE="int" LENGTH="10" NOTNULL="false" SEQUENCE="false" COMMENT="PHP process ID that was running the task"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <FIELD NAME="addto" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Plugin configuration data"/>
         <FIELD NAME="coremajor" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="H5P core API major version required"/>
         <FIELD NAME="coreminor" TYPE="int" LENGTH="4" NOTNULL="false" SEQUENCE="false" COMMENT="H5P core API minor version required"/>
+        <FIELD NAME="metadatasettings" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Library metadata settings"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
         <INDEX NAME="instance" UNIQUE="false" FIELDS="contextid, contenttype, instanceid"/>
       </INDEXES>
     </TABLE>
+    <TABLE NAME="infected_files" COMMENT="Table to store infected file details.">
+      <FIELDS>
+        <FIELD NAME="id" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="true"/>
+        <FIELD NAME="filename" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="Original file name"/>
+        <FIELD NAME="quarantinedfile" TYPE="text" NOTNULL="false" SEQUENCE="false" COMMENT="Quarantine zip file"/>
+        <FIELD NAME="userid" TYPE="int" LENGTH="10" NOTNULL="true" SEQUENCE="false" COMMENT="The user that uploaded the infected file."/>
+        <FIELD NAME="reason" TYPE="text" NOTNULL="true" SEQUENCE="false" COMMENT="The reason for the antivirus failure"/>
+        <FIELD NAME="timecreated" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The time the infected file was uploaded."/>
+      </FIELDS>
+      <KEYS>
+        <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
+        <KEY NAME="userid" TYPE="foreign" FIELDS="userid" REFTABLE="user" REFFIELDS="id" COMMENT="Foreign key for the userid"/>
+      </KEYS>
+    </TABLE>
   </TABLES>
 </XMLDB>
\ No newline at end of file
index b892988..eeef52e 100644 (file)
@@ -142,4 +142,9 @@ $messageproviders = array (
             'email' => MESSAGE_PERMITTED + MESSAGE_DEFAULT_LOGGEDOFF,
         ),
     ],
+
+    // Infected files.
+    'infected' => array(
+        'capability'  => 'moodle/site:config',
+    ),
 );
index 171d0a5..9c473ea 100644 (file)
@@ -401,4 +401,13 @@ $tasks = array(
         'dayofweek' => '*',
         'month' => '*'
     ),
+    array(
+        'classname' => 'core\task\antivirus_cleanup_task',
+        'blocking' => 0,
+        'minute' => 'R',
+        'hour' => '0',
+        'day' => '*',
+        'dayofweek' => '*',
+        'month' => '*',
+    ),
 );
index d3acfec..5c2dcca 100644 (file)
@@ -2575,5 +2575,114 @@ function xmldb_main_upgrade($oldversion) {
         upgrade_main_savepoint(true, 2021052500.02);
     }
 
+    if ($oldversion < 2021052500.04) {
+        // Define field metadatasettings to be added to h5p_libraries.
+        $table = new xmldb_table('h5p_libraries');
+        $field = new xmldb_field('metadatasettings', XMLDB_TYPE_TEXT, null, null, null, null, null, 'coreminor');
+
+        // Conditionally launch add field metadatasettings.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Get installed library files that have no metadata settings value.
+        $params = [
+            'component' => 'core_h5p',
+            'filearea' => 'libraries',
+            'filename' => 'library.json',
+        ];
+        $sql = "SELECT l.id, f.id as fileid
+                  FROM {files} f
+             LEFT JOIN {h5p_libraries} l ON f.itemid = l.id
+                 WHERE f.component = :component
+                       AND f.filearea = :filearea
+                       AND f.filename = :filename";
+        $libraries = $DB->get_records_sql($sql, $params);
+
+        // Update metadatasettings field when the attribute is present in the library.json file.
+        $fs = get_file_storage();
+        foreach ($libraries as $library) {
+            $jsonfile = $fs->get_file_by_id($library->fileid);
+            $jsoncontent = json_decode($jsonfile->get_content());
+            if (isset($jsoncontent->metadataSettings)) {
+                unset($library->fileid);
+                $library->metadatasettings = json_encode($jsoncontent->metadataSettings);
+                $DB->update_record('h5p_libraries', $library);
+            }
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021052500.04);
+    }
+
+    if ($oldversion < 2021052500.05) {
+        // Define fields to be added to task_scheduled.
+        $table = new xmldb_table('task_scheduled');
+        $field = new xmldb_field('timestarted', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'disabled');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        $field = new xmldb_field('hostname', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'timestarted');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        $field = new xmldb_field('pid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'hostname');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define fields to be added to task_adhoc.
+        $table = new xmldb_table('task_adhoc');
+        $field = new xmldb_field('timestarted', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'blocking');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        $field = new xmldb_field('hostname', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'timestarted');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        $field = new xmldb_field('pid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'hostname');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define fields to be added to task_log.
+        $table = new xmldb_table('task_log');
+        $field = new xmldb_field('hostname', XMLDB_TYPE_CHAR, '255', null, null, null, null, 'output');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+        $field = new xmldb_field('pid', XMLDB_TYPE_INTEGER, '10', null, null, null, null, 'hostname');
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Main savepoint reached.
+        upgrade_main_savepoint(true, 2021052500.05);
+    }
+
+    if ($oldversion < 2021052500.06) {
+        // Define table to store virus infected details.
+        $table = new xmldb_table('infected_files');
+
+        // Adding fields to table infected_files.
+        $table->add_field('id', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, XMLDB_SEQUENCE, null);
+        $table->add_field('filename', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('quarantinedfile', XMLDB_TYPE_TEXT, null, null, null, null, null);
+        $table->add_field('userid', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, null);
+        $table->add_field('reason', XMLDB_TYPE_TEXT, null, null, XMLDB_NOTNULL, null, null);
+        $table->add_field('timecreated', XMLDB_TYPE_INTEGER, '10', null, XMLDB_NOTNULL, null, '0');
+
+        // Adding keys to table infected_files.
+        $table->add_key('primary', XMLDB_KEY_PRIMARY, ['id']);
+        $table->add_key('userid', XMLDB_KEY_FOREIGN, ['userid'], 'user', ['id']);
+
+        // Conditionally launch create table for infected_files.
+        if (!$dbman->table_exists($table)) {
+            $dbman->create_table($table);
+        }
+        upgrade_main_savepoint(true, 2021052500.06);
+    }
+
     return true;
 }
index 20c0d05..41b12fc 100644 (file)
@@ -34,7 +34,7 @@ require_once($CFG->libdir . '/form/text.php');
  * Duration element
  *
  * HTML class for a length of time. For example, 30 minutes of 4 days. The
- * values returned to PHP is the duration in seconds.
+ * values returned to PHP is the duration in seconds (an int rounded to the nearest second).
  *
  * @package   core_form
  * @category  form
@@ -43,11 +43,12 @@ require_once($CFG->libdir . '/form/text.php');
  */
 class MoodleQuickForm_duration extends MoodleQuickForm_group {
     /**
-     * Control the fieldnames for form elements
+     * Control the field names for form elements
      * optional => if true, show a checkbox beside the element to turn it on (or off)
+     * defaultunit => which unit is default when the form is blank (default Minutes).
      * @var array
      */
-    protected $_options = array('optional' => false, 'defaultunit' => MINSECS);
+    protected $_options = ['optional' => false, 'defaultunit' => MINSECS];
 
     /** @var array associative array of time units (days, hours, minutes, seconds) */
     private $_units = null;
@@ -55,7 +56,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
    /**
     * constructor
     *
-    * @param string $elementName Element's name
+    * @param ?string $elementName Element's name
     * @param mixed $elementLabel Label(s) for an element
     * @param array $options Options to control the element's display. Recognised values are
     *      'optional' => true/false - whether to display an 'enabled' checkbox next to the element.
@@ -66,16 +67,15 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
     * @param mixed $attributes Either a typical HTML attribute string or an associative array
     */
     public function __construct($elementName = null, $elementLabel = null,
-            $options = array(), $attributes = null) {
-        // TODO MDL-52313 Replace with the call to parent::__construct().
-        HTML_QuickForm_element::__construct($elementName, $elementLabel, $attributes);
+            $options = [], $attributes = null) {
+        parent::__construct($elementName, $elementLabel, $attributes);
         $this->_persistantFreeze = true;
         $this->_appendName = true;
         $this->_type = 'duration';
 
         // Set the options, do not bother setting bogus ones
         if (!is_array($options)) {
-            $options = array();
+            $options = [];
         }
         $this->_options['optional'] = !empty($options['optional']);
         if (isset($options['defaultunit'])) {
@@ -111,7 +111,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      * @deprecated since Moodle 3.1
      */
     public function MoodleQuickForm_duration($elementName = null, $elementLabel = null,
-            $options = array(), $attributes = null) {
+            $options = [], $attributes = null) {
         debugging('Use of class name as constructor is deprecated', DEBUG_DEVELOPER);
         self::__construct($elementName, $elementLabel, $options, $attributes);
     }
@@ -123,13 +123,13 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      */
     public function get_units() {
         if (is_null($this->_units)) {
-            $this->_units = array(
+            $this->_units = [
                 WEEKSECS => get_string('weeks'),
                 DAYSECS => get_string('days'),
                 HOURSECS => get_string('hours'),
                 MINSECS => get_string('minutes'),
                 1 => get_string('seconds'),
-            );
+            ];
         }
         return $this->_units;
     }
@@ -158,14 +158,14 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      */
     public function seconds_to_unit($seconds) {
         if ($seconds == 0) {
-            return array(0, $this->_options['defaultunit']);
+            return [0, $this->_options['defaultunit']];
         }
         foreach ($this->get_units_used() as $unit => $notused) {
             if (fmod($seconds, $unit) == 0) {
-                return array($seconds / $unit, $unit);
+                return [$seconds / $unit, $unit];
             }
         }
-        return array($seconds, 1);
+        return [$seconds, 1];
     }
 
     /**
@@ -174,12 +174,12 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
     function _createElements() {
         $attributes = $this->getAttributes();
         if (is_null($attributes)) {
-            $attributes = array();
+            $attributes = [];
         }
         if (!isset($attributes['size'])) {
             $attributes['size'] = 3;
         }
-        $this->_elements = array();
+        $this->_elements = [];
         // E_STRICT creating elements without forms is nasty because it internally uses $this
         $number = $this->createFormElement('text', 'number',
                 get_string('time', 'form'), $attributes, true);
@@ -226,7 +226,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
                 }
                 if (!is_array($value)) {
                     list($number, $unit) = $this->seconds_to_unit($value);
-                    $value = array('number' => $number, 'timeunit' => $unit);
+                    $value = ['number' => $number, 'timeunit' => $unit];
                     // If optional, default to off, unless a date was provided
                     if ($this->_options['optional']) {
                         $value['enabled'] = $number != 0;
@@ -245,7 +245,6 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
                 }
                 $caller->setType($arg[0] . '[number]', PARAM_FLOAT);
                 return parent::onQuickFormEvent($event, $arg, $caller);
-                break;
 
             default:
                 return parent::onQuickFormEvent($event, $arg, $caller);
@@ -270,7 +269,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      *
      * @param HTML_QuickForm_Renderer $renderer An HTML_QuickForm_Renderer object
      * @param bool $required Whether a group is required
-     * @param string $error An error message associated with a group
+     * @param ?string $error An error message associated with a group
      */
     function accept(&$renderer, $required = false, $error = null) {
         $renderer->renderElement($this, $required, $error);
@@ -286,7 +285,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
      */
     function exportValue(&$submitValues, $assoc = false) {
         // Get the values from all the child elements.
-        $valuearray = array();
+        $valuearray = [];
         foreach ($this->_elements as $element) {
             $thisexport = $element->exportValue($submitValues[$this->getName()], true);
             if (!is_null($thisexport)) {
@@ -301,6 +300,7 @@ class MoodleQuickForm_duration extends MoodleQuickForm_group {
         if ($this->_options['optional'] && empty($valuearray['enabled'])) {
             return $this->_prepareValue(0, $assoc);
         }
-        return $this->_prepareValue($valuearray['number'] * $valuearray['timeunit'], $assoc);
+        return $this->_prepareValue(
+                (int) round($valuearray['number'] * $valuearray['timeunit']), $assoc);
     }
 }
index 4ce9280..629ac4d 100644 (file)
@@ -47,7 +47,7 @@ class core_form_duration_testcase extends basic_testcase {
      *
      * @return MoodleQuickForm
      */
-    protected function get_test_form() {
+    protected function get_test_form(): MoodleQuickForm {
         $form = new temp_form_duration();
         return $form->getform();
     }
@@ -57,27 +57,26 @@ class core_form_duration_testcase extends basic_testcase {
      *
      * @return array with two elements, a MoodleQuickForm and a MoodleQuickForm_duration.
      */
-    protected function get_test_form_and_element() {
+    protected function get_test_form_and_element(): array {
         $mform = $this->get_test_form();
         $element = $mform->addElement('duration', 'duration');
         return [$mform, $element];
     }
 
     /**
-     * Testcase for testing contructor.
-     *
-     * @expectedException coding_exception
+     * Test the constructor error handling.
      */
-    public function test_constructor() {
+    public function test_constructor_rejects_invalid_unit(): void {
         // Test trying to create with an invalid unit.
         $mform = $this->get_test_form();
+        $this->expectException('coding_exception');
         $mform->addElement('duration', 'testel', null, ['defaultunit' => 123, 'optional' => false]);
     }
 
     /**
-     * Test contructor only some units.
+     * Test constructor only some units.
      */
-    public function test_constructor_limited_units() {
+    public function test_constructor_limited_units(): void {
         $mform = $this->get_test_form();
         $mform->addElement('duration', 'testel', null, ['units' => [MINSECS, 1], 'optional' => false]);
         $html = $mform->toHtml();
@@ -90,7 +89,7 @@ class core_form_duration_testcase extends basic_testcase {
     /**
      * Testcase for testing units (seconds, minutes, hours and days)
      */
-    public function test_get_units() {
+    public function test_get_units(): void {
         [$mform, $element] = $this->get_test_form_and_element();
         $units = $element->get_units();
         $this->assertEquals($units, [1 => get_string('seconds'), 60 => get_string('minutes'),
@@ -98,66 +97,95 @@ class core_form_duration_testcase extends basic_testcase {
     }
 
     /**
-     * Testcase for testing conversion of seconds to the best possible unit
+     * Data provider for {@see test_seconds_to_unit()}.
+     *
+     * @return array test cases.
      */
-    public function test_seconds_to_unit() {
-        [$mform, $element] = $this->get_test_form_and_element();
-        $this->assertEquals([0, MINSECS], $element->seconds_to_unit(0)); // Zero minutes, for a nice default unit.
-        $this->assertEquals([1, 1], $element->seconds_to_unit(1));
-        $this->assertEquals([3601, 1], $element->seconds_to_unit(3601));
-        $this->assertEquals([1, MINSECS], $element->seconds_to_unit(60));
-        $this->assertEquals([3, MINSECS], $element->seconds_to_unit(180));
-        $this->assertEquals([1, HOURSECS], $element->seconds_to_unit(3600));
-        $this->assertEquals([2, HOURSECS], $element->seconds_to_unit(7200));
-        $this->assertEquals([1, DAYSECS], $element->seconds_to_unit(86400));
-        $this->assertEquals([25, HOURSECS], $element->seconds_to_unit(90000));
+    public function seconds_to_unit_cases(): array {
+        return [
+            [[0, MINSECS], 0], // Zero minutes, for a nice default unit.
+            [[1, 1], 1],
+            [[3601, 1], 3601],
+            [[1, MINSECS], 60],
+            [[3, MINSECS], 180],
+            [[1, HOURSECS], 3600],
+            [[2, HOURSECS], 7200],
+            [[1, DAYSECS], 86400],
+            [[25, HOURSECS], 90000],
+        ];
+    }
 
+    /**
+     * Testcase for testing conversion of seconds to the best possible unit.
+     *
+     * @dataProvider seconds_to_unit_cases
+     * @param array $expected expected return value from seconds_to_unit
+     * @param int $seconds value to pass to seconds_to_unit
+     */
+    public function test_seconds_to_unit(array $expected, int $seconds): void {
+        [, $element] = $this->get_test_form_and_element();
+        $this->assertEquals($expected, $element->seconds_to_unit($seconds));
+    }
+
+    /**
+     * Testcase for testing conversion of seconds to the best possible unit with a non-default default unit.
+     */
+    public function test_seconds_to_unit_different_default_unit() {
+        $mform = $this->get_test_form();
         $element = $mform->addElement('duration', 'testel', null,
                 ['defaultunit' => DAYSECS, 'optional' => false]);
-        $this->assertEquals([0, DAYSECS], $element->seconds_to_unit(0)); // Zero minutes, for a nice default unit.
+        $this->assertEquals([0, DAYSECS], $element->seconds_to_unit(0));
+    }
+
+    /**
+     * Data provider for {@see test_export_value()}.
+     *
+     * @return array test cases.
+     */
+    public function export_value_cases(): array {
+        return [
+            [10, '10', 1],
+            [9, '9.3', 1],
+            [10, '9.5', 1],
+            [180, '3', MINSECS],
+            [90, '1.5', MINSECS],
+            [7200, '2', HOURSECS],
+            [86400, '1', DAYSECS],
+            [0, '0', HOURSECS],
+            [0, '10', 1, 0, true],
+            [20, '20', 1, 1, true],
+            [0, '10', 1, 0, true, ''],
+            [20, '20', 1, 1, true, ''],
+        ];
     }
 
     /**
      * Testcase to check generated timestamp
+     *
+     * @dataProvider export_value_cases
+     * @param int $expected Expected value returned by the element.
+     * @param string $number Number entered into the element.
+     * @param int $unit Unit selected in the element.
+     * @param int $enabled Whether the enabled checkbox on the form was selected. (Only used if $optional is true.)
+     * @param bool $optional Whether the element has the optional option on.
+     * @param string|null $label The element's label.
      */
-    public function test_exportValue() {
+    public function test_export_value(int $expected, string $number, int $unit, int $enabled = 0,
+            bool $optional = false, ?string $label = null): void {
+
+        // Create the test element.
         $mform = $this->get_test_form();
-        $el = $mform->addElement('duration', 'testel');
-        $values = ['testel' => ['number' => 10, 'timeunit' => 1]];
-        $this->assertEquals(['testel' => 10], $el->exportValue($values, true));
-        $this->assertEquals(10, $el->exportValue($values));
-        $values = ['testel' => ['number' => 3, 'timeunit' => MINSECS]];
-        $this->assertEquals(['testel' => 180], $el->exportValue($values, true));
-        $this->assertEquals(180, $el->exportValue($values));
-        $values = ['testel' => ['number' => 1.5, 'timeunit' => MINSECS]];
-        $this->assertEquals(['testel' => 90], $el->exportValue($values, true));
-        $this->assertEquals(90, $el->exportValue($values));
-        $values = ['testel' => ['number' => 2, 'timeunit' => HOURSECS]];
-        $this->assertEquals(['testel' => 7200], $el->exportValue($values, true));
-        $this->assertEquals(7200, $el->exportValue($values));
-        $values = ['testel' => ['number' => 1, 'timeunit' => DAYSECS]];
-        $this->assertEquals(['testel' => 86400], $el->exportValue($values, true));
-        $this->assertEquals(86400, $el->exportValue($values));
-        $values = ['testel' => ['number' => 0, 'timeunit' => HOURSECS]];
-        $this->assertEquals(['testel' => 0], $el->exportValue($values, true));
-        $this->assertEquals(0, $el->exportValue($values));
-
-        $el = $mform->addElement('duration', 'testel', null, ['optional' => true]);
-        $values = ['testel' => ['number' => 10, 'timeunit' => 1]];
-        $this->assertEquals(['testel' => 0], $el->exportValue($values, true));
-        $this->assertEquals(0, $el->exportValue($values));
-        $values = ['testel' => ['number' => 20, 'timeunit' => 1, 'enabled' => 1]];
-        $this->assertEquals(['testel' => 20], $el->exportValue($values, true));
-        $this->assertEquals(20, $el->exportValue($values));
-
-        // Optional element.
-        $el2 = $mform->addElement('duration', 'testel', '', ['optional' => true]);
-        $values = ['testel' => ['number' => 10, 'timeunit' => 1, 'enabled' => 1]];
-        $this->assertEquals(['testel' => 10], $el2->exportValue($values, true));
-        $this->assertEquals(10, $el2->exportValue($values));
-        $values = ['testel' => ['number' => 10, 'timeunit' => 1, 'enabled' => 0]];
-        $this->assertEquals(['testel' => 0], $el2->exportValue($values, true));
-        $this->assertEquals(null, $el2->exportValue($values));
+        $el = $mform->addElement('duration', 'testel', $label, $optional ? ['optional' => true] : []);
+
+        // Prepare the submitted values.
+        $values = ['testel' => ['number' => $number, 'timeunit' => $unit]];
+        if ($optional) {
+            $values['testel']['enabled'] = $enabled;
+        }
+
+        // Test.
+        $this->assertEquals(['testel' => $expected], $el->exportValue($values, true));
+        $this->assertEquals($expected, $el->exportValue($values));
     }
 }
 
index ab07f4e..d4cb9ed 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-debug.js differ
index 8835642..d7fc49d 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector-min.js differ
index ab07f4e..d4cb9ed 100644 (file)
Binary files a/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js and b/lib/form/yui/build/moodle-form-dateselector/moodle-form-dateselector.js differ
index b3ba4b1..42b3967 100644 (file)
@@ -86,8 +86,8 @@ CALENDAR.prototype = {
             this.set_date_from_selects();
         }
         M.form.dateselector.currentowner = this;
-        M.form.dateselector.calendar.set('mindate', new Date(this.yearselect.firstOptionValue(), 0, 1));
-        M.form.dateselector.calendar.set('maxdate', new Date(this.yearselect.lastOptionValue(), 11, 31));
+        M.form.dateselector.calendar.set('minimumDate', new Date(this.yearselect.firstOptionValue(), 0, 1));
+        M.form.dateselector.calendar.set('maximumDate', new Date(this.yearselect.lastOptionValue(), 11, 31));
         M.form.dateselector.panel.show();
         M.form.dateselector.calendar.show();
         M.form.dateselector.fix_position();
index c4cab7d..0283ceb 100644 (file)
@@ -9789,6 +9789,39 @@ function get_performance_info() {
     return $info;
 }
 
+/**
+ * Renames a file or directory to a unique name within the same directory.
+ *
+ * This function is designed to avoid any potential race conditions, and select an unused name.
+ *
+ * @param string $filepath Original filepath
+ * @param string $prefix Prefix to use for the temporary name
+ * @return string|bool New file path or false if failed
+ * @since Moodle 3.10
+ */
+function rename_to_unused_name(string $filepath, string $prefix = '_temp_') {
+    $dir = dirname($filepath);
+    $basename = $dir . '/' . $prefix;
+    $limit = 0;
+    while ($limit < 100) {
+        // Select a new name based on a random number.
+        $newfilepath = $basename . md5(mt_rand());
+
+        // Attempt a rename to that new name.
+        if (@rename($filepath, $newfilepath)) {
+            return $newfilepath;
+        }
+
+        // The first time, do some sanity checks, maybe it is failing for a good reason and there
+        // is no point trying 100 times if so.
+        if ($limit === 0 && (!file_exists($filepath) || !is_writable($dir))) {
+            return false;
+        }
+        $limit++;
+    }
+    return false;
+}
+
 /**
  * Delete directory or only its content
  *
@@ -9801,6 +9834,19 @@ function remove_dir($dir, $contentonly=false) {
         // Nothing to do.
         return true;
     }
+
+    if (!$contentonly) {
+        // Start by renaming the directory; this will guarantee that other processes don't write to it
+        // while it is in the process of being deleted.
+        $tempdir = rename_to_unused_name($dir);
+        if ($tempdir) {
+            // If the rename was successful then delete the $tempdir instead.
+            $dir = $tempdir;
+        }
+        // If the rename fails, we will continue through and attempt to delete the directory
+        // without renaming it since that is likely to at least delete most of the files.
+    }
+
     if (!$handle = opendir($dir)) {
         return false;
     }
index fb4a29c..f324c4b 100644 (file)
@@ -270,6 +270,16 @@ function theme_reset_all_caches() {
     }
 }
 
+/**
+ * Reset static caches.
+ *
+ * This method indicates that all running cron processes should exit at the
+ * next opportunity.
+ */
+function theme_reset_static_caches() {
+    \core\task\manager::clear_static_caches();
+}
+
 /**
  * Enable or disable theme designer mode.
  *
index 99366fc..8378da8 100644 (file)
@@ -1241,9 +1241,10 @@ class moodle_page {
      * This is normally used as the main heading at the top of the content.
      *
      * @param string $heading the main heading that should be displayed at the top of the <body>.
+     * @param bool $applyformatting apply format_string() - by default true.
      */
-    public function set_heading($heading) {
-        $this->_heading = format_string($heading);
+    public function set_heading($heading, bool $applyformatting = true) {
+        $this->_heading = $applyformatting ? format_string($heading) : clean_text($heading);
     }
 
     /**
diff --git a/lib/templates/infected_file_email.mustache b/lib/templates/infected_file_email.mustache
new file mode 100644 (file)
index 0000000..f538386
--- /dev/null
@@ -0,0 +1,80 @@
+{{!
+    This file is part of Moodle - http://moodle.org/
+
+    Moodle is free software: you can redistribute it and/or modify
+    it under the terms of the GNU General Public License as published by
+    the Free Software Foundation, either version 3 of the License, or
+    (at your option) any later version.
+
+    Moodle is distributed in the hope that it will be useful,
+    but WITHOUT ANY WARRANTY; without even the implied warranty of
+    MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+    GNU General Public License for more details.
+
+    You should have received a copy of the GNU General Public License
+    along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+}}
+{{!
+    @template core/infected_file_email
+
+    Moodle template for infected files emails.
+
+    Context variables required for this template:
+    * report - Hyperlink to the report page,
+    * scanner - Scanning engine that found this file,
+    * filename - Name of the infected file,
+    * filesize - Size of the file,
+    * contenthash - File content hash,
+    * contenttype - Type of uploaded file,
+    * author - User that uploaded the file,
+    * ipaddress - IP address the file was uploaded from,
+    * geoinfo - Geo information about IP address,
+    * referer - The referring page,
+    * identityproviders - List of identiy providers,
+    * date - Date of upload
+    * notice - Notice returned from the scanning engine
+    * additionalinfo - Any additional information from the scanning engine
+
+    Example context (json):
+    {
+        "header": "Infected file detected",
+        "report": "http://example.moodle/report/infectedfiles/index.php",
+        "scanner": "antivirus_clamav",
+        "filename": "virus.txt",
+        "filesize": 100,
+        "contenthash": "3395856ce81f2b7382dee72602f798b642f14140",
+        "contenttype": "text/plain",
+        "author": "Example User (exampleuser)",
+        "ipaddress": "192.168.0.1",
+        "geoinfo": "Brisbane, Australia",
+        "referer": "http://example.moodle/user/files.php",
+        "date": "28/05/20, 11:19",
+        "notice": "Clamav scanning has tried 1 time(s). ClamAV has failed to run.",
+        "additionalinfo": "Here is the output from ClamAV: /tmp/phpElIcr2: Not a regular file ERROR"
+    }
+}}
+
+<div>
+<b>{{header}}</b>
+    <div>
+        <p><b>{{#str}} emailreport, antivirus {{/str}}</b><a href={{report}}>{{report}}</a></p>
+        <p><b>{{#str}} emailscanner, antivirus {{/str}}</b>{{scanner}}</p>
+        <br>
+        <p><b>{{#str}} emailfilename, antivirus {{/str}}</b>{{filename}}</p>
+        <p><b>{{#str}} emailfilesize, antivirus {{/str}}</b>{{filesize}}</p>
+        <p><b>{{#str}} emailcontenthash, antivirus {{/str}}</b>{{contenthash}}</p>
+        <p><b>{{#str}} emailcontenttype, antivirus {{/str}}</b>{{contenttype}}</p>
+        <p><b>{{#str}} emaildate, antivirus {{/str}}</b>{{date}}</p>
+        <br>
+        <p><b>{{#str}} emailauthor, antivirus {{/str}}</b>{{author}}</p>
+        <p><b>{{#str}} emailipaddress, antivirus {{/str}}</b>{{ipaddress}}</p>
+        <p><b>{{#str}} emailgeoinfo, antivirus {{/str}}</b>{{geoinfo}}</p>
+        <p><b>{{#str}} emailreferer, antivirus {{/str}}</b><a href={{referer}}>{{referer}}</a></p>
+    </div>
+    <div>
+        <br>
+        <pre>{{notice}}</pre>
+        <br>
+        <p><b>{{#str}} emailadditionalinfo, antivirus {{/str}} </b></p>
+    </div>
+</div>
index 9192029..96fb1ce 100644 (file)
@@ -85,6 +85,67 @@ class core_antivirus_testcase extends advanced_testcase {
         $this->assertFileNotExists($this->tempfile);
     }
 
+    public function test_manager_send_message_to_user_email_scan_file_virus() {
+        $sink = $this->redirectEmails();
+        $exception = null;
+        try {
+            set_config('notifyemail', 'fake@example.com', 'antivirus');
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $this->assertCount(1, $result);
+        $this->assertContains('fake@example.com', $result[0]->to);
+        $sink->close();
+    }
+
+    public function test_manager_send_message_to_admin_email_scan_file_virus() {
+        $sink = $this->redirectMessages();
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $admins = array_keys(get_admins());
+        $this->assertCount(1, $admins);
+        $this->assertCount(1, $result);
+        $this->assertEquals($result[0]->useridto, reset($admins));
+        $sink->close();
+    }
+
+    public function test_manager_quarantine_file_virus() {
+        try {
+            set_config('enablequarantine', true, 'antivirus');
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(1, count($quarantinedfiles));
+        // Clean up.
+        \core\antivirus\quarantine::clean_up_quarantine_folder(time());
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
+    public function test_manager_none_quarantine_file_virus() {
+        try {
+            \core\antivirus\manager::scan_file($this->tempfile, 'FOUND', true);
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
     public function test_manager_scan_data_no_virus() {
         // Run mock scanning.
         $this->assertEmpty(\core\antivirus\manager::scan_data('OK'));
@@ -100,4 +161,69 @@ class core_antivirus_testcase extends advanced_testcase {
         $this->expectException(\core\antivirus\scanner_exception::class);
         $this->assertEmpty(\core\antivirus\manager::scan_data('FOUND'));
     }
+
+    public function test_manager_send_message_to_user_email_scan_data_virus() {
+        $sink = $this->redirectEmails();
+        set_config('notifyemail', 'fake@example.com', 'antivirus');
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $this->assertCount(1, $result);
+        $this->assertContains('fake@example.com', $result[0]->to);
+        $sink->close();
+    }
+
+    public function test_manager_send_message_to_admin_email_scan_data_virus() {
+        $sink = $this->redirectMessages();
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        $result = $sink->get_messages();
+        $admins = array_keys(get_admins());
+        $this->assertCount(1, $admins);
+        $this->assertCount(1, $result);
+        $this->assertEquals($result[0]->useridto, reset($admins));
+        $sink->close();
+    }
+
+    public function test_manager_quarantine_data_virus() {
+        set_config('enablequarantine', true, 'antivirus');
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(1, count($quarantinedfiles));
+        // Clean up.
+        \core\antivirus\quarantine::clean_up_quarantine_folder(time());
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
+
+
+    public function test_manager_none_quarantine_data_virus() {
+        $exception = null;
+        try {
+            \core\antivirus\manager::scan_data('FOUND');
+        } catch (\core\antivirus\scanner_exception $ex) {
+            $exception = $ex;
+        }
+        $this->assertNotEmpty($exception);
+        // No Quarantined files.
+        $quarantinedfiles = \core\antivirus\quarantine::get_quarantined_files();
+        $this->assertEquals(0, count($quarantinedfiles));
+    }
 }
index a801a62..bd68624 100644 (file)
@@ -311,6 +311,14 @@ class core_moodle_page_testcase extends advanced_testcase {
         $this->testpage->set_heading('a heading');
         // Validated.
         $this->assertSame('a heading', $this->testpage->heading);
+
+        // By default formatting is applied and tags are removed.
+        $this->testpage->set_heading('a heading <a href="#">edit</a><p>');
+        $this->assertSame('a heading edit', $this->testpage->heading);
+
+        // Without formatting the tags are preserved but cleaned.
+        $this->testpage->set_heading('a heading <a href="#">edit</a><p>', false);
+        $this->assertSame('a heading <a href="#">edit</a><p></p>', $this->testpage->heading);
     }
 
     public function test_set_title() {
index 3f40d07..cbb7dc2 100644 (file)
@@ -4713,4 +4713,67 @@ class core_moodlelib_testcase extends advanced_testcase {
             ],
         ];
     }
+
+    /**
+     * Tests the rename_to_unused_name function with a file.
+     */
+    public function test_rename_to_unused_name_file() {
+        global $CFG;
+
+        // Create a new file in dataroot.
+        $file = $CFG->dataroot . '/argh.txt';
+        file_put_contents($file, 'Frogs');
+
+        // Rename it.
+        $newname = rename_to_unused_name($file);
+
+        // Check new name has expected format.
+        $this->assertRegExp('~/_temp_[a-f0-9]+$~', $newname);
+
+        // Check it's still in the same folder.
+        $this->assertEquals($CFG->dataroot, dirname($newname));
+
+        // Check file can be loaded.
+        $this->assertEquals('Frogs', file_get_contents($newname));
+
+        // OK, delete the file.
+        unlink($newname);
+    }
+
+    /**
+     * Tests the rename_to_unused_name function with a directory.
+     */
+    public function test_rename_to_unused_name_dir() {
+        global $CFG;
+
+        // Create a new directory in dataroot.
+        $file = $CFG->dataroot . '/arghdir';
+        mkdir($file);
+
+        // Rename it.
+        $newname = rename_to_unused_name($file);
+
+        // Check new name has expected format.
+        $this->assertRegExp('~/_temp_[a-f0-9]+$~', $newname);
+
+        // Check it's still in the same folder.
+        $this->assertEquals($CFG->dataroot, dirname($newname));
+
+        // Check it's still a directory
+        $this->assertTrue(is_dir($newname));
+
+        // OK, delete the directory.
+        rmdir($newname);
+    }
+
+    /**
+     * Tests the rename_to_unused_name function with error cases.
+     */
+    public function test_rename_to_unused_name_failure() {
+        global $CFG;
+
+        // Rename a file that doesn't exist.
+        $file = $CFG->dataroot . '/argh.txt';
+        $this->assertFalse(rename_to_unused_name($file));
+    }
 }
index 10ce44b..6b46272 100644 (file)
@@ -148,73 +148,87 @@ class core_scheduled_task_testcase extends advanced_testcase {
         $this->assertContains('2:15 AM', core_text::strtoupper($userdate));
     }
 
-    public function test_reset_scheduled_tasks_for_component() {
-        global $DB;
-
+    public function test_reset_scheduled_tasks_for_component_customised(): void {
         $this->resetAfterTest(true);
-        // Remember the defaults.
-        $defaulttasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
-        $initcount = count($defaulttasks);
+
+        $tasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
+
         // Customise a task.
-        $firsttask = reset($defaulttasks);
-        $firsttask->set_minute('1');
-        $firsttask->set_hour('2');
-        $firsttask->set_month('3');
-        $firsttask->set_day_of_week('4');
-        $firsttask->set_day('5');
-        $firsttask->set_customised('1');
-        \core\task\manager::configure_scheduled_task($firsttask);
-        $firsttaskrecord = \core\task\manager::record_from_scheduled_task($firsttask);
-        // We reset this field, because we do not want to compare it.
-        $firsttaskrecord->nextruntime = '0';
+        $task = reset($tasks);
+        $task->set_minute('1');
+        $task->set_hour('2');
+        $task->set_month('3');
+        $task->set_day_of_week('4');
+        $task->set_day('5');
+        $task->set_customised('1');
+        \core\task\manager::configure_scheduled_task($task);
+
+        // Now call reset.
+        \core\task\manager::reset_scheduled_tasks_for_component('moodle');
+
+        // Fetch the task again.
+        $taskafterreset = \core\task\manager::get_scheduled_task(get_class($task));
+
+        // The task should still be the same as the customised.
+        $this->assertTaskEquals($task, $taskafterreset);
+    }
+
+    public function test_reset_scheduled_tasks_for_component_deleted(): void {
+        global $DB;
+        $this->resetAfterTest(true);
 
         // Delete a task to simulate the fact that its new.
-        $secondtask = next($defaulttasks);
-        $DB->delete_records('task_scheduled', array('classname' => '\\' . trim(get_class($secondtask), '\\')));
-        $this->assertFalse(\core\task\manager::get_scheduled_task(get_class($secondtask)));
+        $tasklist = \core\task\manager::load_scheduled_tasks_for_component('moodle');
 
-        // Edit a task to simulate a change in its definition (as if it was not customised).
-        $thirdtask = next($defaulttasks);
-        $thirdtask->set_minute('1');
-        $thirdtask->set_hour('2');
-        $thirdtask->set_month('3');
-        $thirdtask->set_day_of_week('4');
-        $thirdtask->set_day('5');
-        $thirdtaskbefore = \core\task\manager::get_scheduled_task(get_class($thirdtask));
-        $thirdtaskbefore->set_next_run_time(null);      // Ignore this value when comparing.
-        \core\task\manager::configure_scheduled_task($thirdtask);
-        $thirdtask = \core\task\manager::get_scheduled_task(get_class($thirdtask));
-        $thirdtask->set_next_run_time(null);            // Ignore this value when comparing.
-        $this->assertNotEquals($thirdtaskbefore, $thirdtask);
+        // Note: This test must use a task which does not use any random values.
+        $task = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        $DB->delete_records('task_scheduled', array('classname' => '\\' . trim(get_class($task), '\\')));
+        $this->assertFalse(\core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class));
 
         // Now call reset on all the tasks.
         \core\task\manager::reset_scheduled_tasks_for_component('moodle');
 
-        // Load the tasks again.
-        $defaulttasks = \core\task\manager::load_scheduled_tasks_for_component('moodle');
-        $finalcount = count($defaulttasks);
-        // Compare the first task.
-        $newfirsttask = reset($defaulttasks);
-        $newfirsttaskrecord = \core\task\manager::record_from_scheduled_task($newfirsttask);
-        // We reset this field, because we do not want to compare it.
-        $newfirsttaskrecord->nextruntime = '0';
+        // Assert that the second task was added back.
+        $taskafterreset = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+        $this->assertNotFalse($taskafterreset);
 
-        // Assert a customised task was not altered by reset.
-        $this->assertEquals($firsttaskrecord, $newfirsttaskrecord);
+        $this->assertTaskEquals($task, $taskafterreset);
+        $this->assertCount(count($tasklist), \core\task\manager::load_scheduled_tasks_for_component('moodle'));
+    }
 
-        // Assert that the second task was added back.
-        $secondtaskafter = \core\task\manager::get_scheduled_task(get_class($secondtask));
-        $secondtaskafter->set_next_run_time(null);   // Do not compare the nextruntime.
-        $secondtask->set_next_run_time(null);
-        $this->assertEquals($secondtask, $secondtaskafter);
-
-        // Assert that the third task edits were overridden.
-        $thirdtaskafter = \core\task\manager::get_scheduled_task(get_class($thirdtask));
-        $thirdtaskafter->set_next_run_time(null);
-        $this->assertEquals($thirdtaskbefore, $thirdtaskafter);
-
-        // Assert we have the same number of tasks.
-        $this->assertEquals($initcount, $finalcount);
+    public function test_reset_scheduled_tasks_for_component_changed_in_source(): void {
+        $this->resetAfterTest(true);
+
+        // Delete a task to simulate the fact that its new.
+        // Note: This test must use a task which does not use any random values.
+        $task = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // Get a copy of the task before maing changes for later comparison.
+        $taskbeforechange = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // Edit a task to simulate a change in its definition (as if it was not customised).
+        $task->set_minute('1');
+        $task->set_hour('2');
+        $task->set_month('3');
+        $task->set_day_of_week('4');
+        $task->set_day('5');
+        \core\task\manager::configure_scheduled_task($task);
+
+        // Fetch the task out for comparison.
+        $taskafterchange = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // The task should now be different to the original.
+        $this->assertTaskNotEquals($taskbeforechange, $taskafterchange);
+
+        // Now call reset.
+        \core\task\manager::reset_scheduled_tasks_for_component('moodle');
+
+        // Fetch the task again.
+        $taskafterreset = \core\task\manager::get_scheduled_task(core\task\session_cleanup_task::class);
+
+        // The task should now be the same as the original.
+        $this->assertTaskEquals($taskbeforechange, $taskafterreset);
     }
 
     /**
@@ -502,4 +516,56 @@ class core_scheduled_task_testcase extends advanced_testcase {
         $this->assertEquals(0, $task->get_fail_delay());
         $this->assertLessThan($before + 70, $task->get_next_run_time());
     }
+
+    /**
+     * Assert that the specified tasks are equal.
+     *
+     * @param   \core\task\task_base $task
+     * @param   \core\task\task_base $comparisontask
+     */
+    public function assertTaskEquals(\core\task\task_base $task, \core\task\task_base $comparisontask): void {
+        // Convert both to an object.
+        $task = \core\task\manager::record_from_scheduled_task($task);
+        $comparisontask = \core\task\manager::record_from_scheduled_task($comparisontask);
+
+        // Reset the nextruntime field as it is intentionally dynamic.
+        $task->nextruntime = null;
+        $comparisontask->nextruntime = null;
+
+        $args = array_merge(
+            [
+                $task,
+                $comparisontask,
+            ],
+            array_slice(func_get_args(), 2)
+        );
+
+        call_user_func_array([$this, 'assertEquals'], $args);
+    }
+
+    /**
+     * Assert that the specified tasks are not equal.
+     *
+     * @param   \core\task\task_base $task
+     * @param   \core\task\task_base $comparisontask
+     */
+    public function assertTaskNotEquals(\core\task\task_base $task, \core\task\task_base $comparisontask): void {
+        // Convert both to an object.
+        $task = \core\task\manager::record_from_scheduled_task($task);
+        $comparisontask = \core\task\manager::record_from_scheduled_task($comparisontask);
+
+        // Reset the nextruntime field as it is intentionally dynamic.
+        $task->nextruntime = null;
+        $comparisontask->nextruntime = null;
+
+        $args = array_merge(
+            [
+                $task,
+                $comparisontask,
+            ],
+            array_slice(func_get_args(), 2)
+        );
+
+        call_user_func_array([$this, 'assertNotEquals'], $args);
+    }
 }
diff --git a/lib/tests/task_running_test.php b/lib/tests/task_running_test.php
new file mode 100644 (file)
index 0000000..7aa548e
--- /dev/null
@@ -0,0 +1,166 @@
+<?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/>.
+
+/**
+ * This file contains unit tests for the 'task running' data.
+ *
+ * @package core
+ * @copyright 2019 The Open University
+ * @copyright 2020 Mikhail Golenkov <golenkovm@gmail.com>
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+require_once(__DIR__ . '/fixtures/task_fixtures.php');
+
+/**
+ * This file contains unit tests for the 'task running' data.
+ *
+ * @copyright 2019 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class task_running_testcase extends \advanced_testcase {
+
+    /**
+     * Test for ad-hoc tasks.
+     */
+    public function test_adhoc_task_running() {
+        $this->resetAfterTest();
+
+        // Specify lock factory. The reason is that Postgres locks don't work within a single
+        // process (i.e. if you try to get a lock that you already locked, it will just let you)
+        // which is usually OK but not here where we are simulating running two tasks at once in
+        // the same process.
+        set_config('lock_factory', '\core\lock\db_record_lock_factory');
+
+        // Create and queue 2 new ad-hoc tasks.
+        $task1 = new adhoc_test_task();
+        $task1->set_next_run_time(time() - 20);
+        manager::queue_adhoc_task($task1);
+        $task2 = new adhoc_test2_task();
+        $task2->set_next_run_time(time() - 10);
+        manager::queue_adhoc_task($task2);
+
+        // Check no tasks are marked running.
+        $running = manager::get_running_tasks();
+        $this->assertEmpty($running);
+
+        // Mark the first task running and check results.
+        $before = time();
+        $next1 = manager::get_next_adhoc_task(time());
+        manager::adhoc_task_starting($next1);
+        $after = time();
+        $running = manager::get_running_tasks();
+        $this->assertCount(1, $running);
+        foreach ($running as $item) {
+            $this->assertEquals('adhoc', $item->type);
+            $this->assertLessThanOrEqual($after, $item->timestarted);
+            $this->assertGreaterThanOrEqual($before, $item->timestarted);
+        }
+
+        // Mark the second task running and check results.
+        $next2 = manager::get_next_adhoc_task(time());
+        manager::adhoc_task_starting($next2);
+        $running = manager::get_running_tasks();
+        $this->assertCount(2, $running);
+
+        // Second task completes successfully.
+        manager::adhoc_task_complete($next2);
+        $running = manager::get_running_tasks();
+        $this->assertCount(1, $running);
+
+        // First task fails.
+        manager::adhoc_task_failed($next1);
+        $running = manager::get_running_tasks();
+        $this->assertCount(0, $running);
+    }
+
+    /**
+     * Test for scheduled tasks.
+     */
+    public function test_scheduled_task_running() {
+        global $DB;
+        $this->resetAfterTest();
+
+        // Check no tasks are marked running.
+        $running = manager::get_running_tasks();
+        $this->assertEmpty($running);
+
+        // Disable all the tasks, except two, and set those two due to run.
+        $DB->set_field_select('task_scheduled', 'disabled', 1, 'classname != ? AND classname != ?',
+                ['\core\task\session_cleanup_task', '\core\task\file_trash_cleanup_task']);
+        $DB->set_field('task_scheduled', 'nextruntime', 1,
+                ['classname' => '\core\task\session_cleanup_task']);
+        $DB->set_field('task_scheduled', 'nextruntime', 1,
+                ['classname' => '\core\task\file_trash_cleanup_task']);
+        $DB->set_field('task_scheduled', 'lastruntime', time() - 1000,
+                ['classname' => '\core\task\session_cleanup_task']);
+        $DB->set_field('task_scheduled', 'lastruntime', time() - 500,
+                ['classname' => '\core\task\file_trash_cleanup_task']);
+
+        // Get the first task and start it off.
+        $next1 = manager::get_next_scheduled_task(time());
+        $before = time();
+        manager::scheduled_task_starting($next1);
+        $after = time();
+        $running = manager::get_running_tasks();
+        $this->assertCount(1, $running);
+        foreach ($running as $item) {
+            $this->assertLessThanOrEqual($after, $item->timestarted);
+            $this->assertGreaterThanOrEqual($before, $item->timestarted);
+            $this->assertEquals('\core\task\session_cleanup_task', $item->classname);
+        }
+
+        // Mark the second task running and check results. We have to change the times so the other
+        // one comes up first, otherwise it repeats the same one.
+        $DB->set_field('task_scheduled', 'lastruntime', time() - 1500,
+                ['classname' => '\core\task\file_trash_cleanup_task']);
+
+        // Make sure that there is a time gap between task to sort them as expected.
+        sleep(1);
+        $next2 = manager::get_next_scheduled_task(time());
+        manager::scheduled_task_starting($next2);
+
+        // Check default sorting by timestarted.
+        $running = manager::get_running_tasks();
+        $this->assertCount(2, $running);
+        $item = array_shift($running);
+        $this->assertEquals('\core\task\session_cleanup_task', $item->classname);
+        $item = array_shift($running);
+        $this->assertEquals('\core\task\file_trash_cleanup_task', $item->classname);
+
+        // Check sorting by time ASC.
+        $running = manager::get_running_tasks('time ASC');
+        $this->assertCount(2, $running);
+        $item = array_shift($running);
+        $this->assertEquals('\core\task\file_trash_cleanup_task', $item->classname);
+        $item = array_shift($running);
+        $this->assertEquals('\core\task\session_cleanup_task', $item->classname);
+
+        // Complete the file trash one.
+        manager::scheduled_task_complete($next2);
+        $running = manager::get_running_tasks();
+        $this->assertCount(1, $running);
+
+        // Other task fails.
+        manager::scheduled_task_failed($next1);
+        $running = manager::get_running_tasks();
+        $this->assertCount(0, $running);
+    }
+}
index 60a675e..0df4336 100644 (file)
@@ -1,7 +1,7 @@
 This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
-=== 4.0 ===
+=== 3.10 ===
 * Added function setScrollable in core/modal. This function can be used to set the modal's body to be scrollable or not
   when the modal's height exceeds the browser's height. This is also supported in core/modal_factory through the
   'scrollable' config parameter which can be set to either true or false. If not explicitly defined, the default value
@@ -36,6 +36,10 @@ information provided here is intended especially for developers.
   a callback function instead of an array of options.
 * Admin setting admin_setting_configselect now supports validating the selection by supplying a
   callback function.
+* The task system has new functions adhoc_task_starting() and scheduled_task_starting() which must
+  be called before executing a task, and a new function \core\task\manager::get_running_tasks()
+  returns information about currently-running tasks.
+* New library function rename_to_unused_name() to rename a file within its current location.
 
 === 3.9 ===
 * Following function has been deprecated, please use \core\task\manager::run_from_cli().
index 01d9a33..3cbe9f7 100644 (file)
@@ -59,33 +59,53 @@ class assignfeedback_file_zip_importer {
             return false;
         }
 
-        $info = explode('_', $fileinfo->get_filepath() . $fileinfo->get_filename(), 5);
+        // Break the full path-name into path parts.
+        $pathparts = explode('/', $fileinfo->get_filepath() . $fileinfo->get_filename());
 
-        if (count($info) < 5) {
-            return false;
-        }
+        while (!empty($pathparts)) {
+            // Get the next path part and break it up by underscores.
+            $pathpart = array_shift($pathparts);
+            $info = explode('_', $pathpart, 5);
 
-        $participantid = $info[1];
-        $filename = $info[4];
-        $plugin = $assignment->get_plugin_by_type($info[2], $info[3]);
+            if (count($info) < 5) {
+                continue;
+            }
 
-        if (!is_numeric($participantid)) {
-            return false;
-        }
+            // Check the participant id.
+            $participantid = $info[1];
 
-        if (!$plugin) {
-            return false;
-        }
+            if (!is_numeric($participantid)) {
+                continue;
+            }
 
-        // Convert to int.
-        $participantid += 0;
+            // Convert to int.
+            $participantid += 0;
 
-        if (empty($participants[$participantid])) {
-            return false;
+            if (empty($participants[$participantid])) {
+                continue;
+            }
+
+            // Set user, which is by reference, so is used by the calling script.
+            $user = $participants[$participantid];
+
+            // Set the plugin. This by reference, and is used by the calling script.
+            $plugin = $assignment->get_plugin_by_type($info[2], $info[3]);
+
+            if (!$plugin) {
+                continue;
+            }
+
+            // Take any remaining text in this part and put it back in the path parts array.
+            array_unshift($pathparts, $info[4]);
+
+            // Combine the remaining parts and set it as the filename.
+            // Note that filename is a 'by reference' variable, so we need to set it before returning.
+            $filename = implode('/', $pathparts);
+
+            return true;
         }
 
-        $user = $participants[$participantid];
-        return true;
+        return false;
     }
 
     /**
diff --git a/mod/assign/feedback/file/tests/importziplib_test.php b/mod/assign/feedback/file/tests/importziplib_test.php
new file mode 100644 (file)
index 0000000..009579b
--- /dev/null
@@ -0,0 +1,148 @@
+<?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 importziplib.
+ *
+ * @package    assignfeedback_file
+ * @copyright  2020 Eric Merrill <merrill@oakland.edu>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+global $CFG;
+require_once($CFG->dirroot . '/mod/assign/tests/generator.php');
+require_once($CFG->dirroot . '/mod/assign/feedback/file/importziplib.php');
+
+/**
+ * Unit tests for importziplib.
+ *
+ * @copyright  2020 Eric Merrill <merrill@oakland.edu>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class assignfeedback_importziplib_testcase extends advanced_testcase {
+
+    // Use the generator helper.
+    use mod_assign_test_generator;
+
+    /**
+     * Test the assignfeedback_file_zip_importer->is_valid_filename_for_import() method.
+     */
+    public function test_is_valid_filename_for_import() {
+        // Do the initial assign setup.
+        $this->resetAfterTest();
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $assign = $this->create_instance($course, [
+                'assignsubmission_onlinetext_enabled' => 1,
+                'assignfeedback_file_enabled' => 1,
+            ]);
+
+        // Create an online text submission.
+        $this->add_submission($student, $assign);
+
+        // Now onto the file work.
+        $fs = get_file_storage();
+
+        // Setup a basic file we will work with. We will keep renaming and repathing it.
+        $record = new stdClass;
+        $record->contextid = $assign->get_context()->id;
+        $record->component = 'assignfeedback_file';
+        $record->filearea  = ASSIGNFEEDBACK_FILE_FILEAREA;
+        $record->itemid    = $assign->get_user_grade($student->id, true)->id;
+        $record->filepath  = '/';
+        $record->filename  = '1.txt';
+        $record->source    = 'test';
+        $file = $fs->create_file_from_string($record, 'file content');
+
+        // The importer we will use.
+        $importer = new assignfeedback_file_zip_importer();
+
+        // Setup some variable we use.
+        $user = null;
+        $plugin = null;
+        $filename = '';
+
+        $allusers = $assign->list_participants(0, false);
+        $participants = array();
+        foreach ($allusers as $user) {
+            $participants[$assign->get_uniqueid_for_user($user->id)] = $user;
+        }
+
+        $file->rename('/import/', '.hiddenfile');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        $file->rename('/import/', '~hiddenfile');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        $file->rename('/import/some_path_here/', 'RandomFile.txt');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        $file->rename('/import/', '~hiddenfile');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        // Get the students assign id.
+        $studentid = $assign->get_uniqueid_for_user($student->id);
+
+        // Submissions are identified with the format:
+        // StudentName_StudentID_PluginType_Plugin_FilePathAndName.
+
+        // Test a string student id.
+        $badname = 'Student Name_StringID_assignsubmission_file_My_cool_filename.txt';
+        $file->rename('/import/', $badname);
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        // Test an invalid student id.
+        $badname = 'Student Name_' . ($studentid + 100) . '_assignsubmission_file_My_cool_filename.txt';
+        $file->rename('/import/', $badname);
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        // Test an invalid submission plugin.
+        $badname = 'Student Name_' . $studentid . '_assignsubmission_noplugin_My_cool_filename.txt';
+        $file->rename('/import/', $badname);
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertFalse($result);
+
+        // Test a basic, good file.
+        $goodbase = 'Student Name_' . $studentid . '_assignsubmission_file_';
+        $file->rename('/import/', $goodbase . "My_cool_filename.txt");
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertTrue($result);
+        $this->assertEquals($participants[$studentid], $user);
+        $this->assertEquals('My_cool_filename.txt', $filename);
+        $this->assertInstanceOf(assign_submission_file::class, $plugin);
+
+        // Test another good file, with some additional path and underscores.
+        $user = null;
+        $plugin = null;
+        $filename = '';
+        $file->rename('/import/some_path_here/' . $goodbase . '/some_path/', 'My File.txt');
+        $result = $importer->is_valid_filename_for_import($assign, $file, $participants, $user, $plugin, $filename);
+        $this->assertTrue($result);
+        $this->assertEquals($participants[$studentid], $user);
+        $this->assertEquals('/some_path/My File.txt', $filename);
+        $this->assertInstanceOf(assign_submission_file::class, $plugin);
+    }
+}
index 8601148..eba9b28 100644 (file)
@@ -145,7 +145,7 @@ $string['savemychoice'] = 'Save my choice';
 $string['search:activity'] = 'Choice - activity information';
 $string['selectalloption'] = 'Select all "{$a}"';
 $string['showavailable'] = 'Show available spaces';
-$string['showavailable_help'] = 'Allow students to see how many available spaces there are per option.';
+$string['showavailable_help'] = 'Show participants the limit for each option and the number of responses for it so far.';
 $string['showpreview'] = 'Show preview';
 $string['showpreview_help'] = 'Allow students to preview the available options before the choice is opened for submission.';
 $string['showunanswered'] = 'Show column for unanswered';
index 997176c..36dd037 100644 (file)
@@ -399,7 +399,7 @@ $string['numberofpagesviewed'] = 'Number of questions answered: {$a}';
 $string['numberofpagesviewedheader'] = 'Number of questions answered';
 $string['numberofpagesviewednotice'] = 'Number of questions answered: {$a->nquestions} (You should answer at least {$a->minquestions})';
 $string['numerical'] = 'Numerical';
-$string['numericanswer_help'] = 'You can specify a number, or a range of numbers by using colon. For example 2:5 means any answer between 2 and 5 including them are correct.';
+$string['numericanswer_help'] = 'You can specify a single number, or a range of numbers by using colon. For example 2:5 means any answer between 2 and 5 and including 2 and 5 is correct.';
 $string['numericanswer'] = 'Numeric answer';
 $string['offlinedatamessage'] = 'You have worked on this attempt using a mobile device. Data was last saved to this site {$a} ago. Please check that you do not have any unsaved work.';
 $string['ongoing'] = 'Display ongoing score';
index 5bdceb5..5775363 100644 (file)
@@ -145,9 +145,35 @@ if (($launchcontainer == LTI_LAUNCH_CONTAINER_WINDOW) &&
         $content = lti_initiate_login($cm->course, $id, $lti, $config);
     }
 
+    // Build the allowed URL, since we know what it will be from $lti->toolurl,
+    // If the specified toolurl is invalid the iframe won't load, but we still want to avoid parse related errors here.
+    // So we set an empty default allowed url, and only build a real one if the parse is successful.
+    $ltiallow = '';
+    $urlparts = parse_url($lti->toolurl);
+    if ($urlparts && array_key_exists('scheme', $urlparts) && array_key_exists('host', $urlparts)) {
+        $ltiallow = $urlparts['scheme'] . '://' . $urlparts['host'];
+        // If a port has been specified we append that too.
+        if (array_key_exists('port', $urlparts)) {
+            $ltiallow .= ':' . $urlparts['port'];
+        }
+    }
+
     // Request the launch content with an iframe tag.
-    echo '<iframe id="contentframe" height="600px" width="100%" src="launch.php?id=' . $cm->id .
-         "&triggerview=0\" webkitallowfullscreen mozallowfullscreen allowfullscreen>{$content}</iframe>";
+    $attributes = [];
+    $attributes['id'] = "contentframe";
+    $attributes['height'] = '600px';
+    $attributes['width'] = '100%';
+    $attributes['src'] = 'launch.php?id=' . $cm->id . '&triggerview=0';
+    $attributes['allow'] = "microphone $ltiallow; " .
+        "camera $ltiallow; " .
+        "geolocation $ltiallow; " .
+        "midi $ltiallow; " .
+        "encrypted-media $ltiallow; " .
+        "autoplay $ltiallow";
+    $attributes['allowfullscreen'] = 1;
+    $iframehtml = html_writer::tag('iframe', $content, $attributes);
+    echo $iframehtml;
+
 
     // Output script to make the iframe tag be as large as possible.
     $resize = '
index c030e3d..68689f0 100644 (file)
Binary files a/pix/i/completion-auto-enabled.png and b/pix/i/completion-auto-enabled.png differ
index 43f2df4..0ee649a 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2zm13.7-1.1l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#999"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H11Zm3,8h2V6.4l-2,2Zm0,1v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5ZM15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#949494"/></svg>
\ No newline at end of file
index c2766ac..badd1f0 100644 (file)
Binary files a/pix/i/completion-auto-fail.png and b/pix/i/completion-auto-fail.png differ
index f437d97..1929dfc 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c1.1 0 2 .9 2 2v1h2V2c0-1.1-.9-2-2-2h-3v2zm5 4h-2v4h2V6zm-2 5v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#999"/><path d="M10.2 8l2.6-2.6c.4-.4.4-1 0-1.4l-.8-.7c-.4-.4-1-.4-1.4 0L8 5.9 5.4 3.3c-.4-.4-1-.4-1.4 0l-.7.7c-.4.4-.4 1 0 1.4L5.9 8l-2.6 2.6c-.3.4-.3 1 0 1.4l.7.7c.4.4 1 .4 1.4 0L8 10.2l2.5 2.5c.4.4 1 .4 1.4 0l.7-.7c.4-.4.4-1 0-1.4L10.2 8z" fill="#FF403C"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,2,2V5h2V2a2,2,0,0,0-2-2H11Zm5,4H14v4h2Zm-2,5v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#949494"/><path d="M10.2,8l2.6-2.6a1,1,0,0,0,0-1.4L12,3.3a1,1,0,0,0-1.4,0L8,5.9,5.4,3.3A1,1,0,0,0,4,3.3L3.3,4a1,1,0,0,0,0,1.4L5.9,8,3.3,10.6a1.2,1.2,0,0,0,0,1.4l.7.7a1,1,0,0,0,1.4,0L8,10.2l2.5,2.5a1,1,0,0,0,1.4,0l.7-.7a1,1,0,0,0,0-1.4Z" fill="#ff403c"/></svg>
\ No newline at end of file
index c93c86b..01419b9 100644 (file)
Binary files a/pix/i/completion-auto-n.png and b/pix/i/completion-auto-n.png differ
index 56f0b03..e5d4138 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c1.1 0 2 .9 2 2v1h2V2c0-1.1-.9-2-2-2h-3v2zm5 4h-2v4h2V6zm-2 5v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#999"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,2,2V5h2V2a2,2,0,0,0-2-2H11Zm5,4H14v4h2Zm-2,5v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#949494"/></svg>
\ No newline at end of file
index f7384ad..0e88881 100644 (file)
Binary files a/pix/i/completion-auto-pass.png and b/pix/i/completion-auto-pass.png differ
index f644de7..9b91a5f 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#999"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#9C3"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H11Zm3,8h2V6.4l-2,2Zm0,1v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#949494"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#79a128"/></svg>
\ No newline at end of file
index aeca4ed..7022a81 100644 (file)
Binary files a/pix/i/completion-auto-y-override.png and b/pix/i/completion-auto-y-override.png differ
index 13cf5d7..c849f00 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#FF2727"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H11Zm3,8h2V6.4l-2,2Zm0,1v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#ff2727"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#6393ee"/></svg>
\ No newline at end of file
index a5440d5..349f8bf 100644 (file)
Binary files a/pix/i/completion-auto-y.png and b/pix/i/completion-auto-y.png differ
index c734b49..9f2809f 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M10 0v2H6V0h4zm1 2h1c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2h-3v2zm3 8h2V6.4l-2 2V10zm0 1v1c0 1.1-.9 2-2 2h-1v2h3c1.1 0 2-.9 2-2v-3h-2zm-4 5v-2H6v2h4zm-5-2H4c-1.1 0-2-.9-2-2v-1H0v3c0 1.1.9 2 2 2h3v-2zm-5-4h2V6H0v4zm2-5V4c0-1.1.9-2 2-2h1V0H2C.9 0 0 .9 0 2v3h2z" fill="#999"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M10,0V2H6V0Zm1,2h1a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H11Zm3,8h2V6.4l-2,2Zm0,1v1a2,2,0,0,1-2,2H11v2h3a2,2,0,0,0,2-2V11Zm-4,5V14H6v2ZM5,14H4a2,2,0,0,1-2-2V11H0v3a2,2,0,0,0,2,2H5ZM0,10H2V6H0ZM2,5V4A2,2,0,0,1,4,2H5V0H2A2,2,0,0,0,0,2V5Z" fill="#949494"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#6393ee"/></svg>
\ No newline at end of file
index 1850d22..2b8389f 100644 (file)
Binary files a/pix/i/completion-manual-enabled.png and b/pix/i/completion-manual-enabled.png differ
index c415b90..dd45c3f 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M16 6.4V14c0 1.1-.9 2-2 2H2c-1.1 0-2-.9-2-2V2C0 .9.9 0 2 0h12c1.1 0 2 .9 2 2v.8l-.3-.3c-.4-.4-.9-.6-1.4-.6-.4 0-.9.1-1.2.4-.3-.2-.7-.3-1.1-.3H4c-1.1 0-2 .9-2 2v8c0 1.1.9 2 2 2h8c1.1 0 2-.9 2-2V8.4l2-2zm-.3-2.5l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#999"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M16,6.4V14a2,2,0,0,1-2,2H2a2,2,0,0,1-2-2V2A2,2,0,0,1,2,0H14a2,2,0,0,1,2,2v.8l-.3-.3a2,2,0,0,0-1.4-.6,1.75,1.75,0,0,0-1.2.4A2,2,0,0,0,12,2H4A2,2,0,0,0,2,4v8a2,2,0,0,0,2,2h8a2,2,0,0,0,2-2V8.4Zm-.3-2.5L15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#949494"/></svg>
\ No newline at end of file
index 79e5c71..f783abf 100644 (file)
Binary files a/pix/i/completion-manual-n.png and b/pix/i/completion-manual-n.png differ
index 19785de..5c8d283 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M14 0H2C.9 0 0 .9 0 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V2c0-1.1-.9-2-2-2zm0 12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h8c1.1 0 2 .9 2 2v8z" fill="#999"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M14,0H2A2,2,0,0,0,0,2V14a2,2,0,0,0,2,2H14a2,2,0,0,0,2-2V2A2,2,0,0,0,14,0Zm0,12a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a2,2,0,0,1,2,2Z" fill="#949494"/></svg>
\ No newline at end of file
index bdbc46b..78d5b86 100644 (file)
Binary files a/pix/i/completion-manual-y-override.png and b/pix/i/completion-manual-y-override.png differ
index 69270ba..6ff1757 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M14 8.4V12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h8c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2H2C.9 0 0 .9 0 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6.4l-2 2z" fill="#FF2727"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M14,8.4V12a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H2A2,2,0,0,0,0,2V14a2,2,0,0,0,2,2H14a2,2,0,0,0,2-2V6.4Z" fill="#ff2727"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#6393ee"/></svg>
\ No newline at end of file
index d1c85ee..7c79094 100644 (file)
Binary files a/pix/i/completion-manual-y.png and b/pix/i/completion-manual-y.png differ
index 8125e80..b2a39cd 100644 (file)
@@ -1,3 +1 @@
-<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [\r
-       <!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">\r
-]><svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16" preserveAspectRatio="xMinYMid meet" overflow="visible"><path d="M14 8.4V12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V4c0-1.1.9-2 2-2h8c.4 0 .8.1 1.1.3.3-.3.8-.4 1.2-.4.5 0 1 .2 1.4.6l.3.3V2c0-1.1-.9-2-2-2H2C.9 0 0 .9 0 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2V6.4l-2 2z" fill="#999"/><path d="M15.7 3.9l-.7-.7c-.4-.4-1-.4-1.4 0l-6 6L5.4 7c-.4-.3-1-.3-1.4 0l-.7.7c-.4.4-.4 1 0 1.4l3.6 3.6c.4.4 1 .4 1.4 0l7.4-7.4c.4-.4.4-1 0-1.4z" fill="#76A1F0"/></svg>
\ No newline at end of file
+<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path d="M14,8.4V12a2,2,0,0,1-2,2H4a2,2,0,0,1-2-2V4A2,2,0,0,1,4,2h8a2,2,0,0,1,1.1.3,1.75,1.75,0,0,1,1.2-.4,2,2,0,0,1,1.4.6l.3.3V2a2,2,0,0,0-2-2H2A2,2,0,0,0,0,2V14a2,2,0,0,0,2,2H14a2,2,0,0,0,2-2V6.4Z" fill="#949494"/><path d="M15.7,3.9,15,3.2a1,1,0,0,0-1.4,0l-6,6L5.4,7A1.2,1.2,0,0,0,4,7l-.7.7a1,1,0,0,0,0,1.4l3.6,3.6a1,1,0,0,0,1.4,0l7.4-7.4A1,1,0,0,0,15.7,3.9Z" fill="#6293ee"/></svg>
\ No newline at end of file
index e29055e..5c5e7be 100644 (file)
@@ -1,6 +1,6 @@
 This files describes API changes for question behaviour plugins.
 
-=== 4.0 ===
+=== 3.10 ===
 
 1) The slot parameter of method M.core_question_engine.init_submit_button now removed.
    The method will get the unique id by using the 'Check' button element.
diff --git a/report/infectedfiles/classes/output/renderer.php b/report/infectedfiles/classes/output/renderer.php
new file mode 100644 (file)
index 0000000..a820166
--- /dev/null
@@ -0,0 +1,55 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Infected file report renderer
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace report_infectedfiles\output;
+use report_infectedfiles\table\infectedfiles_table;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Infected file report renderer
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class renderer extends \plugin_renderer_base {
+
+    /**
+     * Render the table
+     *
+     * @param infectedfiles_table $table table of infected files
+     * @return false|string return html code of the table
+     * @throws \coding_exception
+     * @throws \moodle_exception
+     */
+    protected function render_infectedfiles_table(infectedfiles_table $table) {
+        ob_start();
+        $table->display($table->pagesize, false);
+        $o = ob_get_contents();
+        ob_end_clean();
+        return $o;
+    }
+}
diff --git a/report/infectedfiles/classes/privacy/provider.php b/report/infectedfiles/classes/privacy/provider.php
new file mode 100644 (file)
index 0000000..3631c1d
--- /dev/null
@@ -0,0 +1,172 @@
+<?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/>.
+
+/**
+ * Infected file report
+ *
+ * @package    report_infectedfiles
+ * @author     Nathan Nguyen <nathannguyen@catalyst-au.net>
+ * @copyright  Catalyst IT
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+namespace report_infectedfiles\privacy;
+
+use core_privacy\local\metadata\collection;
+use core_privacy\local\request;
+