Merge branch 'MDL-66625-master' of git://github.com/rezaies/moodle
authorJun Pataleta <jun@moodle.com>
Thu, 17 Oct 2019 22:39:01 +0000 (06:39 +0800)
committerJun Pataleta <jun@moodle.com>
Thu, 17 Oct 2019 22:39:01 +0000 (06:39 +0800)
44 files changed:
admin/tool/uploaduser/example.csv [new file with mode: 0644]
admin/tool/uploaduser/lang/en/tool_uploaduser.php
admin/tool/uploaduser/user_form.php
blocks/timeline/templates/event-list-content.mustache
config-dist.php
lib/classes/useragent.php
lib/form/dateselector.php
lib/form/datetimeselector.php
lib/formslib.php
lib/tests/core_media_player_native.php [new file with mode: 0644]
lib/tests/fixtures/testable_core_media_player.php [new file with mode: 0644]
lib/tests/fixtures/testable_core_media_player_native.php [new file with mode: 0644]
lib/tests/medialib_test.php
media/player/html5audio/tests/player_test.php
media/player/html5video/tests/player_test.php
mod/data/import.php
mod/data/lib.php
mod/data/tests/fixtures/test_data_import.csv [new file with mode: 0644]
mod/data/tests/fixtures/test_data_import_with_field_username.csv [new file with mode: 0644]
mod/data/tests/fixtures/test_data_import_with_userdata.csv [new file with mode: 0644]
mod/data/tests/import_test.php [new file with mode: 0644]
mod/forum/backup/moodle2/restore_forum_stepslib.php
mod/forum/classes/local/data_mappers/legacy/post.php
mod/forum/classes/local/entities/post.php
mod/forum/classes/local/exporters/post.php
mod/forum/classes/local/factories/entity.php
mod/forum/classes/task/refresh_forum_post_counts.php [new file with mode: 0644]
mod/forum/db/install.xml
mod/forum/db/upgrade.php
mod/forum/lib.php
mod/forum/report/summary/classes/summary_table.php
mod/forum/report/summary/lang/en/forumreport_summary.php
mod/forum/tests/entities_discussion_summary_test.php
mod/forum/tests/entities_discussion_test.php
mod/forum/tests/entities_post_read_receipt_collection_test.php
mod/forum/tests/entities_post_test.php
mod/forum/tests/externallib_test.php
mod/forum/tests/generator/lib.php
mod/forum/upgrade.txt
mod/forum/version.php
mod/quiz/report/attemptsreport_table.php
mod/quiz/report/overview/report.php
mod/quiz/report/responses/report.php
mod/quiz/report/upgrade.txt

diff --git a/admin/tool/uploaduser/example.csv b/admin/tool/uploaduser/example.csv
new file mode 100644 (file)
index 0000000..355c3e1
--- /dev/null
@@ -0,0 +1,4 @@
+username,firstname,lastname,email
+student1,Student,One,s1@example.com
+student2,Student,Two,s2@example.com
+student3,Student,Three,s3@example.com
\ No newline at end of file
index e94ba5e..f0903ae 100644 (file)
@@ -33,6 +33,10 @@ $string['deleteerrors'] = 'Delete errors';
 $string['encoding'] = 'Encoding';
 $string['errormnetadd'] = 'Can not add remote users';
 $string['errors'] = 'Errors';
+$string['examplecsv'] = 'Example text file';
+$string['examplecsv_help'] = 'To use the example text file, download it then open it with a text or spreadsheet editor. Leave the first line unchanged, then edit the following lines (records) and add your user data, adding more lines as necessary. Save the file as CSV then upload it.
+
+The example text file may also be used for testing, as you are able to preview user data and can choose to cancel the action before user accounts are created.';
 $string['invalidupdatetype'] = 'This option cannot be selected with the chosen upload type.';
 $string['invaliduserdata'] = 'Invalid data detected for user {$a} and it has been automatically cleaned.';
 $string['invalidtheme'] = 'Theme "{$a}" is not installed and will be ignored.';
@@ -63,7 +67,9 @@ $string['uploadusers_help'] = 'Users may be uploaded (and optionally enrolled in
 * Each line of the file contains one record
 * Each record is a series of data separated by commas (or other delimiters)
 * The first record contains a list of fieldnames defining the format of the rest of the file
-* Required fieldnames are username, password, firstname, lastname, email';
+* Required fieldnames are username, password, firstname, lastname, email
+
+<a href="https://docs.moodle.org/en/Upload_users" target="_blank">More help</a>';
 $string['uploaduserspreview'] = 'Upload users preview';
 $string['uploadusersresult'] = 'Upload users results';
 $string['uploaduser:uploaduserpictures'] = 'Upload user pictures';
index 59b9131..552ea4c 100644 (file)
@@ -40,6 +40,11 @@ class admin_uploaduser_form1 extends moodleform {
 
         $mform->addElement('header', 'settingsheader', get_string('upload'));
 
+        $url = new moodle_url('example.csv');
+        $link = html_writer::link($url, 'example.csv');
+        $mform->addElement('static', 'examplecsv', get_string('examplecsv', 'tool_uploaduser'), $link);
+        $mform->addHelpButton('examplecsv', 'examplecsv', 'tool_uploaduser');
+
         $mform->addElement('filepicker', 'userfile', get_string('file'));
         $mform->addRule('userfile', null, 'required');
 
index b8df729..f138e9c 100644 (file)
@@ -65,7 +65,7 @@
 }}
 <div class="border-bottom pb-2">
     {{#eventsbyday}}
-        <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedayshort, core_langconfig {{/str}}  {{/userdate}}</h5>
+        <h5 class="h6 mt-3 mb-0 {{#past}}text-danger{{/past}}">{{#userdate}} {{dayTimestamp}}, {{#str}} strftimedaydate, core_langconfig {{/str}}  {{/userdate}}</h5>
         {{> block_timeline/event-list-items }}
     {{/eventsbyday}}
 </div>
\ No newline at end of file
index ba04c38..4928d85 100644 (file)
@@ -643,6 +643,15 @@ $CFG->admin = 'admin';
 //              . 'copy_action_column,preview_action_column,delete_action_column,'
 //              . 'creator_name_column,modifier_name_column';
 //
+// Forum summary report
+//
+// In order for the forum summary report to calculate word count and character count data, those details are now stored
+// for each post in the database when posts are created or updated. For posts that existed prior to a Moodle 3.8 upgrade,
+// these are calculated by the refresh_forum_post_counts ad-hoc task in chunks of 5000 posts per batch by default.
+// That default can be overridden by setting an integer value for $CFG->forumpostcountchunksize.
+//
+//      $CFG->forumpostcountchunksize = 5000;
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index bcf2cc9..c8d7085 100644 (file)
@@ -1123,8 +1123,7 @@ class core_useragent {
         $extension = strtolower($extension);
 
         $supportedvideo = array('m4v', 'webm', 'ogv', 'mp4', 'mov');
-        $supportedaudio = array('ogg', 'oga', 'aac', 'm4a', 'mp3', 'wav');
-        // TODO MDL-56549 Flac will be supported in Firefox 51 in January 2017.
+        $supportedaudio = array('ogg', 'oga', 'aac', 'm4a', 'mp3', 'wav', 'flac');
 
         // Basic extension support.
         if (!in_array($extension, $supportedvideo) && !in_array($extension, $supportedaudio)) {
@@ -1158,6 +1157,11 @@ class core_useragent {
         if ($isogg && (self::is_ie() || self::is_edge() || self::is_safari() || self::is_safari_ios())) {
             return false;
         }
+        // FLAC is not supported in IE and Edge (below 16.0).
+        if ($extension === 'flac' &&
+                (self::is_ie() || (self::is_edge() && !self::check_edge_version('16.0')))) {
+            return false;
+        }
         // Wave is not supported in IE.
         if ($extension === 'wav' && self::is_ie()) {
             return false;
index da095b9..048b119 100644 (file)
@@ -97,11 +97,6 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
                 }
             }
         }
-
-        // The YUI2 calendar only supports the gregorian calendar type.
-        if ($calendartype->get_name() === 'gregorian') {
-            form_init_date_js();
-        }
     }
 
     /**
@@ -256,9 +251,21 @@ class MoodleQuickForm_date_selector extends MoodleQuickForm_group {
      * @param string $error An error message associated with a group
      */
     function accept(&$renderer, $required = false, $error = null) {
+        form_init_date_js();
         $renderer->renderElement($this, $required, $error);
     }
 
+    /**
+     * Export for template
+     *
+     * @param renderer_base $output
+     * @return array|stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        form_init_date_js();
+        return parent::export_for_template($output);
+    }
+
     /**
      * Output a timestamp. Give it the name of the group.
      *
index 311e233..094b05f 100644 (file)
@@ -100,11 +100,6 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
                 }
             }
         }
-
-        // The YUI2 calendar only supports the gregorian calendar type.
-        if ($calendartype->get_name() === 'gregorian') {
-            form_init_date_js();
-        }
     }
 
     /**
@@ -282,9 +277,21 @@ class MoodleQuickForm_date_time_selector extends MoodleQuickForm_group {
      * @param string $error An error message associated with a group
      */
     function accept(&$renderer, $required = false, $error = null) {
+        form_init_date_js();
         $renderer->renderElement($this, $required, $error);
     }
 
+    /**
+     * Export for template
+     *
+     * @param renderer_base $output
+     * @return array|stdClass
+     */
+    public function export_for_template(renderer_base $output) {
+        form_init_date_js();
+        return parent::export_for_template($output);
+    }
+
     /**
      * Output a timestamp. Give it the name of the group.
      *
index 2dd9b33..990ab73 100644 (file)
@@ -77,7 +77,12 @@ function form_init_date_js() {
     global $PAGE;
     static $done = false;
     if (!$done) {
+        $done = true;
         $calendar = \core_calendar\type_factory::get_calendar_instance();
+        if ($calendar->get_name() !== 'gregorian') {
+            // The YUI2 calendar only supports the gregorian calendar type.
+            return;
+        }
         $module   = 'moodle-form-dateselector';
         $function = 'M.form.dateselector.init_date_selectors';
         $defaulttimezone = date_default_timezone_get();
@@ -105,7 +110,6 @@ function form_init_date_js() {
             'december'          => date_format_string(strtotime("December 1"), '%B', $defaulttimezone)
         ));
         $PAGE->requires->yui_module($module, $function, $config);
-        $done = true;
     }
 }
 
diff --git a/lib/tests/core_media_player_native.php b/lib/tests/core_media_player_native.php
new file mode 100644 (file)
index 0000000..c7e925b
--- /dev/null
@@ -0,0 +1,163 @@
+<?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/>.
+
+/**
+ * Test for core_media_player_native.
+ *
+ * @package   core
+ * @category  test
+ * @copyright 2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '/fixtures/testable_core_media_player_native.php');
+
+/**
+ * Test for core_media_player_native.
+ *
+ * @package   core
+ * @category  test
+ * @covers    core_media_player_native
+ * @copyright 2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class core_media_player_native_testcase extends advanced_testcase {
+
+    /**
+     * Pre-test setup.
+     */
+    public function setUp() {
+        parent::setUp();
+        $this->resetAfterTest();
+    }
+
+    /**
+     * Test method get_supported_extensions
+     */
+    public function test_get_supported_extensions() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+        $nativeextensions = file_get_typegroup('extension', ['html_video', 'html_audio']);
+
+        // Make sure that the list of extensions from the setting is exactly the same.
+        $player = new media_test_native_plugin();
+        $this->assertEmpty(array_diff($player->get_supported_extensions(), $nativeextensions));
+        $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
+
+    }
+
+    /**
+     * Test method list_supported_urls
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+        $nativeextensions = file_get_typegroup('extension', ['html_video', 'html_audio']);
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/video.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_test_native_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
+    /**
+     * Test method get_attribute
+     */
+    public function test_get_attribute() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertEquals($title, media_test_native_plugin::get_attribute($content, 'title'));
+    }
+
+    /**
+     * Test methods add_attributes and remove_attributes
+     */
+    public function test_add_remove_attributes() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        // Add attributes.
+        $content = media_test_native_plugin::add_attributes($content, ['preload' => 'none', 'controls' => 'true']);
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="true"~', $content);
+
+        // Change existing attribute.
+        $content = media_test_native_plugin::add_attributes($content, ['controls' => 'false']);
+        $this->assertRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+
+        // Remove attributes.
+        $content = media_test_native_plugin::remove_attributes($content, ['title']);
+        $this->assertNotRegExp('~title="' . $title . '"~', $content);
+        $this->assertRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+
+        // Remove another one.
+        $content = media_test_native_plugin::remove_attributes($content, ['preload']);
+        $this->assertNotRegExp('~title="' . $title . '"~', $content);
+        $this->assertNotRegExp('~preload="none"~', $content);
+        $this->assertRegExp('~controls="false"~', $content);
+    }
+
+    /**
+     * Test method replace_sources
+     */
+    public function test_replace_sources() {
+        $urls = [
+            new moodle_url('http://example.org/some_filename.mp4'),
+            new moodle_url('http://example.org/some_filename_hires.mp4'),
+        ];
+
+        $player = new media_test_native_plugin();
+        // We are using fixture embed method directly as content generator.
+        $title = 'Some Filename Video';
+        $content = $player->embed($urls, $title, 0, 0, []);
+
+        // Test sources present.
+        $this->assertContains('<source src="http://example.org/some_filename.mp4" />', $content);
+        $this->assertContains('<source src="http://example.org/some_filename_hires.mp4" />', $content);
+
+        // Change sources.
+        $newsource = '<source src="http://example.org/new_filename.mp4" />';
+        $content = media_test_native_plugin::replace_sources($content, $newsource);
+        $this->assertContains($newsource, $content);
+        $this->assertNotContains('<source src="http://example.org/some_filename.mp4" />', $content);
+        $this->assertNotContains('<source src="http://example.org/some_filename_hires.mp4" />', $content);
+    }
+}
\ No newline at end of file
diff --git a/lib/tests/fixtures/testable_core_media_player.php b/lib/tests/fixtures/testable_core_media_player.php
new file mode 100644 (file)
index 0000000..f36b5c9
--- /dev/null
@@ -0,0 +1,90 @@
+<?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/>.
+
+/**
+ * Fixture for testing the functionality of core_media_player.
+ *
+ * @package     core
+ * @subpackage  fixtures
+ * @category    test
+ * @copyright   2012 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Media player stub for testing purposes.
+ *
+ * @copyright   2012 The Open University
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class media_test_plugin extends core_media_player {
+    /** @var array Array of supported extensions */
+    public $ext;
+    /** @var int Player rank */
+    public $rank;
+    /** @var int Arbitrary number */
+    public $num;
+
+    /**
+     * Constructor is used for tuning the fixture.
+     *
+     * @param int $num Number (used in output)
+     * @param int $rank Player rank
+     * @param array $ext Array of supported extensions
+     */
+    public function __construct($num = 1, $rank = 13, $ext = array('mp3', 'flv', 'f4v', 'mp4')) {
+        $this->ext = $ext;
+        $this->rank = $rank;
+        $this->num = $num;
+    }
+
+    /**
+     * Generates code required to embed the player.
+     *
+     * @param array $urls URLs of media files
+     * @param string $name Display name; '' to use default
+     * @param int $width Optional width; 0 to use default
+     * @param int $height Optional height; 0 to use default
+     * @param array $options Options array
+     * @return string HTML code for embed
+     */
+    public function embed($urls, $name, $width, $height, $options) {
+        self::pick_video_size($width, $height);
+        $contents = "\ntestsource=". join("\ntestsource=", $urls) .
+            "\ntestname=$name\ntestwidth=$width\ntestheight=$height\n<!--FALLBACK-->\n";
+        return html_writer::span($contents, 'mediaplugin mediaplugin_test');
+    }
+
+    /**
+     * Gets the list of file extensions supported by this media player.
+     *
+     * @return array Array of strings (extension not including dot e.g. '.mp3')
+     */
+    public function get_supported_extensions() {
+        return $this->ext;
+    }
+
+    /**
+     * Gets the ranking of this player.
+     *
+     * @return int Rank
+     */
+    public function get_rank() {
+        return 10;
+    }
+}
\ No newline at end of file
diff --git a/lib/tests/fixtures/testable_core_media_player_native.php b/lib/tests/fixtures/testable_core_media_player_native.php
new file mode 100644 (file)
index 0000000..f484c77
--- /dev/null
@@ -0,0 +1,89 @@
+<?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/>.
+
+/**
+ * Fixture for testing the functionality of core_media_player_native.
+ *
+ * @package     core
+ * @subpackage  fixtures
+ * @category    test
+ * @copyright   2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Native media player stub for testing purposes.
+ *
+ * @copyright   2019 Ruslan Kabalin
+ * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class media_test_native_plugin extends core_media_player_native {
+    /** @var int Player rank */
+    public $rank;
+    /** @var int Arbitrary number */
+    public $num;
+
+    /**
+     * Constructor is used for tuning the fixture.
+     *
+     * @param int $num Number (used in output)
+     * @param int $rank Player rank
+     */
+    public function __construct($num = 1, $rank = 13) {
+        $this->rank = $rank;
+        $this->num = $num;
+    }
+
+    /**
+     * Generates code required to embed the player.
+     *
+     * @param array $urls URLs of media files
+     * @param string $name Display name; '' to use default
+     * @param int $width Optional width; 0 to use default
+     * @param int $height Optional height; 0 to use default
+     * @param array $options Options array
+     * @return string HTML code for embed
+     */
+    public function embed($urls, $name, $width, $height, $options) {
+        $sources = array();
+        foreach ($urls as $url) {
+            $params = ['src' => $url];
+            $sources[] = html_writer::empty_tag('source', $params);
+        }
+
+        $sources = implode("\n", $sources);
+        $title = $this->get_name($name, $urls);
+        // Escape title but prevent double escaping.
+        $title = s(preg_replace(['/&amp;/', '/&gt;/', '/&lt;/'], ['&', '>', '<'], $title));
+
+        return <<<OET
+<video class="mediaplugin mediaplugin_test" title="$title">
+    $sources
+</video>
+OET;
+    }
+
+    /**
+     * Gets the ranking of this player.
+     *
+     * @return int Rank
+     */
+    public function get_rank() {
+        return 10;
+    }
+}
\ No newline at end of file
index 194cc19..8b230c9 100644 (file)
 /**
  * Test classes for handling embedded media (audio/video).
  *
- * @package core_media
- * @category phpunit
+ * @package   core
+ * @category  test
  * @copyright 2012 The Open University
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
 defined('MOODLE_INTERNAL') || die();
+require_once(__DIR__ . '/fixtures/testable_core_media_player.php');
 
 /**
  * Test script for media embedding.
@@ -495,42 +496,4 @@ class core_medialib_testcase extends advanced_testcase {
         }
         return $out;
     }
-}
-
-/**
- * Media player stub for testing purposes.
- */
-class media_test_plugin extends core_media_player {
-    /** @var array Array of supported extensions */
-    public $ext;
-    /** @var int Player rank */
-    public $rank;
-    /** @var int Arbitrary number */
-    public $num;
-
-    /**
-     * @param int $num Number (used in output)
-     * @param int $rank Player rank
-     * @param array $ext Array of supported extensions
-     */
-    public function __construct($num = 1, $rank = 13, $ext = array('mp3', 'flv', 'f4v', 'mp4')) {
-        $this->ext = $ext;
-        $this->rank = $rank;
-        $this->num = $num;
-    }
-
-    public function embed($urls, $name, $width, $height, $options) {
-        self::pick_video_size($width, $height);
-        $contents = "\ntestsource=". join("\ntestsource=", $urls) .
-            "\ntestname=$name\ntestwidth=$width\ntestheight=$height\n<!--FALLBACK-->\n";
-        return html_writer::span($contents, 'mediaplugin mediaplugin_test');
-    }
-
-    public function get_supported_extensions() {
-        return $this->ext;
-    }
-
-    public function get_rank() {
-        return 10;
-    }
-}
+}
\ No newline at end of file
index 0c243c2..7dfe8e4 100644 (file)
@@ -60,7 +60,7 @@ class media_html5audio_testcase extends advanced_testcase {
     /**
      * Test method get_supported_extensions()
      */
-    public function test_supported_extensions() {
+    public function test_get_supported_extensions() {
         global $CFG;
         require_once($CFG->libdir . '/filelib.php');
 
@@ -72,6 +72,25 @@ class media_html5audio_testcase extends advanced_testcase {
         $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
     }
 
+    /**
+     * Test method list_supported_urls()
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+
+        $nativeextensions = file_get_typegroup('extension', 'html_audio');
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/audio.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_html5audio_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
     /**
      * Test embedding without media filter (for example for displaying file resorce).
      */
index 7caf405..3def9af 100644 (file)
@@ -60,7 +60,7 @@ class media_html5video_testcase extends advanced_testcase {
     /**
      * Test method get_supported_extensions()
      */
-    public function test_supported_extensions() {
+    public function test_get_supported_extensions() {
         $nativeextensions = file_get_typegroup('extension', 'html_video');
 
         // Make sure that the list of extensions from the setting is exactly the same as html_video group.
@@ -69,6 +69,25 @@ class media_html5video_testcase extends advanced_testcase {
         $this->assertEmpty(array_diff($nativeextensions, $player->get_supported_extensions()));
     }
 
+    /**
+     * Test method list_supported_urls()
+     */
+    public function test_list_supported_urls() {
+        global $CFG;
+        require_once($CFG->libdir . '/filelib.php');
+
+        $nativeextensions = file_get_typegroup('extension', 'html_video');
+
+        // Create list of URLs for each extension.
+        $urls = array_map(function($ext){
+            return new moodle_url('http://example.org/video.' . $ext);
+        }, $nativeextensions);
+
+        // Make sure that the list of supported URLs is not filtering permitted extensions.
+        $player = new media_html5video_plugin();
+        $this->assertCount(count($urls), $player->list_supported_urls($urls));
+    }
+
     /**
      * Test embedding without media filter (for example for displaying file resorce).
      */
index c2c0c37..fb5ad44 100644 (file)
@@ -91,93 +91,8 @@ if (!$formdata = $form->get_data()) {
     echo $OUTPUT->footer();
     die;
 } else {
-    // Large files are likely to take their time and memory. Let PHP know
-    // that we'll take longer, and that the process should be recycled soon
-    // to free up memory.
-    core_php_time_limit::raise();
-    raise_memory_limit(MEMORY_EXTRA);
-
-    $iid = csv_import_reader::get_new_iid('moddata');
-    $cir = new csv_import_reader($iid, 'moddata');
-
     $filecontent = $form->get_file_content('recordsfile');
-    $readcount = $cir->load_csv_content($filecontent, $formdata->encoding, $formdata->fielddelimiter);
-    unset($filecontent);
-    if (empty($readcount)) {
-        print_error('csvfailed','data',"{$CFG->wwwroot}/mod/data/edit.php?d={$data->id}");
-    } else {
-        if (!$fieldnames = $cir->get_columns()) {
-            print_error('cannotreadtmpfile', 'error');
-        }
-        $fieldnames = array_flip($fieldnames);
-        // check the fieldnames are valid
-        $rawfields = $DB->get_records('data_fields', array('dataid' => $data->id), '', 'name, id, type');
-        $fields = array();
-        $errorfield = '';
-        $safetoskipfields = array(get_string('user'), get_string('username'), get_string('email'),
-            get_string('timeadded', 'data'), get_string('timemodified', 'data'),
-            get_string('approved', 'data'), get_string('tags', 'data'));
-        foreach ($fieldnames as $name => $id) {
-            if (!isset($rawfields[$name])) {
-                if (!in_array($name, $safetoskipfields)) {
-                    $errorfield .= "'$name' ";
-                }
-            } else {
-                $field = $rawfields[$name];
-                require_once("$CFG->dirroot/mod/data/field/$field->type/field.class.php");
-                $classname = 'data_field_' . $field->type;
-                $fields[$name] = new $classname($field, $data, $cm);
-            }
-        }
-
-        if (!empty($errorfield)) {
-            print_error('fieldnotmatched','data',"{$CFG->wwwroot}/mod/data/edit.php?d={$data->id}",$errorfield);
-        }
-
-        $cir->init();
-        $recordsadded = 0;
-        while ($record = $cir->next()) {
-            if ($recordid = data_add_record($data, 0)) {  // add instance to data_record
-                foreach ($fields as $field) {
-                    $fieldid = $fieldnames[$field->field->name];
-                    if (isset($record[$fieldid])) {
-                        $value = $record[$fieldid];
-                    } else {
-                        $value = '';
-                    }
-
-                    if (method_exists($field, 'update_content_import')) {
-                        $field->update_content_import($recordid, $value, 'field_' . $field->field->id);
-                    } else {
-                        $content = new stdClass();
-                        $content->fieldid = $field->field->id;
-                        $content->content = $value;
-                        $content->recordid = $recordid;
-                        $DB->insert_record('data_content', $content);
-                    }
-                }
-
-                if (core_tag_tag::is_enabled('mod_data', 'data_records') &&
-                        isset($fieldnames[get_string('tags', 'data')])) {
-                    $columnindex = $fieldnames[get_string('tags', 'data')];
-                    $rawtags = $record[$columnindex];
-                    $tags = explode(',', $rawtags);
-                    foreach ($tags as $tag) {
-                        $tag = trim($tag);
-                        if (empty($tag)) {
-                            continue;
-                        }
-                        core_tag_tag::add_item_tag('mod_data', 'data_records', $recordid, $context, $tag);
-                    }
-                }
-
-                $recordsadded++;
-                print get_string('added', 'moodle', $recordsadded) . ". " . get_string('entry', 'data') . " (ID $recordid)<br />\n";
-            }
-        }
-        $cir->close();
-        $cir->cleanup(true);
-    }
+    $recordsadded = data_import_csv($cm, $data, $filecontent, $formdata->encoding, $formdata->fielddelimiter);
 }
 
 if ($recordsadded > 0) {
index bd562c9..4e40e8e 100644 (file)
@@ -973,16 +973,17 @@ function data_numentries($data, $userid=null) {
  * @global object
  * @param object $data
  * @param int $groupid
+ * @param int $userid
  * @return bool
  */
-function data_add_record($data, $groupid=0){
+function data_add_record($data, $groupid = 0, $userid = null) {
     global $USER, $DB;
 
     $cm = get_coursemodule_from_instance('data', $data->id);
     $context = context_module::instance($cm->id);
 
     $record = new stdClass();
-    $record->userid = $USER->id;
+    $record->userid = $userid ?? $USER->id;
     $record->dataid = $data->id;
     $record->groupid = $groupid;
     $record->timecreated = $record->timemodified = time();
@@ -3044,6 +3045,135 @@ function data_supports($feature) {
         default: return null;
     }
 }
+
+/**
+ * Import records for a data instance from csv data.
+ *
+ * @param object $cm Course module of the data instance.
+ * @param object $data The data instance.
+ * @param string $csvdata The csv data to be imported.
+ * @param string $encoding The encoding of csv data.
+ * @param string $fielddelimiter The delimiter of the csv data.
+ * @return int Number of records added.
+ */
+function data_import_csv($cm, $data, &$csvdata, $encoding, $fielddelimiter) {
+    global $CFG, $DB;
+    // Large files are likely to take their time and memory. Let PHP know
+    // that we'll take longer, and that the process should be recycled soon
+    // to free up memory.
+    core_php_time_limit::raise();
+    raise_memory_limit(MEMORY_EXTRA);
+
+    $iid = csv_import_reader::get_new_iid('moddata');
+    $cir = new csv_import_reader($iid, 'moddata');
+
+    $context = context_module::instance($cm->id);
+
+    $readcount = $cir->load_csv_content($csvdata, $encoding, $fielddelimiter);
+    $csvdata = null; // Free memory.
+    if (empty($readcount)) {
+        print_error('csvfailed', 'data', "{$CFG->wwwroot}/mod/data/edit.php?d={$data->id}");
+    } else {
+        if (!$fieldnames = $cir->get_columns()) {
+            print_error('cannotreadtmpfile', 'error');
+        }
+
+        // Check the fieldnames are valid.
+        $rawfields = $DB->get_records('data_fields', array('dataid' => $data->id), '', 'name, id, type');
+        $fields = array();
+        $errorfield = '';
+        $usernamestring = get_string('username');
+        $safetoskipfields = array(get_string('user'), get_string('email'),
+            get_string('timeadded', 'data'), get_string('timemodified', 'data'),
+            get_string('approved', 'data'), get_string('tags', 'data'));
+        $userfieldid = null;
+        foreach ($fieldnames as $id => $name) {
+            if (!isset($rawfields[$name])) {
+                if ($name == $usernamestring) {
+                    $userfieldid = $id;
+                } else if (!in_array($name, $safetoskipfields)) {
+                    $errorfield .= "'$name' ";
+                }
+            } else {
+                // If this is the second time, a field with this name comes up, it must be a field not provided by the user...
+                // like the username.
+                if (isset($fields[$name])) {
+                    if ($name == $usernamestring) {
+                        $userfieldid = $id;
+                    }
+                    unset($fieldnames[$id]); // To ensure the user provided content fields remain in the array once flipped.
+                } else {
+                    $field = $rawfields[$name];
+                    require_once("$CFG->dirroot/mod/data/field/$field->type/field.class.php");
+                    $classname = 'data_field_' . $field->type;
+                    $fields[$name] = new $classname($field, $data, $cm);
+                }
+            }
+        }
+
+        if (!empty($errorfield)) {
+            print_error('fieldnotmatched', 'data',
+                "{$CFG->wwwroot}/mod/data/edit.php?d={$data->id}", $errorfield);
+        }
+
+        $fieldnames = array_flip($fieldnames);
+
+        $cir->init();
+        $recordsadded = 0;
+        while ($record = $cir->next()) {
+            $authorid = null;
+            if ($userfieldid) {
+                if (!($author = core_user::get_user_by_username($record[$userfieldid], 'id'))) {
+                    $authorid = null;
+                } else {
+                    $authorid = $author->id;
+                }
+            }
+            if ($recordid = data_add_record($data, 0, $authorid)) {  // Add instance to data_record.
+                foreach ($fields as $field) {
+                    $fieldid = $fieldnames[$field->field->name];
+                    if (isset($record[$fieldid])) {
+                        $value = $record[$fieldid];
+                    } else {
+                        $value = '';
+                    }
+
+                    if (method_exists($field, 'update_content_import')) {
+                        $field->update_content_import($recordid, $value, 'field_' . $field->field->id);
+                    } else {
+                        $content = new stdClass();
+                        $content->fieldid = $field->field->id;
+                        $content->content = $value;
+                        $content->recordid = $recordid;
+                        $DB->insert_record('data_content', $content);
+                    }
+                }
+
+                if (core_tag_tag::is_enabled('mod_data', 'data_records') &&
+                    isset($fieldnames[get_string('tags', 'data')])) {
+                    $columnindex = $fieldnames[get_string('tags', 'data')];
+                    $rawtags = $record[$columnindex];
+                    $tags = explode(',', $rawtags);
+                    foreach ($tags as $tag) {
+                        $tag = trim($tag);
+                        if (empty($tag)) {
+                            continue;
+                        }
+                        core_tag_tag::add_item_tag('mod_data', 'data_records', $recordid, $context, $tag);
+                    }
+                }
+
+                $recordsadded++;
+                print get_string('added', 'moodle', $recordsadded) . ". " . get_string('entry', 'data') . " (ID $recordid)<br />\n";
+            }
+        }
+        $cir->close();
+        $cir->cleanup(true);
+        return $recordsadded;
+    }
+    return 0;
+}
+
 /**
  * @global object
  * @param array $export
diff --git a/mod/data/tests/fixtures/test_data_import.csv b/mod/data/tests/fixtures/test_data_import.csv
new file mode 100644 (file)
index 0000000..0ac9f1c
--- /dev/null
@@ -0,0 +1,3 @@
+ID,Param2
+1,"My first entry"
+2,"My second entry"
\ No newline at end of file
diff --git a/mod/data/tests/fixtures/test_data_import_with_field_username.csv b/mod/data/tests/fixtures/test_data_import_with_field_username.csv
new file mode 100644 (file)
index 0000000..215bf6c
--- /dev/null
@@ -0,0 +1,4 @@
+ID,Username,Param2,Username,"Email address"
+1,otherusername1,"My first entry",student,student@moodle.org
+2,otherusername2,"My second entry",student2,student@moodle.org
+3,otherusername3,"My third entry",student,student@moodle.org
\ No newline at end of file
diff --git a/mod/data/tests/fixtures/test_data_import_with_userdata.csv b/mod/data/tests/fixtures/test_data_import_with_userdata.csv
new file mode 100644 (file)
index 0000000..dd5ef63
--- /dev/null
@@ -0,0 +1,3 @@
+ID,Param2,Username,"Email address"
+1,"My first entry",student,student@moodle.org
+2,"My second entry",student2,student@moodle.org
\ No newline at end of file
diff --git a/mod/data/tests/import_test.php b/mod/data/tests/import_test.php
new file mode 100644 (file)
index 0000000..ab5c826
--- /dev/null
@@ -0,0 +1,290 @@
+<?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 importing csv files.
+ *
+ * @package    mod_data
+ * @category   test
+ * @copyright  2019 Tobias Reischmann
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Unit tests for import.php.
+ *
+ * @package    mod_data
+ * @copyright  2019 Tobias Reischmann
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class mod_data_import_test extends advanced_testcase {
+
+    /**
+     * Set up function.
+     */
+    protected function setUp() {
+        parent::setUp();
+
+        global $CFG;
+        require_once($CFG->dirroot . '/mod/data/lib.php');
+        require_once($CFG->dirroot . '/lib/datalib.php');
+        require_once($CFG->dirroot . '/lib/csvlib.class.php');
+        require_once($CFG->dirroot . '/search/tests/fixtures/testable_core_search.php');
+        require_once($CFG->dirroot . '/mod/data/tests/generator/lib.php');
+    }
+
+    /**
+     * Get the test data.
+     * In this instance we are setting up database records to be used in the unit tests.
+     *
+     * @return array
+     */
+    protected function get_test_data(): array {
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+        $course = $this->getDataGenerator()->create_course();
+        $teacher = $this->getDataGenerator()->create_and_enrol($course, 'teacher');
+        $this->setUser($teacher);
+        $student = $this->getDataGenerator()->create_and_enrol($course, 'student', array('username' => 'student'));
+
+        $data = $generator->create_instance(array('course' => $course->id));
+        $cm = get_coursemodule_from_instance('data', $data->id);
+
+        // Add fields.
+        $fieldrecord = new StdClass();
+        $fieldrecord->name = 'ID'; // Identifier of the records for testing.
+        $fieldrecord->type = 'number';
+        $generator->create_field($fieldrecord, $data);
+
+        $fieldrecord->name = 'Param2';
+        $fieldrecord->type = 'text';
+        $generator->create_field($fieldrecord, $data);
+
+
+        return [
+            'teacher' => $teacher,
+            'student' => $student,
+            'data' => $data,
+            'cm' => $cm,
+        ];
+    }
+
+    /**
+     * Test uploading entries for a data instance without userdata.
+     * @throws dml_exception
+     */
+    public function test_import(): void {
+        [
+            'data' => $data,
+            'cm' => $cm,
+            'teacher' => $teacher,
+        ] = $this->get_test_data();
+
+        $filecontent = file_get_contents(__DIR__ . '/fixtures/test_data_import.csv');
+        ob_start();
+        data_import_csv($cm, $data, $filecontent, 'UTF-8', 'comma');
+        ob_end_clean();
+
+        // No userdata is present in the file: Fallback is to assign the uploading user as author.
+        $expecteduserids = array();
+        $expecteduserids[1] = $teacher->id;
+        $expecteduserids[2] = $teacher->id;
+
+        $records = $this->get_data_records($data->id);
+        $this->assertCount(2, $records);
+        foreach ($records as $record) {
+            $identifier = $record->items['ID']->content;
+            $this->assertEquals($expecteduserids[$identifier], $record->userid);
+        }
+    }
+
+    /**
+     * Test uploading entries for a data instance with userdata.
+     *
+     * At least one entry has an identifiable user, which is assigned as author.
+     * @throws dml_exception
+     */
+    public function test_import_with_userdata(): void {
+        [
+            'data' => $data,
+            'cm' => $cm,
+            'teacher' => $teacher,
+            'student' => $student,
+        ] = $this->get_test_data();
+
+        $filecontent = file_get_contents(__DIR__ . '/fixtures/test_data_import_with_userdata.csv');
+        ob_start();
+        data_import_csv($cm, $data, $filecontent, 'UTF-8', 'comma');
+        ob_end_clean();
+
+        $expecteduserids = array();
+        $expecteduserids[1] = $student->id; // User student exists and is assigned as author.
+        $expecteduserids[2] = $teacher->id; // User student2 does not exist. Fallback is the uploading user.
+
+        $records = $this->get_data_records($data->id);
+        $this->assertCount(2, $records);
+        foreach ($records as $record) {
+            $identifier = $record->items['ID']->content;
+            $this->assertEquals($expecteduserids[$identifier], $record->userid);
+        }
+    }
+
+    /**
+     * Test uploading entries for a data instance with userdata and a defined field 'Username'.
+     *
+     * This should test the corner case, in which a user has defined a data fields, which has the same name
+     * as the current lang string for username. In that case, the first Username entry is used for the field.
+     * The second one is used to identify the author.
+     * @throws coding_exception
+     * @throws dml_exception
+     */
+    public function test_import_with_field_username(): void {
+        [
+            'data' => $data,
+            'cm' => $cm,
+            'teacher' => $teacher,
+            'student' => $student,
+        ] = $this->get_test_data();
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Add username field.
+        $fieldrecord = new StdClass();
+        $fieldrecord->name = 'Username';
+        $fieldrecord->type = 'text';
+        $generator->create_field($fieldrecord, $data);
+
+        $filecontent = file_get_contents(__DIR__ . '/fixtures/test_data_import_with_field_username.csv');
+        ob_start();
+        data_import_csv($cm, $data, $filecontent, 'UTF-8', 'comma');
+        ob_end_clean();
+
+        $expecteduserids = array();
+        $expecteduserids[1] = $student->id; // User student exists and is assigned as author.
+        $expecteduserids[2] = $teacher->id; // User student2 does not exist. Fallback is the uploading user.
+        $expecteduserids[3] = $student->id; // User student exists and is assigned as author.
+
+        $expectedcontent = array();
+        $expectedcontent[1] = array(
+            'Username' => 'otherusername1',
+            'Param2' => 'My first entry',
+        );
+        $expectedcontent[2] = array(
+            'Username' => 'otherusername2',
+            'Param2' => 'My second entry',
+        );
+        $expectedcontent[3] = array(
+            'Username' => 'otherusername3',
+            'Param2' => 'My third entry',
+        );
+
+        $records = $this->get_data_records($data->id);
+        $this->assertCount(3, $records);
+        foreach ($records as $record) {
+            $identifier = $record->items['ID']->content;
+            $this->assertEquals($expecteduserids[$identifier], $record->userid);
+
+            foreach ($expectedcontent[$identifier] as $field => $value) {
+                $this->assertEquals($value, $record->items[$field]->content,
+                    "The value of field \"$field\" for the record at position \"$identifier\" ".
+                    "which is \"{$record->items[$field]->content}\" does not match the expected value \"$value\".");
+            }
+        }
+    }
+
+    /**
+     * Test uploading entries for a data instance with a field 'Username' but only one occurrence in the csv file.
+     *
+     * This should test the corner case, in which a user has defined a data fields, which has the same name
+     * as the current lang string for username. In that case, the only Username entry is used for the field.
+     * The author should not be set.
+     * @throws coding_exception
+     * @throws dml_exception
+     */
+    public function test_import_with_field_username_without_userdata(): void {
+        [
+            'data' => $data,
+            'cm' => $cm,
+            'teacher' => $teacher,
+            'student' => $student,
+        ] = $this->get_test_data();
+        $generator = $this->getDataGenerator()->get_plugin_generator('mod_data');
+
+        // Add username field.
+        $fieldrecord = new StdClass();
+        $fieldrecord->name = 'Username';
+        $fieldrecord->type = 'text';
+        $generator->create_field($fieldrecord, $data);
+
+        $filecontent = file_get_contents(__DIR__ . '/fixtures/test_data_import_with_userdata.csv');
+        ob_start();
+        data_import_csv($cm, $data, $filecontent, 'UTF-8', 'comma');
+        ob_end_clean();
+
+        // No userdata is present in the file: Fallback is to assign the uploading user as author.
+        $expecteduserids = array();
+        $expecteduserids[1] = $teacher->id;
+        $expecteduserids[2] = $teacher->id;
+
+        $expectedcontent = array();
+        $expectedcontent[1] = array(
+            'Username' => 'student',
+            'Param2' => 'My first entry',
+        );
+        $expectedcontent[2] = array(
+            'Username' => 'student2',
+            'Param2' => 'My second entry',
+        );
+
+        $records = $this->get_data_records($data->id);
+        $this->assertCount(2, $records);
+        foreach ($records as $record) {
+            $identifier = $record->items['ID']->content;
+            $this->assertEquals($expecteduserids[$identifier], $record->userid);
+
+            foreach ($expectedcontent[$identifier] as $field => $value) {
+                $this->assertEquals($value, $record->items[$field]->content,
+                    "The value of field \"$field\" for the record at position \"$identifier\" ".
+                    "which is \"{$record->items[$field]->content}\" does not match the expected value \"$value\".");
+            }
+        }
+    }
+
+    /**
+     * Returns the records of the data instance.
+     *
+     * Each records has an item entry, which contains all fields associated with this item.
+     * Each fields has the parameters name, type and content.
+     * @param int $dataid Id of the data instance.
+     * @return array The records of the data instance.
+     * @throws dml_exception
+     */
+    private function get_data_records(int $dataid): array {
+        global $DB;
+
+        $records = $DB->get_records('data_records', ['dataid' => $dataid]);
+        foreach ($records as $record) {
+            $sql = 'SELECT f.name, f.type, con.content FROM
+                {data_content} con JOIN {data_fields} f ON con.fieldid = f.id
+                WHERE con.recordid = :recordid';
+            $items = $DB->get_records_sql($sql, array('recordid' => $record->id));
+            $record->items = $items;
+        }
+        return $records;
+    }
+}
index 855e25c..593e353 100644 (file)
@@ -116,6 +116,7 @@ class restore_forum_activity_structure_step extends restore_activity_structure_s
             $data->parent = $this->get_mappingid('forum_post', $data->parent);
         }
 
+        \mod_forum\local\entities\post::add_message_counts($data);
         $newitemid = $DB->insert_record('forum_posts', $data);
         $this->set_mapping('forum_post', $oldid, $newitemid, true);
 
index fcced5f..09742c1 100644 (file)
@@ -61,6 +61,8 @@ class post {
                 'mailnow' => $post->should_mail_now(),
                 'deleted' => $post->is_deleted(),
                 'privatereplyto' => $post->get_private_reply_recipient_id(),
+                'wordcount' => $post->get_wordcount(),
+                'charcount' => $post->get_charcount(),
             ];
         }, $posts);
     }
index f84053b..90cd0fc 100644 (file)
@@ -67,6 +67,10 @@ class post {
     private $deleted;
     /** @var int $privatereplyto The user being privately replied to */
     private $privatereplyto;
+    /** @var int $wordcount Number of words in the message */
+    private $wordcount;
+    /** @var int $charcount Number of chars in the message */
+    private $charcount;
 
     /**
      * Constructor.
@@ -104,7 +108,9 @@ class post {
         int $totalscore,
         bool $mailnow,
         bool $deleted,
-        int $privatereplyto
+        int $privatereplyto,
+        ?int $wordcount,
+        ?int $charcount
     ) {
         $this->id = $id;
         $this->discussionid = $discussionid;
@@ -122,6 +128,8 @@ class post {
         $this->mailnow = $mailnow;
         $this->deleted = $deleted;
         $this->privatereplyto = $privatereplyto;
+        $this->wordcount = $wordcount;
+        $this->charcount = $charcount;
     }
 
     /**
@@ -315,4 +323,35 @@ class post {
     public function is_private_reply_intended_for_user(stdClass $user) : bool {
         return $this->get_private_reply_recipient_id() == $user->id;
     }
+
+    /**
+     * Returns the word count.
+     *
+     * @return int|null
+     */
+    public function get_wordcount() : ?int {
+        return $this->wordcount;
+    }
+
+    /**
+     * Returns the char count.
+     *
+     * @return int|null
+     */
+    public function get_charcount() : ?int {
+        return $this->charcount;
+    }
+
+    /**
+     * This methods adds/updates forum posts' word count and char count attributes based on $data->message.
+     *
+     * @param \stdClass $record A record ready to be inserted / updated in DB.
+     * @return void.
+     */
+    public static function add_message_counts(\stdClass $record) : void {
+        if (!empty($record->message)) {
+            $record->wordcount = count_words($record->message);
+            $record->charcount = count_letters($record->message);
+        }
+    }
 }
index f854e46..8faf0f9 100644 (file)
@@ -120,6 +120,12 @@ class post extends exporter {
                 'default' => null,
                 'null' => NULL_ALLOWED
             ],
+            'charcount' => [
+                'type' => PARAM_INT,
+                'optional' => true,
+                'default' => null,
+                'null' => NULL_ALLOWED
+            ],
             'capabilities' => [
                 'type' => [
                     'view' => [
@@ -410,6 +416,15 @@ class post extends exporter {
             $replysubject = "{$strre} {$replysubject}";
         }
 
+        $showwordcount = $forum->should_display_word_count();
+        if ($showwordcount) {
+            $wordcount = $post->get_wordcount() ?? count_words($message);
+            $charcount = $post->get_charcount() ?? count_letters($message);
+        } else {
+            $wordcount = null;
+            $charcount = null;
+        }
+
         return [
             'id' => $post->get_id(),
             'subject' => $subject,
@@ -424,8 +439,9 @@ class post extends exporter {
             'unread' => ($loadcontent && $readreceiptcollection) ? !$readreceiptcollection->has_user_read_post($user, $post) : null,
             'isdeleted' => $isdeleted,
             'isprivatereply' => $isprivatereply,
-            'haswordcount' => $forum->should_display_word_count(),
-            'wordcount' => $forum->should_display_word_count() ? count_words($message) : null,
+            'haswordcount' => $showwordcount,
+            'wordcount' => $wordcount,
+            'charcount' => $charcount,
             'capabilities' => [
                 'view' => $canview,
                 'edit' => $canedit,
index e40e596..8406b99 100644 (file)
@@ -154,7 +154,9 @@ class entity {
             $record->totalscore,
             $record->mailnow,
             $record->deleted,
-            $record->privatereplyto
+            $record->privatereplyto,
+            $record->wordcount,
+            $record->charcount
         );
     }
 
diff --git a/mod/forum/classes/task/refresh_forum_post_counts.php b/mod/forum/classes/task/refresh_forum_post_counts.php
new file mode 100644 (file)
index 0000000..7a018af
--- /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/>.
+
+/**
+ * Adhoc task that updates all of the existing forum_post records with no wordcount or no charcount.
+ *
+ * @package    mod_forum
+ * @copyright  2019 David Monllao
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_forum\task;
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Adhoc task that updates all of the existing forum_post records with no wordcount or no charcount.
+ *
+ * @package     mod_forum
+ * @copyright   2019 David Monllao
+ * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class refresh_forum_post_counts extends \core\task\adhoc_task {
+
+    /**
+     * Run the task to populate word and character counts on existing forum posts.
+     * If the maximum number of records are updated, the task re-queues itself,
+     * as there may be more records to process.
+     */
+    public function execute() {
+        if ($this->update_null_forum_post_counts()) {
+            \core\task\manager::queue_adhoc_task(new refresh_forum_post_counts());
+        }
+    }
+
+    /**
+     * Updates null forum post counts according to the post message.
+     *
+     * @return bool Whether there may be more rows to process
+     */
+    protected function update_null_forum_post_counts(): bool {
+        global $CFG, $DB;
+
+        // Default to chunks of 5000 records per run, unless overridden in config.php
+        $chunksize = $CFG->forumpostcountchunksize ?? 5000;
+
+        $select = 'wordcount IS NULL OR charcount IS NULL';
+        $recordset = $DB->get_recordset_select('forum_posts', $select, null, 'discussion', 'id, message', 0, $chunksize);
+
+        if (!$recordset->valid()) {
+            $recordset->close();
+            return false;
+        }
+
+        foreach ($recordset as $record) {
+            \mod_forum\local\entities\post::add_message_counts($record);
+            $DB->update_record('forum_posts', $record);
+        }
+
+        $recordscount = count($recordset);
+        $recordset->close();
+
+        return ($recordscount == $chunksize);
+    }
+}
index ce528a4..ac12f9b 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/forum/db" VERSION="20190404" COMMENT="XMLDB file for Moodle mod/forum"
+<XMLDB PATH="mod/forum/db" VERSION="20191001" COMMENT="XMLDB file for Moodle mod/forum"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -85,6 +85,8 @@
         <FIELD NAME="mailnow" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="deleted" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="privatereplyto" TYPE="int" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="wordcount" TYPE="int" LENGTH="20" NOTNULL="false" SEQUENCE="false"/>
+        <FIELD NAME="charcount" TYPE="int" LENGTH="20" NOTNULL="false" SEQUENCE="false"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
       </KEYS>
     </TABLE>
   </TABLES>
-</XMLDB>
+</XMLDB>
\ No newline at end of file
index 918ec72..9c6d83e 100644 (file)
@@ -157,5 +157,44 @@ function xmldb_forum_upgrade($oldversion) {
     // Automatically generated Moodle v3.7.0 release upgrade line.
     // Put any upgrade step following this.
 
+    if ($oldversion < 2019071901) {
+
+        // Define field wordcount to be added to forum_posts.
+        $table = new xmldb_table('forum_posts');
+        $field = new xmldb_field('wordcount', XMLDB_TYPE_INTEGER, '20', null, null, null, null, 'privatereplyto');
+
+        // Conditionally launch add field wordcount.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field charcount to be added to forum_posts.
+        $table = new xmldb_table('forum_posts');
+        $field = new xmldb_field('charcount', XMLDB_TYPE_INTEGER, '20', null, null, null, null, 'wordcount');
+
+        // Conditionally launch add field charcount.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Forum savepoint reached.
+        upgrade_mod_savepoint(true, 2019071901, 'forum');
+    }
+
+    if ($oldversion < 2019071902) {
+        // Create adhoc task for upgrading of existing forum_posts.
+        $record = new \stdClass();
+        $record->classname = '\mod_forum\task\refresh_forum_post_counts';
+        $record->component = 'mod_forum';
+
+        // Next run time based from nextruntime computation in \core\task\manager::queue_adhoc_task().
+        $nextruntime = time() - 1;
+        $record->nextruntime = $nextruntime;
+        $DB->insert_record('task_adhoc', $record);
+
+        // Main savepoint reached.
+        upgrade_mod_savepoint(true, 2019071902, 'forum');
+    }
+
     return true;
 }
index db0eb65..0f8902c 100644 (file)
@@ -240,6 +240,7 @@ function forum_update_instance($forum, $mform) {
             $post->message = file_save_draft_area_files($draftid, $modcontext->id, 'mod_forum', 'post', $post->id, $options, $post->message);
         }
 
+        \mod_forum\local\entities\post::add_message_counts($post);
         $DB->update_record('forum_posts', $post);
         $discussion->name = $forum->name;
         $DB->update_record('forum_discussions', $discussion);
@@ -2948,6 +2949,7 @@ function forum_add_new_post($post, $mform, $unused = null) {
         $post->mailnow    = 0;
     }
 
+    \mod_forum\local\entities\post::add_message_counts($post);
     $post->id = $DB->insert_record("forum_posts", $post);
     $post->message = file_save_draft_area_files($post->itemid, $context->id, 'mod_forum', 'post', $post->id,
             mod_forum_post_form::editor_options($context, null), $post->message);
@@ -3018,6 +3020,7 @@ function forum_update_post($newpost, $mform, $unused = null) {
     }
     $post->message = file_save_draft_area_files($newpost->itemid, $context->id, 'mod_forum', 'post', $post->id,
             mod_forum_post_form::editor_options($context, $post->id), $post->message);
+    \mod_forum\local\entities\post::add_message_counts($post);
     $DB->update_record('forum_posts', $post);
     // Note: Discussion modified time/user are intentionally not updated, to enable them to track the latest new post.
     $DB->update_record('forum_discussions', $discussion);
@@ -3080,6 +3083,7 @@ function forum_add_discussion($discussion, $mform=null, $unused=null, $userid=nu
     $post->course        = $forum->course; // speedup
     $post->mailnow       = $discussion->mailnow;
 
+    \mod_forum\local\entities\post::add_message_counts($post);
     $post->id = $DB->insert_record("forum_posts", $post);
 
     // TODO: Fix the calling code so that there always is a $cm when this function is called
index d20e666..323a519 100644 (file)
@@ -72,6 +72,11 @@ class summary_table extends table_sql {
      */
     protected $context = null;
 
+    /**
+     * @var bool
+     */
+    private $showwordcharcounts = null;
+
     /**
      * @var bool Whether the user can see all private replies or not.
      */
@@ -127,6 +132,11 @@ class summary_table extends table_sql {
             $columnheaders['viewcount'] = get_string('viewcount', 'forumreport_summary');
         }
 
+        if ($this->show_word_char_counts()) {
+            $columnheaders['wordcount'] = get_string('wordcount', 'forumreport_summary');
+            $columnheaders['charcount'] = get_string('charcount', 'forumreport_summary');
+        }
+
         $columnheaders['earliestpost'] = get_string('earliestpost', 'forumreport_summary');
         $columnheaders['latestpost'] = get_string('latestpost', 'forumreport_summary');
 
@@ -459,10 +469,16 @@ class summary_table extends table_sql {
             $this->fill_log_summary_temp_table($this->context->id);
 
             $this->sql->basefields .= ', CASE WHEN tmp.viewcount IS NOT NULL THEN tmp.viewcount ELSE 0 END AS viewcount';
-            $this->sql->basefromjoins .= ' LEFT JOIN {' . self::LOG_SUMMARY_TEMP_TABLE . '} tmp ON tmp.userid = u.id';
+            $this->sql->basefromjoins .= ' LEFT JOIN {' . self::LOG_SUMMARY_TEMP_TABLE . '} tmp ON tmp.userid = u.id ';
             $this->sql->basegroupby .= ', tmp.viewcount';
         }
 
+        if ($this->show_word_char_counts()) {
+            // All p.wordcount values should be NOT NULL, this CASE WHEN is an extra just-in-case.
+            $this->sql->basefields .= ', SUM(CASE WHEN p.wordcount IS NOT NULL THEN p.wordcount ELSE 0 END) AS wordcount';
+            $this->sql->basefields .= ', SUM(CASE WHEN p.charcount IS NOT NULL THEN p.charcount ELSE 0 END) AS charcount';
+        }
+
         $this->sql->params = [
             'component' => 'mod_forum',
             'courseid' => $this->cm->course,
@@ -682,4 +698,31 @@ class summary_table extends table_sql {
         $this->is_downloading($format, $filename);
         $this->out($this->perpage, false);
     }
+
+    /*
+     * Should the word / char counts be displayed?
+     *
+     * We don't want to show word/char columns if there is any null value because this means
+     * that they have not been calculated yet.
+     * @return bool
+     */
+    protected function show_word_char_counts(): bool {
+        global $DB;
+
+        if (is_null($this->showwordcharcounts)) {
+            // This should be really fast.
+            $sql = "SELECT 'x'
+                      FROM {forum_posts} fp
+                      JOIN {forum_discussions} fd ON fd.id = fp.discussion
+                     WHERE fd.forum = :forumid AND (fp.wordcount IS NULL OR fp.charcount IS NULL)";
+
+            if ($DB->record_exists_sql($sql, ['forumid' => $this->cm->instance])) {
+                $this->showwordcharcounts = false;
+            } else {
+                $this->showwordcharcounts = true;
+            }
+        }
+
+        return $this->showwordcharcounts;
+    }
 }
index 4d5ac13..50d1068 100644 (file)
@@ -23,6 +23,7 @@
  */
 
 $string['attachmentcount'] = 'Number of attachments';
+$string['charcount'] = 'Character count';
 $string['viewcount'] = 'Number of views';
 $string['earliestpost'] = 'Earliest post';
 $string['filter:groupsbuttonlabel'] = 'Open the groups filter';
@@ -38,4 +39,5 @@ $string['replycount'] = 'Number of replies posted';
 $string['summary:viewall'] = 'Access summary report data for each user within a given forum or forums';
 $string['summary:view'] = 'Access summary report within a given forum or forums';
 $string['summarytitle'] = 'Summary report - {$a}';
-$string['viewsdisclaimer'] = 'Number of views column is not filtered by group';
\ No newline at end of file
+$string['viewsdisclaimer'] = 'Number of views column is not filtered by group';
+$string['wordcount'] = 'Word count';
index 5475d52..adc051a 100644 (file)
@@ -93,7 +93,9 @@ class mod_forum_entities_discussion_summary_testcase extends advanced_testcase {
             0,
             false,
             false,
-            false
+            false,
+            null,
+            null
         );
 
         $discussionsummary = new discussion_summary_entity($discussion, $firstpost, $firstauthor, $lastauthor);
index c3bb6f8..b477901 100644 (file)
@@ -75,7 +75,9 @@ class mod_forum_entities_discussion_testcase extends advanced_testcase {
             0,
             false,
             false,
-            false
+            false,
+            null,
+            null
         );
         $notfirstpost = new post_entity(
             1,
@@ -93,7 +95,9 @@ class mod_forum_entities_discussion_testcase extends advanced_testcase {
             0,
             false,
             false,
-            false
+            false,
+            null,
+            null
         );
 
         $this->assertEquals(1, $discussion->get_id());
index 567d66a..f1dc189 100644 (file)
@@ -58,7 +58,9 @@ class mod_forum_entities_post_read_receipt_collection_testcase extends advanced_
             0,
             false,
             false,
-            false
+            false,
+            null,
+            null
         );
         $post = new post_entity(
             1,
@@ -76,7 +78,9 @@ class mod_forum_entities_post_read_receipt_collection_testcase extends advanced_
             0,
             false,
             false,
-            false
+            false,
+            null,
+            null
         );
         $collection = new collection_entity([
             (object) [
index 62bdd8b..01ecf6e 100644 (file)
@@ -59,7 +59,9 @@ class mod_forum_entities_post_testcase extends advanced_testcase {
             0,
             false,
             false,
-            false
+            false,
+            null,
+            null
         );
 
         $this->assertEquals(4, $post->get_id());
index 84428a5..7ef7955 100644 (file)
@@ -582,6 +582,8 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         $record = new stdClass();
         $record->course = $course1->id;
         $record->trackingtype = FORUM_TRACKING_OFF;
+        // Display word count. Otherwise, word and char counts will be set to null by the forum post exporter.
+        $record->displaywordcount = true;
         $forum1 = self::getDataGenerator()->create_module('forum', $record);
         $forum1context = context_module::instance($forum1->cmid);
 
@@ -673,6 +675,8 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
         // User pictures are initially empty, we should get the links once the external function is called.
         $isolatedurl = $urlfactory->get_discussion_view_url_from_discussion_id($discussion1reply2->discussion);
         $isolatedurl->params(['parent' => $discussion1reply2->id]);
+        $message = file_rewrite_pluginfile_urls($discussion1reply2->message, 'pluginfile.php',
+            $forum1context->id, 'mod_forum', 'post', $discussion1reply2->id);
         $expectedposts['posts'][] = array(
             'id' => $discussion1reply2->id,
             'discussionid' => $discussion1reply2->discussion,
@@ -681,14 +685,14 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'timecreated' => $discussion1reply2->created,
             'subject' => $discussion1reply2->subject,
             'replysubject' => get_string('re', 'mod_forum') . " {$discussion1reply2->subject}",
-            'message' => file_rewrite_pluginfile_urls($discussion1reply2->message, 'pluginfile.php',
-                    $forum1context->id, 'mod_forum', 'post', $discussion1reply2->id),
+            'message' => $message,
             'messageformat' => 1,   // This value is usually changed by external_format_text() function.
             'unread' => null,
             'isdeleted' => false,
             'isprivatereply' => false,
-            'haswordcount' => false,
-            'wordcount' => null,
+            'haswordcount' => true,
+            'wordcount' => count_words($message),
+            'charcount' => count_letters($message),
             'author'=> $exporteduser3,
             'attachments' => [],
             'tags' => [],
@@ -728,6 +732,8 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
 
         $isolatedurl = $urlfactory->get_discussion_view_url_from_discussion_id($discussion1reply1->discussion);
         $isolatedurl->params(['parent' => $discussion1reply1->id]);
+        $message = file_rewrite_pluginfile_urls($discussion1reply1->message, 'pluginfile.php',
+            $forum1context->id, 'mod_forum', 'post', $discussion1reply1->id);
         $expectedposts['posts'][] = array(
             'id' => $discussion1reply1->id,
             'discussionid' => $discussion1reply1->discussion,
@@ -736,14 +742,14 @@ class mod_forum_external_testcase extends externallib_advanced_testcase {
             'timecreated' => $discussion1reply1->created,
             'subject' => $discussion1reply1->subject,
             'replysubject' => get_string('re', 'mod_forum') . " {$discussion1reply1->subject}",
-            'message' => file_rewrite_pluginfile_urls($discussion1reply1->message, 'pluginfile.php',
-                    $forum1context->id, 'mod_forum', 'post', $discussion1reply1->id),
+            'message' => $message,
             'messageformat' => 1,   // This value is usually changed by external_format_text() function.
             'unread' => null,
             'isdeleted' => false,
             'isprivatereply' => false,
-            'haswordcount' => false,
-            'wordcount' => null,
+            'haswordcount' => true,
+            'wordcount' => count_words($message),
+            'charcount' => count_letters($message),
             'author'=> $exporteduser2,
             'attachments' => [],
             'tags' => [],
index d8a9a7c..dad429e 100644 (file)
@@ -313,6 +313,7 @@ class mod_forum_generator extends testing_module_generator {
         }
 
         $record = (object) $record;
+        \mod_forum\local\entities\post::add_message_counts($record);
 
         // Add the post.
         $record->id = $DB->insert_record('forum_posts', $record);
index e64b5fb..dc259c0 100644 (file)
@@ -5,6 +5,10 @@ information provided here is intended especially for developers.
 
 * The following functions have been finally deprecated and can not be used anymore:
     * forum_scale_used()
+* In order for the forum summary report to calculate word count and character count data, those details are now stored
+    for each post in the database when posts are created or updated. For posts that existed prior to a Moodle 3.8 upgrade, these
+    are calculated by the refresh_forum_post_counts ad-hoc task in chunks of 5000 posts by default. Site admins are able to modify this
+    default, by setting $CFG->forumpostcountchunksize to the required integer value.
 
 === 3.7 ===
   * Changed the forum discussion rendering to use templates rather than print functions.
index 1a5da4c..9411f9f 100644 (file)
@@ -24,6 +24,6 @@
 
 defined('MOODLE_INTERNAL') || die();
 
-$plugin->version   = 2019071900;       // The current module version (Date: YYYYMMDDXX)
+$plugin->version   = 2019071902;       // The current module version (Date: YYYYMMDDXX)
 $plugin->requires  = 2019051100;       // Requires this Moodle version
 $plugin->component = 'mod_forum';      // Full name of the plugin (used for diagnostics)
index 787032b..c0b798a 100644 (file)
@@ -657,11 +657,15 @@ abstract class quiz_attempts_report_table extends table_sql {
      *
      * It returns the HTML for a master \core\output\checkbox_toggleall component that selects/deselects all quiz attempts.
      *
+     * @param string $columnname The name of the checkbox column.
      * @return string
      */
-    public function checkbox_col_header() {
+    public function checkbox_col_header(string $columnname) {
         global $OUTPUT;
 
+        // Make sure to disable sorting on this column.
+        $this->no_sorting($columnname);
+
         // Build the select/deselect all control.
         $selectallid = $this->uniqueid . '-selectall-attempts';
         $selectalltext = get_string('selectall', 'quiz');
index 7627fbe..50e752b 100644 (file)
@@ -172,8 +172,9 @@ class quiz_overview_report extends quiz_attempts_report {
             $headers = array();
 
             if (!$table->is_downloading() && $options->checkboxcolumn) {
-                $columns[] = 'checkbox';
-                $headers[] = $table->checkbox_col_header();
+                $columnname = 'checkbox';
+                $columns[] = $columnname;
+                $headers[] = $table->checkbox_col_header($columnname);
             }
 
             $this->add_user_columns($table, $columns, $headers);
index 38ee776..89f1d6e 100644 (file)
@@ -141,8 +141,9 @@ class quiz_responses_report extends quiz_attempts_report {
             $headers = array();
 
             if (!$table->is_downloading() && $options->checkboxcolumn) {
-                $columns[] = 'checkbox';
-                $headers[] = $table->checkbox_col_header();
+                $columnname = 'checkbox';
+                $columns[] = $columnname;
+                $headers[] = $table->checkbox_col_header($columnname);
             }
 
             $this->add_user_columns($table, $columns, $headers);
index cbd4b93..9c006f2 100644 (file)
@@ -2,6 +2,13 @@ This files describes API changes for quiz report plugins.
 
 Overview of this plugin type at http://docs.moodle.org/dev/Quiz_reports
 
+=== 3.8 ===
+
+* New quiz_attempts_report_table method: \quiz_attempts_report_table::checkbox_col_header()
+  This generates a column header containing a checkbox that toggles the checked state of all the checkboxes corresponding to the
+  entries listed on a given quiz report table. It requires the name of the checkbox column as a parameter in order to disable
+  sorting on the checkbox column.
+
 === 3.2 ===
 
 * A code refactoring based on new sql functions in MDL-31243 and removing