Merge branch 'MDL-68677-master' of git://github.com/andrewnicols/moodle
authorEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 14 May 2020 23:58:41 +0000 (01:58 +0200)
committerEloy Lafuente (stronk7) <stronk7@moodle.org>
Thu, 14 May 2020 23:58:41 +0000 (01:58 +0200)
133 files changed:
admin/cli/cfg.php
admin/settings/courses.php
backup/moodle2/backup_course_task.class.php
backup/moodle2/backup_root_task.class.php
backup/moodle2/backup_settingslib.php
backup/moodle2/backup_stepslib.php
backup/moodle2/restore_course_task.class.php
backup/moodle2/restore_root_task.class.php
backup/moodle2/restore_settingslib.php
backup/moodle2/restore_stepslib.php
backup/moodle2/tests/moodle2_test.php
backup/util/dbops/backup_controller_dbops.class.php
backup/util/dbops/restore_controller_dbops.class.php
backup/util/ui/tests/behat/import_contentbank_content.feature [new file with mode: 0644]
blocks/moodleblock.class.php
completion/classes/api.php
contentbank/amd/build/search.min.js [new file with mode: 0644]
contentbank/amd/build/search.min.js.map [new file with mode: 0644]
contentbank/amd/build/selectors.min.js [new file with mode: 0644]
contentbank/amd/build/selectors.min.js.map [new file with mode: 0644]
contentbank/amd/src/search.js [new file with mode: 0644]
contentbank/amd/src/selectors.js [new file with mode: 0644]
contentbank/classes/contentbank.php
contentbank/classes/output/bankcontent.php
contentbank/index.php
contentbank/templates/bankcontent.mustache
contentbank/templates/bankcontent/search.mustache [new file with mode: 0644]
contentbank/templates/bankcontent/toolbar.mustache [moved from contentbank/templates/toolbar.mustache with 91% similarity]
contentbank/tests/behat/search_content.feature [new file with mode: 0644]
contentbank/tests/content_test.php
contentbank/tests/contentbank_test.php
contentbank/tests/contenttype_test.php
contentbank/tests/external/delete_content_test.php
contentbank/tests/external/rename_content_test.php
contentbank/tests/privacy_test.php
course/amd/build/activitychooser.min.js
course/amd/build/activitychooser.min.js.map
course/amd/build/local/activitychooser/dialogue.min.js
course/amd/build/local/activitychooser/dialogue.min.js.map
course/amd/src/activitychooser.js
course/amd/src/local/activitychooser/dialogue.js
h5p/classes/editor.php
h5p/classes/editor_framework.php
h5p/classes/helper.php
h5p/classes/local/library/autoloader.php
h5p/classes/local/library/handler.php
h5p/h5plib/v124/joubel/editor/readme_moodle.txt
h5p/h5plib/v124/lang/en/h5plib_v124.php
h5p/tests/editor_test.php
h5p/tests/local/library/handler_test.php [new file with mode: 0644]
lang/en/backup.php
lang/en/contentbank.php
lib/amd/build/modal.min.js
lib/amd/build/modal.min.js.map
lib/amd/build/user_date.min.js
lib/amd/build/user_date.min.js.map
lib/amd/src/modal.js
lib/amd/src/user_date.js
lib/behat/classes/behat_core_generator.php
lib/cronlib.php
lib/external/externallib.php
lib/external/tests/external_test.php
lib/grouplib.php
lib/navigationlib.php
lib/pagelib.php
lib/table/amd/build/dynamic.min.js
lib/table/amd/build/dynamic.min.js.map
lib/table/amd/src/dynamic.js
lib/tablelib.php
lib/templates/time_element.mustache [new file with mode: 0644]
lib/testing/generator/block_generator.php
lib/testing/generator/module_generator.php
lib/testing/generator/repository_generator.php
lib/tests/grouplib_test.php
lib/upgrade.txt
mod/assign/lib.php
mod/chat/lib.php
mod/choice/locallib.php
mod/data/locallib.php
mod/feedback/lib.php
mod/forum/classes/local/exporters/post.php
mod/forum/locallib.php
mod/forum/templates/discussion_list.mustache
mod/forum/templates/forum_discussion_nested_v2_first_post.mustache
mod/forum/templates/forum_discussion_post.mustache
mod/forum/templates/forum_discussion_threaded_post.mustache
mod/forum/tests/generator/lib.php
mod/forum/view.php
mod/h5pactivity/backup/moodle2/backup_h5pactivity_stepslib.php
mod/h5pactivity/classes/local/attempt.php
mod/h5pactivity/classes/local/grader.php [new file with mode: 0644]
mod/h5pactivity/classes/local/manager.php [new file with mode: 0644]
mod/h5pactivity/classes/privacy/provider.php
mod/h5pactivity/classes/xapi/handler.php
mod/h5pactivity/db/install.xml
mod/h5pactivity/db/upgrade.php
mod/h5pactivity/lang/en/h5pactivity.php
mod/h5pactivity/lib.php
mod/h5pactivity/mod_form.php
mod/h5pactivity/tests/behat/define_settings.feature [new file with mode: 0644]
mod/h5pactivity/tests/behat/grading_attempts.feature [new file with mode: 0644]
mod/h5pactivity/tests/event/course_module_instance_list_viewed_test.php
mod/h5pactivity/tests/event/course_module_viewed_test.php
mod/h5pactivity/tests/event/statement_received_test.php
mod/h5pactivity/tests/generator/lib.php
mod/h5pactivity/tests/generator_test.php
mod/h5pactivity/tests/local/attempt_test.php
mod/h5pactivity/tests/local/grader_test.php [new file with mode: 0644]
mod/h5pactivity/tests/local/manager_test.php [new file with mode: 0644]
mod/h5pactivity/tests/privacy_test.php
mod/h5pactivity/tests/restore_test.php
mod/h5pactivity/tests/xapi/handler_test.php
mod/h5pactivity/version.php
mod/h5pactivity/view.php
mod/lesson/lib.php
mod/quiz/lib.php
mod/scorm/locallib.php
mod/upgrade.txt
mod/workshop/lib.php
pix/i/contentbank.png [new file with mode: 0644]
pix/i/contentbank.svg [new file with mode: 0644]
report/configlog/classes/output/report_table.php
report/configlog/lang/en/report_configlog.php
theme/boost/scss/moodle/core.scss
theme/boost/scss/moodle/message.scss
theme/boost/scss/moodle/tables.scss
theme/boost/scss/moodle/undo.scss
theme/boost/style/moodle.css
theme/classic/style/moodle.css
user/classes/table/participants.php
user/classes/table/participants_search.php [new file with mode: 0644]
user/tests/table/participants_search_test.php [new file with mode: 0644]
version.php

index 7f56b92..35045ca 100644 (file)
@@ -124,7 +124,12 @@ if ($options['unset'] || $options['set'] !== null) {
         cli_error('The configuration variable is hard-set in the config.php, unable to change.', 4);
     }
 
-    set_config($options['name'], $options['set'], $options['component']);
+    $new = $options['set'];
+    $old = get_config($options['component'], $options['name']);
+    if ($new !== $old) {
+        set_config($options['name'], $options['set'], $options['component']);
+        add_to_config_log($options['name'], $old, $new, $options['component']);
+    }
     exit(0);
 }
 
index 841b72b..8936f7c 100644 (file)
@@ -235,6 +235,11 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             new lang_string('generalgroups', 'backup'), new lang_string('configgeneralgroups', 'backup'),
             array('value' => 1, 'locked' => 0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_competencies', new lang_string('generalcompetencies','backup'), new lang_string('configgeneralcompetencies','backup'), array('value'=>1, 'locked'=>0)));
+    $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_general_contentbankcontent',
+        new lang_string('generalcontentbankcontent', 'backup'),
+        new lang_string('configgeneralcontentbankcontent', 'backup'),
+        ['value' => 1, 'locked' => 0])
+    );
 
     $ADMIN->add('backups', $temp);
 
@@ -256,6 +261,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
             new lang_string('generalgroups', 'backup'), new lang_string('configgeneralgroups', 'backup'),
             array('value' => 1, 'locked' => 0)));
     $temp->add(new admin_setting_configcheckbox_with_lock('backup/backup_import_competencies', new lang_string('generalcompetencies','backup'), new lang_string('configgeneralcompetencies','backup'), array('value'=>1, 'locked'=>0)));
+    $temp->add(new admin_setting_configcheckbox_with_lock(
+        'backup/backup_import_contentbankcontent',
+        new lang_string('generalcontentbankcontent', 'backup'),
+        new lang_string('configgeneralcontentbankcontent', 'backup'),
+        ['value' => 1, 'locked' => 0])
+    );
 
     $ADMIN->add('backups', $temp);
 
@@ -375,6 +386,12 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configcheckbox('backup/backup_auto_groups', new lang_string('generalgroups', 'backup'),
             new lang_string('configgeneralgroups', 'backup'), 1));
     $temp->add(new admin_setting_configcheckbox('backup/backup_auto_competencies', new lang_string('generalcompetencies','backup'), new lang_string('configgeneralcompetencies','backup'), 1));
+    $temp->add(new admin_setting_configcheckbox(
+        'backup/backup_auto_contentbankcontent',
+        new lang_string('generalcontentbankcontent', 'backup'),
+        new lang_string('configgeneralcontentbankcontent', 'backup'),
+        1)
+    );
 
     //$temp->add(new admin_setting_configcheckbox('backup/backup_auto_messages', new lang_string('messages', 'message'), new lang_string('backupmessageshelp','message'), 0));
     //$temp->add(new admin_setting_configcheckbox('backup/backup_auto_blogs', new lang_string('blogs', 'blog'), new lang_string('backupblogshelp','blog'), 0));
@@ -435,6 +452,9 @@ if ($hassiteconfig or has_any_capability($capabilities, $systemcontext)) {
     $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_competencies',
         new lang_string('generalcompetencies', 'backup'),
         new lang_string('configrestorecompetencies', 'backup'), array('value' => 1, 'locked' => 0)));
+    $temp->add(new admin_setting_configcheckbox_with_lock('restore/restore_general_contentbankcontent',
+        new lang_string('generalcontentbankcontent', 'backup'),
+        new lang_string('configrestorecontentbankcontent', 'backup'), array('value' => 1, 'locked' => 0)));
 
     // Restore defaults when merging into another course.
     $temp->add(new admin_setting_heading('mergerestoredefaults', new lang_string('mergerestoredefaults', 'backup'), ''));
index d9060e3..d2ac8c5 100644 (file)
@@ -139,6 +139,11 @@ class backup_course_task extends backup_task {
         // Migrate the already exported inforef entries to final ones
         $this->add_step(new move_inforef_annotations_to_final('migrate_inforef'));
 
+        // Generate the content bank file (conditionally).
+        if ($this->get_setting_value('contentbankcontent')) {
+            $this->add_step(new backup_contentbankcontent_structure_step('course_contentbank', 'contentbank.xml'));
+        }
+
         // At the end, mark it as built
         $this->built = true;
     }
index ee434c1..4771c39 100644 (file)
@@ -179,5 +179,10 @@ class backup_root_task extends backup_task {
         $customfields = new backup_customfield_setting('customfield', base_setting::IS_BOOLEAN, true);
         $customfields->set_ui(new backup_setting_ui_checkbox($customfields, get_string('rootsettingcustomfield', 'backup')));
         $this->add_setting($customfields);
+
+        // Define content bank content inclusion setting.
+        $contentbank = new backup_contentbankcontent_setting('contentbankcontent', base_setting::IS_BOOLEAN, true);
+        $contentbank->set_ui(new backup_setting_ui_checkbox($contentbank, get_string('rootsettingcontentbankcontent', 'backup')));
+        $this->add_setting($contentbank);
     }
 }
index 9125673..59ea252 100644 (file)
@@ -199,3 +199,9 @@ class backup_activity_included_setting extends activity_backup_setting {}
  * user information or no, depends of @backup_users_setting
  */
 class backup_activity_userinfo_setting extends activity_backup_setting {}
+
+/**
+ * Root setting to control if backup will include content bank content or no
+ */
+class backup_contentbankcontent_setting extends backup_generic_setting {
+}
index ac8c917..cf0115a 100644 (file)
@@ -2740,3 +2740,34 @@ class backup_completion_defaults_structure_step extends backup_structure_step {
 
     }
 }
+
+/**
+ * Structure step in charge of constructing the contentbank.xml file for all the contents found in a given context
+ */
+class backup_contentbankcontent_structure_step extends backup_structure_step {
+
+    /**
+     * Define structure for content bank step
+     */
+    protected function define_structure() {
+
+        // Define each element separated.
+        $contents = new backup_nested_element('contents');
+        $content = new backup_nested_element('content', ['id'], [
+            'name', 'contenttype', 'instanceid', 'configdata', 'usercreated', 'usermodified', 'timecreated', 'timemodified']);
+
+        // Build the tree.
+        $contents->add_child($content);
+
+        // Define sources.
+        $content->set_source_table('contentbank_content', ['contextid' => backup::VAR_CONTEXTID]);
+
+        // Define annotations.
+        $content->annotate_ids('user', 'usercreated');
+        $content->annotate_ids('user', 'usermodified');
+        $content->annotate_files('contentbank', 'public', 'id');
+
+        // Return the root element (contents).
+        return $contents;
+    }
+}
index 6bcf826..be4f3f9 100644 (file)
@@ -126,6 +126,11 @@ class restore_course_task extends restore_task {
         // Activity completion defaults.
         $this->add_step(new restore_completion_defaults_structure_step('course_completion_defaults', 'completiondefaults.xml'));
 
+        // Content bank content (conditionally).
+        if ($this->get_setting_value('contentbankcontent')) {
+            $this->add_step(new restore_contentbankcontent_structure_step('course_contentbank', 'contentbank.xml'));
+        }
+
         // At the end, mark it as built
         $this->built = true;
     }
index 3069a0b..632985f 100644 (file)
@@ -290,5 +290,17 @@ class restore_root_task extends restore_task {
         $customfields = new restore_customfield_setting('customfields', base_setting::IS_BOOLEAN, $defaultvalue);
         $customfields->set_ui(new backup_setting_ui_checkbox($customfields, get_string('rootsettingcustomfield', 'backup')));
         $this->add_setting($customfields);
+
+        // Define Content bank content.
+        $defaultvalue = false;
+        $changeable = false;
+        if (isset($rootsettings['contentbankcontent']) && $rootsettings['contentbankcontent']) { // Only enabled when available.
+            $defaultvalue = true;
+            $changeable = true;
+        }
+        $contents = new restore_contentbankcontent_setting('contentbankcontent', base_setting::IS_BOOLEAN, $defaultvalue);
+        $contents->set_ui(new backup_setting_ui_checkbox($contents, get_string('rootsettingcontentbankcontent', 'backup')));
+        $contents->get_ui()->set_changeable($changeable);
+        $this->add_setting($contents);
     }
 }
index daa3fec..61124de 100644 (file)
@@ -236,3 +236,9 @@ class restore_activity_included_setting extends restore_activity_generic_setting
  * user information or no, depends of @restore_users_setting
  */
 class restore_activity_userinfo_setting extends restore_activity_generic_setting {}
+
+/**
+ * root setting to control if restore will create content bank content or no
+ */
+class restore_contentbankcontent_setting extends restore_generic_setting {
+}
index 10199e0..223b849 100644 (file)
@@ -3984,6 +3984,60 @@ class restore_activity_grade_history_structure_step extends restore_structure_st
     }
 }
 
+/**
+ * This structure steps restores the content bank content
+ */
+class restore_contentbankcontent_structure_step extends restore_structure_step {
+
+    /**
+     * Define structure for content bank step
+     */
+    protected function define_structure() {
+
+        $paths = [];
+        $paths[] = new restore_path_element('contentbankcontent', '/contents/content');
+
+        return $paths;
+    }
+
+    /**
+     * Define data processed for content bank
+     *
+     * @param mixed  $data
+     */
+    public function process_contentbankcontent($data) {
+        global $DB;
+
+        $data = (object)$data;
+        $oldid = $data->id;
+
+        $params = [
+            'name'           => $data->name,
+            'contextid'      => $this->task->get_contextid(),
+            'contenttype'    => $data->contenttype,
+            'instanceid'     => $data->instanceid,
+            'timecreated'    => $data->timecreated,
+        ];
+        $exists = $DB->record_exists('contentbank_content', $params);
+        if (!$exists) {
+            $params['configdata'] = $data->configdata;
+            $params['usercreated'] = $this->get_mappingid('user', $data->usercreated);
+            $params['usermodified'] = $this->get_mappingid('user', $data->usermodified);
+            $params['timemodified'] = time();
+            $newitemid = $DB->insert_record('contentbank_content', $params);
+            $this->set_mapping('contentbank_content', $oldid, $newitemid, true);
+        }
+    }
+
+    /**
+     * Define data processed after execute for content bank
+     */
+    protected function after_execute() {
+        // Add related files.
+        $this->add_related_files('contentbank', 'public', 'contentbank_content');
+    }
+}
+
 /**
  * This structure steps restores one instance + positions of one block
  * Note: Positions corresponding to one existing context are restored
index 6649934..b4fed99 100644 (file)
@@ -1063,4 +1063,32 @@ class core_backup_moodle2_testcase extends advanced_testcase {
             }
         }
     }
+
+    /**
+     * Test the content bank content through a backup and restore.
+     */
+    public function test_contentbank_content_backup() {
+        global $DB, $USER, $CFG;
+        $this->resetAfterTest();
+
+        $this->setAdminUser();
+        $generator = $this->getDataGenerator();
+        $cbgenerator = $this->getDataGenerator()->get_plugin_generator('core_contentbank');
+
+        // Create course and add content bank content.
+        $course = $generator->create_course();
+        $context = context_course::instance($course->id);
+        $filepath = $CFG->dirroot . '/h5p/tests/fixtures/filltheblanks.h5p';
+        $contents = $cbgenerator->generate_contentbank_data('contenttype_h5p', 2, $USER->id, $context, true, $filepath);
+        $this->assertEquals(2, $DB->count_records('contentbank_content'));
+
+        // Do backup and restore.
+        $newcourseid = $this->backup_and_restore($course);
+
+        // Confirm that values were transferred correctly into content bank on new course.
+        $newcontext = context_course::instance($newcourseid);
+
+        $this->assertEquals(4, $DB->count_records('contentbank_content'));
+        $this->assertEquals(2, $DB->count_records('contentbank_content', ['contextid' => $newcontext->id]));
+    }
 }
index ce9c1ba..9e09f55 100644 (file)
@@ -564,7 +564,8 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_general_histories'          => 'grade_histories',
                         'backup_general_questionbank'       => 'questionbank',
                         'backup_general_groups'             => 'groups',
-                        'backup_general_competencies'       => 'competencies'
+                        'backup_general_competencies'       => 'competencies',
+                        'backup_general_contentbankcontent' => 'contentbankcontent',
                 );
                 self::apply_admin_config_defaults($controller, $settings, true);
                 break;
@@ -577,7 +578,8 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_import_calendarevents'     => 'calendarevents',
                         'backup_import_questionbank'       => 'questionbank',
                         'backup_import_groups'             => 'groups',
-                        'backup_import_competencies'       => 'competencies'
+                        'backup_import_competencies'       => 'competencies',
+                        'backup_import_contentbankcontent' => 'contentbankcontent',
                 );
                 self::apply_admin_config_defaults($controller, $settings, true);
                 if ((!$controller->get_interactive()) &&
@@ -608,7 +610,8 @@ abstract class backup_controller_dbops extends backup_dbops {
                         'backup_auto_histories'          => 'grade_histories',
                         'backup_auto_questionbank'       => 'questionbank',
                         'backup_auto_groups'             => 'groups',
-                        'backup_auto_competencies'       => 'competencies'
+                        'backup_auto_competencies'       => 'competencies',
+                        'backup_auto_contentbankcontent' => 'contentbankcontent'
                 );
                 self::apply_admin_config_defaults($controller, $settings, false);
                 break;
index 6649777..148655b 100644 (file)
@@ -157,7 +157,8 @@ abstract class restore_controller_dbops extends restore_dbops {
             'restore_general_histories'          => 'grade_histories',
             'restore_general_questionbank'       => 'questionbank',
             'restore_general_groups'             => 'groups',
-            'restore_general_competencies'       => 'competencies'
+            'restore_general_competencies'       => 'competencies',
+            'restore_general_contentbankcontent' => 'contentbankcontent'
         );
         self::apply_admin_config_defaults($controller, $settings, true);
 
diff --git a/backup/util/ui/tests/behat/import_contentbank_content.feature b/backup/util/ui/tests/behat/import_contentbank_content.feature
new file mode 100644 (file)
index 0000000..68f2fb3
--- /dev/null
@@ -0,0 +1,45 @@
+@core @core_backup @core_contentbank
+Feature: Import course content bank content
+  In order to import content from a course contentbank
+  As a teacher
+  I need to confirm that errors will not happen
+
+  Background:
+    Given the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+      | Course 2 | C2        | 0        |
+    And the following "users" exist:
+      | username | firstname | lastname | email |
+      | teacher1 | Teacher | 1 | teacher1@example.com |
+    And the following "course enrolments" exist:
+      | user | course | role |
+      | teacher1 | C1 | editingteacher |
+      | teacher1 | C2 | editingteacher |
+    And the following "contentbank content" exist:
+      | course| contenttype     | user     | contentname       |
+      | C1    | contenttype_h5p | teacher1 | ipsums.h5p        |
+    And I log in as "teacher1"
+
+  Scenario: Import content bank content to another course
+    Given I am on "Course 2" course homepage
+    And I click on "Content bank" "link"
+    And I should not see "ipsums.h5p"
+    When I import "Course 1" course into "Course 2" course using this options:
+    And I click on "Content bank" "link"
+    Then I should see "ipsums.h5p"
+    And I am on "Course 1" course homepage
+    And I click on "Content bank" "link"
+    And I should see "ipsums.h5p"
+
+  Scenario: User could configure not to import content bank
+    Given I am on "Course 2" course homepage
+    And I click on "Content bank" "link"
+    And I should not see "ipsums.h5p"
+    When I import "Course 1" course into "Course 2" course using this options:
+      | Initial | Include content bank content | 0 |
+    And I click on "Content bank" "link"
+    Then I should not see "ipsums.h5p"
+    And I am on "Course 1" course homepage
+    And I click on "Content bank" "link"
+    And I should see "ipsums.h5p"
index dd45eed..873434b 100644 (file)
@@ -221,6 +221,11 @@ class block_base {
     public function get_content_for_output($output) {
         global $CFG;
 
+        // We can exit early if the current user doesn't have the capability to view the block.
+        if (!has_capability('moodle/block:view', $this->context)) {
+            return null;
+        }
+
         $bc = new block_contents($this->html_attributes());
         $bc->attributes['data-block'] = $this->name();
         $bc->blockinstanceid = $this->instance->id;
index b569e37..f55aa90 100644 (file)
@@ -87,7 +87,8 @@ class api {
             if ($completionexpectedtime !== null) {
                 // Calendar event exists so update it.
                 $event->name = get_string('completionexpectedfor', 'completion', $lang);
-                $event->description = format_module_intro($modulename, $instance, $cmid);
+                $event->description = format_module_intro($modulename, $instance, $cmid, false);
+                $event->format = FORMAT_HTML;
                 $event->timestart = $completionexpectedtime;
                 $event->timesort = $completionexpectedtime;
                 $event->visible = instance_is_visible($modulename, $instance);
@@ -104,7 +105,8 @@ class api {
             // Event doesn't exist so create one.
             if ($completionexpectedtime !== null) {
                 $event->name = get_string('completionexpectedfor', 'completion', $lang);
-                $event->description = format_module_intro($modulename, $instance, $cmid);
+                $event->description = format_module_intro($modulename, $instance, $cmid, false);
+                $event->format = FORMAT_HTML;
                 $event->courseid = $instance->course;
                 $event->groupid = 0;
                 $event->userid = 0;
diff --git a/contentbank/amd/build/search.min.js b/contentbank/amd/build/search.min.js
new file mode 100644 (file)
index 0000000..9090c9b
Binary files /dev/null and b/contentbank/amd/build/search.min.js differ
diff --git a/contentbank/amd/build/search.min.js.map b/contentbank/amd/build/search.min.js.map
new file mode 100644 (file)
index 0000000..a3ddca1
Binary files /dev/null and b/contentbank/amd/build/search.min.js.map differ
diff --git a/contentbank/amd/build/selectors.min.js b/contentbank/amd/build/selectors.min.js
new file mode 100644 (file)
index 0000000..c7322b9
Binary files /dev/null and b/contentbank/amd/build/selectors.min.js differ
diff --git a/contentbank/amd/build/selectors.min.js.map b/contentbank/amd/build/selectors.min.js.map
new file mode 100644 (file)
index 0000000..99b3b56
Binary files /dev/null and b/contentbank/amd/build/selectors.min.js.map differ
diff --git a/contentbank/amd/src/search.js b/contentbank/amd/src/search.js
new file mode 100644 (file)
index 0000000..bd5cb55
--- /dev/null
@@ -0,0 +1,160 @@
+// 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/>.
+
+/**
+ * Search methods for finding contents in the content bank.
+ *
+ * @module     core_contentbank/search
+ * @package    core_contentbank
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+import $ from 'jquery';
+import selectors from 'core_contentbank/selectors';
+import {get_string as getString} from 'core/str';
+import Pending from 'core/pending';
+import {debounce} from 'core/utils';
+
+/**
+ * Set up the search.
+ *
+ * @method init
+ */
+export const init = () => {
+    const pendingPromise = new Pending();
+
+    const root = $(selectors.elements.main);
+    registerListenerEvents(root);
+
+    pendingPromise.resolve();
+};
+
+/**
+ * Register contentbank search related event listeners.
+ *
+ * @method registerListenerEvents
+ * @param {Object} root The root element for the contentbank.
+ */
+const registerListenerEvents = (root) => {
+
+    const searchInput = root.find(selectors.elements.searchinput)[0];
+
+    root.on('click', selectors.actions.search, function(e) {
+        e.preventDefault();
+        toggleSearchResultsView(root, searchInput.value);
+    });
+
+    root.on('click', selectors.actions.clearSearch, function(e) {
+        e.preventDefault();
+        searchInput.value = "";
+        searchInput.focus();
+        toggleSearchResultsView(root, searchInput.value);
+    });
+
+    // The search input is also triggered.
+    searchInput.addEventListener('input', debounce(() => {
+        // Display the search results.
+        toggleSearchResultsView(root, searchInput.value);
+    }, 300));
+
+};
+
+/**
+ * Toggle (display/hide) the search results depending on the value of the search query.
+ *
+ * @method toggleSearchResultsView
+ * @param {HTMLElement} body The root element for the contentbank.
+ * @param {String} searchQuery The search query.
+ */
+const toggleSearchResultsView = async(body, searchQuery) => {
+    const clearSearchButton = body.find(selectors.elements.clearsearch)[0];
+    const searchIcon = body.find(selectors.elements.searchicon)[0];
+
+    const navbarBreadcrumb = body.find(selectors.elements.cbnavbarbreadcrumb)[0];
+    const navbarTotal = body.find(selectors.elements.cbnavbartotalsearch)[0];
+    // Update the results.
+    const filteredContents = filterContents(body, searchQuery);
+    if (searchQuery.length > 0) {
+        // As the search query is present, search results should be displayed.
+
+        // Display the "clear" search button in the activity chooser search bar.
+        searchIcon.classList.add('d-none');
+        clearSearchButton.classList.remove('d-none');
+
+        // Change the cb-navbar to display total items found.
+        navbarBreadcrumb.classList.add('d-none');
+        navbarTotal.innerHTML = await getString('itemsfound', 'core_contentbank', filteredContents.length);
+        navbarTotal.classList.remove('d-none');
+    } else {
+        // As search query is not present, the search results should be removed.
+
+        // Hide the "clear" search button in the activity chooser search bar.
+        clearSearchButton.classList.add('d-none');
+        searchIcon.classList.remove('d-none');
+
+        // Display again the breadcrumb in the navbar.
+        navbarBreadcrumb.classList.remove('d-none');
+        navbarTotal.classList.add('d-none');
+    }
+};
+
+/**
+ * Return the list of contents which have a name that matches the given search term.
+ *
+ * @method filterContents
+ * @param {HTMLElement} body The root element for the contentbank.
+ * @param {String} searchTerm The search term to match.
+ * @return {Array}
+ */
+const filterContents = (body, searchTerm) => {
+    const contents = Array.from(body.find(selectors.elements.cbfile));
+    const searchResults = [];
+    contents.forEach((content) => {
+        const contentName = content.getAttribute('data-file');
+        if (searchTerm === '' || contentName.toLowerCase().includes(searchTerm.toLowerCase())) {
+            // The content matches the search criteria so it should be displayed and hightlighted.
+            searchResults.push(content);
+            const contentNameElement = content.querySelector(selectors.regions.cbcontentname);
+            contentNameElement.innerHTML = highlight(contentName, searchTerm);
+            content.classList.remove('d-none');
+        } else {
+            content.classList.add('d-none');
+        }
+    });
+
+    return searchResults;
+};
+
+/**
+ * Highlight a given string in a text.
+ *
+ * @method highlight
+ * @param  {String} text The whole text.
+ * @param  {String} highlightText The piece of text to highlight.
+ * @return {String}
+ */
+const highlight = (text, highlightText) => {
+    let result = text;
+    if (highlightText !== '') {
+        const pos = text.toLowerCase().indexOf(highlightText.toLowerCase());
+        if (pos > -1) {
+            result = text.substr(0, pos) + '<span class="matchtext">' + text.substr(pos, highlightText.length) + '</span>' +
+                text.substr(pos + highlightText.length);
+        }
+    }
+
+    return result;
+};
diff --git a/contentbank/amd/src/selectors.js b/contentbank/amd/src/selectors.js
new file mode 100644 (file)
index 0000000..080f85f
--- /dev/null
@@ -0,0 +1,54 @@
+// 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/>.
+
+/**
+ * Define all of the selectors we will be using on the contentbank interface.
+ *
+ * @module     core_contentbank/selectors
+ * @package    core_contentbank
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * A small helper function to build queryable data selectors.
+ *
+ * @method getDataSelector
+ * @param {String} name
+ * @param {String} value
+ * @return {string}
+ */
+const getDataSelector = (name, value) => {
+    return `[data-${name}="${value}"]`;
+};
+
+export default {
+    regions: {
+        cbcontentname: getDataSelector('region', 'cb-content-name'),
+    },
+    actions: {
+        search: getDataSelector('action', 'searchcontent'),
+        clearSearch: getDataSelector('action', 'clearsearchcontent'),
+    },
+    elements: {
+        cbfile: '.cb-file',
+        cbnavbarbreadcrumb: '.cb-navbar-breadbrumb',
+        cbnavbartotalsearch: '.cb-navbar-totalsearch',
+        clearsearch: '.input-group-append .clear-icon',
+        main: '#region-main',
+        searchicon: '.input-group-append .search-icon',
+        searchinput: '#searchinput',
+    },
+};
index 35499ce..197a36c 100644 (file)
@@ -42,7 +42,7 @@ class contentbank {
      *
      * @return string[] Array of contentbank contenttypes.
      */
-    private function get_enabled_content_types(): array {
+    public function get_enabled_content_types(): array {
         $enabledtypes = \core\plugininfo\contenttype::get_enabled_plugins();
         $types = [];
         foreach ($enabledtypes as $name) {
@@ -159,20 +159,28 @@ class contentbank {
      * Find the contents with %$search% in the contextid defined.
      * If contextid and search are empty, all contents are returned.
      * In all the cases, only the contents for the enabled contentbank-type plugins are returned.
+     * No content-type permissions are validated here. It is the caller responsability to check that the user can access to them.
+     * The only validation done here is, for each content, a call to the method $content->is_view_allowed().
      *
      * @param  string|null $search Optional string to search (for now it will search only into the name).
      * @param  int $contextid Optional contextid to search.
+     * @param  array $contenttypenames Optional array with the list of content-type names to search.
      * @return array The contents for the enabled contentbank-type plugins having $search as name and placed in $contextid.
      */
-    public function search_contents(?string $search = null, ?int $contextid = 0): array {
+    public function search_contents(?string $search = null, ?int $contextid = 0, ?array $contenttypenames = null): array {
         global $DB;
 
         $contents = [];
 
         // Get only contents for enabled content-type plugins.
-        $contenttypes = array_map(function($contenttypename) {
-            return "contenttype_$contenttypename";
-        }, $this->get_enabled_content_types());
+        $contenttypes = [];
+        $enabledcontenttypes = $this->get_enabled_content_types();
+        foreach ($enabledcontenttypes as $contenttypename) {
+            if (empty($contenttypenames) || in_array($contenttypename, $contenttypenames)) {
+                $contenttypes[] = "contenttype_$contenttypename";
+            }
+        }
+
         if (empty($contenttypes)) {
             // Early return if there are no content-type plugins enabled.
             return $contents;
@@ -193,7 +201,7 @@ class contentbank {
             $params['name'] = '%' . $DB->sql_like_escape($search) . '%';
         }
 
-        $records = $DB->get_records_select('contentbank_content', $sql, $params);
+        $records = $DB->get_records_select('contentbank_content', $sql, $params, 'name ASC');
         foreach ($records as $record) {
             $contentclass = "\\$record->contenttype\\content";
             $content = new $contentclass($record);
index ac1a855..471266e 100644 (file)
@@ -72,22 +72,22 @@ class bankcontent implements renderable, templatable {
      * @return stdClass
      */
     public function export_for_template(renderer_base $output): stdClass {
+        global $PAGE;
+
+        $PAGE->requires->js_call_amd('core_contentbank/search', 'init');
+
         $data = new stdClass();
         $contentdata = array();
         foreach ($this->contents as $content) {
             $record = $content->get_content();
-            $managerclass = $content->get_content_type().'\\contenttype';
-            if (class_exists($managerclass)) {
-                $manager = new $managerclass($this->context);
-                if ($manager->can_access()) {
-                    $name = $content->get_name();
-                    $contentdata[] = array(
-                        'name' => $name,
-                        'link' => $manager->get_view_url($record),
-                        'icon' => $manager->get_icon($name)
-                    );
-                }
-            }
+            $contenttypeclass = $content->get_content_type().'\\contenttype';
+            $contenttype = new $contenttypeclass($this->context);
+            $name = $content->get_name();
+            $contentdata[] = array(
+                'name' => $name,
+                'link' => $contenttype->get_view_url($record),
+                'icon' => $contenttype->get_icon($name)
+            );
         }
         $data->contents = $contentdata;
         $data->tools = $this->toolbar;
index bbf9786..f4f101b 100644 (file)
@@ -46,9 +46,19 @@ $PAGE->set_title($title);
 $PAGE->set_heading($title);
 $PAGE->set_pagetype('contenbank');
 
-// Get all contents managed by active plugins to render.
+// Get all contents managed by active plugins where the user has permission to render them.
 $cb = new \core_contentbank\contentbank();
-$foldercontents = $cb->search_contents($search, $contextid);
+$contenttypes = [];
+$enabledcontenttypes = $cb->get_enabled_content_types();
+foreach ($enabledcontenttypes as $contenttypename) {
+    $contenttypeclass = "\\contenttype_$contenttypename\\contenttype";
+    $contenttype = new $contenttypeclass($context);
+    if ($contenttype->can_access()) {
+        $contenttypes[] = $contenttypename;
+    }
+}
+
+$foldercontents = $cb->search_contents($search, $contextid, $contenttypes);
 
 // Get the toolbar ready.
 $toolbar = array ();
index 2212b81..396c297 100644 (file)
     }
 
 }}
-{{>core_contentbank/toolbar}}
+<div class="d-flex justify-content-between flex-column flex-sm-row">
+    <div class="cb-search-container mb-2">
+        {{>core_contentbank/bankcontent/search}}
+    </div>
+    <div class="cb-toolbar-container mb-2">
+        {{>core_contentbank/bankcontent/toolbar}}
+    </div>
+</div>
 <div class="content-bank-container pb-3 border">
     <div class="content-bank">
         <div class="cb-navbar bg-light p-2 border-bottom">
-            {{#pix}} i/folder {{/pix}}
+            <div class="cb-navbar-breadbrumb">
+                {{#pix}} i/folder {{/pix}}
+            </div>
+            <div class="cb-navbar-totalsearch d-none">
+            </div>
         </div>
         <div class="cb-content-wrapper d-flex flex-wrap p-2">
         {{#contents}}
-            <div class="cb-file position-relative mb-2">
+            <div class="cb-file position-relative mb-2" data-file="{{{name}}}">
                 <div class="p-2">
                     <div class="cb-thumbnail mb-1 text-center">
                         {{{ icon }}}
@@ -60,7 +71,7 @@
                     {{#link}}
                         <a href="{{{ link }}}" class="stretched-link" title="{{{name}}}">
                     {{/link}}
-                            <span class="cb-name word-break-all clamp-2 text-center" >
+                            <span class="cb-name word-break-all clamp-2 text-center" data-region="cb-content-name">
                                 {{{ name }}}
                             </span>
                     {{#link}}
diff --git a/contentbank/templates/bankcontent/search.mustache b/contentbank/templates/bankcontent/search.mustache
new file mode 100644 (file)
index 0000000..8d02863
--- /dev/null
@@ -0,0 +1,51 @@
+{{!
+    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_contentbank/bankcontent/search
+
+    Example context (json):
+    {}
+
+}}
+<div class="searchbar input-group" role="search">
+    <label for="searchinput">
+        <span class="sr-only">{{#str}} searchcontentbankbyname, contentbank {{/str}}</span>
+    </label>
+    <input type="text"
+           id="searchinput"
+           class="form-control searchinput border-right-0"
+           placeholder="{{#str}} search, core {{/str}}"
+           name="search"
+           autocomplete="off"
+    >
+    <div class="input-group-append">
+        <div class="input-group-text bg-transparent">
+            <div class="search-icon">
+                <button class="btn p-0 align-baseline icon-no-margin" data-action="searchcontent"
+                    aria-label="{{#str}} search, core {{/str}}">
+                    <span class="d-flex" aria-hidden="true">{{#pix}} a/search, core {{/pix}}</span>
+                </button>
+            </div>
+            <div class="clear-icon d-none">
+                <button class="btn p-0 align-baseline icon-no-margin" data-action="clearsearchcontent"
+                    aria-label="{{#str}} clearsearch, core {{/str}}">
+                    <span class="d-flex" aria-hidden="true">{{#pix}} e/cancel_solid_circle, core {{/pix}}</span>
+                </button>
+            </div>
+        </div>
+    </div>
+</div>
\ No newline at end of file
similarity index 91%
rename from contentbank/templates/toolbar.mustache
rename to contentbank/templates/bankcontent/toolbar.mustache
index 9ab8024..88f4a4c 100644 (file)
@@ -15,7 +15,7 @@
     along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 }}
 {{!
-    @template core_contentbank/toolbar
+    @template core_contentbank/bankcontent/toolbar
 
     Example context (json):
     {
@@ -38,7 +38,7 @@
         {{#tools}}
             {{#link}}<a href="{{{ link }}}" title="{{{ name }}}">{{/link}}
                 <div class="cb-tool icon-no-margin btn btn-secondary btn-lg">
-                    {{#pix}} {{{ icon }}} {{/pix}}
+                    {{#pix}} {{{ icon }}} {{/pix}} <span class="sr-only">{{{ name }}}</span>
                 </div>
             {{#link}}</a>{{/link}}
         {{/tools}}
diff --git a/contentbank/tests/behat/search_content.feature b/contentbank/tests/behat/search_content.feature
new file mode 100644 (file)
index 0000000..940415f
--- /dev/null
@@ -0,0 +1,51 @@
+@core @core_contentbank @contentbank_h5p @_file_upload @javascript
+Feature: Search content in the content bank
+  In order to find easily content in the content bank
+  As an admin
+  I need to be able to search content in the content bank
+
+  Background:
+    Given the following "contentbank content" exist:
+        | contextid | contenttype       | user  | contentname          |
+        | 1         | contenttype_h5p   | admin | santjordi.h5p        |
+        | 1         | contenttype_h5p   | admin | santjordi_rose.h5p   |
+        | 1         | contenttype_h5p   | admin | SantJordi_book       |
+        | 1         | contenttype_h5p   | admin | Dragon_santjordi.h5p |
+        | 1         | contenttype_h5p   | admin | princess.h5p         |
+        | 1         | contenttype_h5p   | admin | mathsbook.h5p        |
+        | 1         | contenttype_h5p   | admin | historybook.h5p      |
+        | 1         | contenttype_h5p   | admin | santvicenc.h5p       |
+
+  Scenario: Admins can search content in the content bank
+    Given 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 expand "Site pages" node
+    And I click on "Content bank" "link"
+    And I should see "santjordi.h5p"
+    And "Clear search input" "button" should not exist
+    And I should not see "items found"
+    When I set the field "Search" to "book"
+    Then "Clear search input" "button" should exist
+    And I should see "3 items found"
+    And I should see "SantJordi_book"
+    And I should see "mathsbook.h5p"
+    And I should see "historybook.h5p"
+    And I set the field "Search" to "sant"
+    And "Clear search input" "button" should exist
+    And I should see "5 items found"
+    And I set the field "Search" to "santjordi"
+    And I should see "4 items found"
+    And I should see "santjordi.h5p"
+    And I should see "santjordi_rose.h5p"
+    And I should see "SantJordi_book"
+    And I should see "Dragon_santjordi.h5p"
+    And I click on "Clear search input" "button"
+    And "Clear search input" "button" should not exist
+    And I should not see "items found"
+    And I set the field "Search" to ".h5p"
+    And "Clear search input" "button" should exist
+    And I should see "7 items found"
+    And I set the field "Search" to "friend"
+    And I should see "0 items found"
index 2fcd66c..75d44c9 100644 (file)
 
 namespace core_contentbank;
 
-defined('MOODLE_INTERNAL') || die();
-
-global $CFG;
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
-
 use stdClass;
 use context_system;
 use contenttype_testable\contenttype as contenttype;
+
 /**
  * Test for content bank contenttype class.
  *
@@ -46,6 +41,16 @@ use contenttype_testable\contenttype as contenttype;
  */
 class core_contenttype_content_testcase extends \advanced_testcase {
 
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
     /**
      * Tests for behaviour of get_name().
      *
index e420456..1999559 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-defined('MOODLE_INTERNAL') || die();
+namespace core_contentbank;
 
-global $CFG;
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+use advanced_testcase;
+use context_course;
+use context_system;
 
 /**
  * Test for extensions manager.
@@ -38,6 +39,16 @@ require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.p
  * @coversDefaultClass \core_contentbank\contentbank
  */
 class core_contentbank_testcase extends advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+    }
+
     /**
      * Data provider for test_get_extension_supporter.
      *
@@ -62,7 +73,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension(string $filename, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
 
         $extension = $cb->get_extension($filename);
         $this->assertEquals($expected, $extension);
@@ -93,7 +104,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension_supporter_for_admins(array $supporters, string $extension, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $expectedsupporters = [$extension => $expected];
 
         $systemcontext = context_system::instance();
@@ -117,7 +128,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension_supporter_for_users(array $supporters, string $extension, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $systemcontext = context_system::instance();
 
         // Set a user with no permissions.
@@ -142,7 +153,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension_supporter_for_teachers(array $supporters, string $extension, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $expectedsupporters = [$extension => $expected];
 
         $course = $this->getDataGenerator()->create_course();
@@ -168,7 +179,7 @@ class core_contentbank_testcase extends advanced_testcase {
     public function test_get_extension_supporter(array $supporters, string $extension, string $expected) {
         $this->resetAfterTest();
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $systemcontext = context_system::instance();
         $this->setAdminUser();
 
@@ -185,7 +196,8 @@ class core_contentbank_testcase extends advanced_testcase {
      * @param  int $expectedresult Expected result.
      * @param  array $contexts List of contexts where to create content.
      */
-    public function test_search_contents(?string $search, string $where, int $expectedresult, array $contexts = []): void {
+    public function test_search_contents(?string $search, string $where, int $expectedresult, array $contexts = [],
+            array $contenttypes = null): void {
         global $DB;
 
         $this->resetAfterTest();
@@ -218,8 +230,8 @@ class core_contentbank_testcase extends advanced_testcase {
         }
 
         // Search for some content.
-        $cb = new \core_contentbank\contentbank();
-        $contents = $cb->search_contents($search, $contextid);
+        $cb = new contentbank();
+        $contents = $cb->search_contents($search, $contextid, $contenttypes);
 
         $this->assertCount($expectedresult, $contents);
         if (!empty($contents) && !empty($search)) {
@@ -321,6 +333,13 @@ class core_contentbank_testcase extends advanced_testcase {
                 0,
                 []
             ],
+            'Search with unexisting content-type' => [
+                null,
+                'course',
+                0,
+                ['system', 'category', 'course'],
+                ['contenttype_unexisting'],
+            ],
         ];
     }
 
@@ -350,7 +369,7 @@ class core_contentbank_testcase extends advanced_testcase {
         $fs = get_file_storage();
         $dummyh5pfile = $fs->create_file_from_string($dummyh5p, 'Dummy H5Pcontent');
 
-        $cb = new \core_contentbank\contentbank();
+        $cb = new contentbank();
         $content = $cb->create_content_from_file($systemcontext, $USER->id, $dummyh5pfile);
 
         $this->assertEquals('contenttype_h5p', $content->get_content_type());
index e74bd1b..d5cf377 100644 (file)
 
 namespace core_contentbank;
 
-defined('MOODLE_INTERNAL') || die();
-
-global $CFG;
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
-require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
-
 use stdClass;
 use context_system;
 use contenttype_testable\contenttype as contenttype;
@@ -64,6 +58,16 @@ class core_contenttype_contenttype_testcase extends \advanced_testcase {
     /** @var contenttype The contenttype instance. */
     protected $contenttype;
 
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.php');
+        require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
+    }
+
     /**
      * Tests get_contenttype_name result.
      *
index fd35f45..262e7e6 100644 (file)
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-namespace core_contentbank;
+namespace core_contentbank\external;
 
 defined('MOODLE_INTERNAL') || die();
 
 global $CFG;
 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
+require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
 
-use core_contentbank\external\delete_content;
 use dml_missing_record_exception;
 use external_api;
 use externallib_advanced_testcase;
index 4b1bf6b..6a9ea67 100644 (file)
@@ -24,7 +24,7 @@
  * @since      Moodle 3.9
  */
 
-namespace core_contentbank;
+namespace core_contentbank\external;
 
 defined('MOODLE_INTERNAL') || die();
 
@@ -33,9 +33,6 @@ require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_contenttype.p
 require_once($CFG->dirroot . '/contentbank/tests/fixtures/testable_content.php');
 require_once($CFG->dirroot . '/webservice/tests/helpers.php');
 
-use core_contentbank\external\delete_content;
-use core_contentbank\external\external;
-use core_contentbank\external\rename_content;
 use external_api;
 
 /**
index 09c3f0f..e737117 100644 (file)
@@ -25,8 +25,6 @@
 
 namespace core_contentbank;
 
-defined('MOODLE_INTERNAL') || die();
-
 use stdClass;
 use context_system;
 use context_coursecat;
index 3bf1b45..b3f553f 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js and b/course/amd/build/activitychooser.min.js differ
index 90b5d87..78345bc 100644 (file)
Binary files a/course/amd/build/activitychooser.min.js.map and b/course/amd/build/activitychooser.min.js.map differ
index ae2b3bb..abfc5d3 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js and b/course/amd/build/local/activitychooser/dialogue.min.js differ
index acc2129..355a979 100644 (file)
Binary files a/course/amd/build/local/activitychooser/dialogue.min.js.map and b/course/amd/build/local/activitychooser/dialogue.min.js.map differ
index d3b83d0..a66560e 100644 (file)
@@ -78,7 +78,6 @@ const registerListenerEvents = (courseId) => {
     events.forEach((event) => {
         document.addEventListener(event, async(e) => {
             if (e.target.closest(selectors.elements.sectionmodchooser)) {
-                const data = await fetchModuleData();
                 // We need to know who called this.
                 // Standard courses use the ID in the main section info.
                 const sectionDiv = e.target.closest(selectors.elements.section);
@@ -86,11 +85,31 @@ const registerListenerEvents = (courseId) => {
                 const button = e.target.closest(selectors.elements.sectionmodchooser);
                 // If we don't have a section ID use the fallback ID.
                 const caller = sectionDiv || button;
-                const favouriteFunction = partiallyAppliedFavouriteManager(data, caller.dataset.sectionid);
+
+                // We want to show the modal instantly but loading whilst waiting for our data.
+                let bodyPromiseResolver;
+                const bodyPromise = new Promise(resolve => {
+                    bodyPromiseResolver = resolve;
+                });
+
+                const sectionModal = buildModal(bodyPromise);
+
+                // Now we have a modal we should start fetching data.
+                const data = await fetchModuleData();
+
+                // Apply the section id to all the module instance links.
                 const builtModuleData = sectionIdMapper(data, caller.dataset.sectionid);
-                const sectionModal = await modalBuilder(builtModuleData);
 
-                ChooserDialogue.displayChooser(caller, sectionModal, builtModuleData, favouriteFunction);
+                ChooserDialogue.displayChooser(
+                    sectionModal,
+                    builtModuleData,
+                    partiallyAppliedFavouriteManager(data, caller.dataset.sectionid),
+                );
+
+                bodyPromiseResolver(await Templates.render(
+                    'core_course/activitychooser',
+                    templateDataBuilder(builtModuleData)
+                ));
             }
         });
     });
@@ -102,7 +121,7 @@ const registerListenerEvents = (courseId) => {
  *
  * @method sectionIdMapper
  * @param {Object} webServiceData Our original data from the Web service call
- * @param {Array} id The ID of the section we need to append to the links
+ * @param {Number} id The ID of the section we need to append to the links
  * @return {Array} [modules] with URL's built
  */
 const sectionIdMapper = (webServiceData, id) => {
@@ -114,15 +133,6 @@ const sectionIdMapper = (webServiceData, id) => {
     return newData.content_items;
 };
 
-/**
- * Build a modal on demand to save page load times
- *
- * @method modalBuilder
- * @param {Array} data our array of modules with section ID's applied in the URL field
- * @return {Object} Our modal that we are going to show the user
- */
-const modalBuilder = data => buildModal(templateDataBuilder(data));
-
 /**
  * Given an array of modules we want to figure out where & how to place them into our template object
  *
@@ -158,18 +168,22 @@ const templateDataBuilder = (data) => {
  * Given an object we want to build a modal ready to show
  *
  * @method buildModal
- * @param {Object} data The template data which contains arrays of modules
- * @return {Object} The modal for the calling section with everything already set up
+ * @param {Promise} bodyPromise
+ * @return {Object} The modal ready to display immediately and render body in later.
  */
-const buildModal = data => {
+const buildModal = bodyPromise => {
     return ModalFactory.create({
         type: ModalFactory.types.DEFAULT,
         title: getString('addresourceoractivity'),
-        body: Templates.render('core_course/activitychooser', data),
+        body: bodyPromise,
         large: true,
         templateContext: {
             classes: 'modchooser'
         }
+    })
+    .then(modal => {
+        modal.show();
+        return modal;
     });
 };
 
@@ -240,6 +254,7 @@ const partiallyAppliedFavouriteManager = (moduleData, sectionId) => {
             if (favourite) {
                 result.favourite = true;
 
+                // eslint-disable-next-line camelcase
                 newFaves.content_items = moduleData.content_items.filter(mod => mod.favourite === true);
 
                 const builtFaves = sectionIdMapper(newFaves, sectionId);
index 6a476cf..42a19b3 100644 (file)
@@ -42,6 +42,7 @@ import {debounce} from 'core/utils';
 const showModuleHelp = (carousel, moduleData) => {
     const help = carousel.find(selectors.regions.help)[0];
     help.innerHTML = '';
+    help.classList.add('m-auto');
 
     // Add a spinner.
     const spinnerPromise = addIconToContainer(help);
@@ -483,6 +484,37 @@ const searchModules = (modules, searchTerm) => {
     return searchResults;
 };
 
+/**
+ * Set up our tabindex information across the chooser.
+ *
+ * @method setupKeyboardAccessibility
+ * @param {Promise} modal Our created modal for the section
+ * @param {Map} mappedModules A map of all of the built module information
+ */
+const setupKeyboardAccessibility = (modal, mappedModules) => {
+    modal.getModal()[0].tabIndex = -1;
+
+    modal.getBodyPromise().then(body => {
+        $(selectors.elements.tab).on('shown.bs.tab', (e) => {
+            const activeSectionId = e.target.getAttribute("href");
+            const activeSectionChooserOptions = body[0]
+                .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
+            const firstChooserOption = activeSectionChooserOptions
+                .querySelector(selectors.regions.chooserOption.container);
+            const prevActiveSectionId = e.relatedTarget.getAttribute("href");
+            const prevActiveSectionChooserOptions = body[0]
+                .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));
+
+            // Disable the focus of every chooser option in the previous active section.
+            disableFocusAllChooserOptions(prevActiveSectionChooserOptions);
+            // Enable the focus of the first chooser option in the current active section.
+            toggleFocusableChooserOption(firstChooserOption, true);
+            initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions);
+        });
+        return;
+    }).catch(Notification.exception);
+};
+
 /**
  * Disable the focus of all chooser options in a specific container (section).
  *
@@ -500,13 +532,11 @@ const disableFocusAllChooserOptions = (sectionChooserOptions) => {
  * Display the module chooser.
  *
  * @method displayChooser
- * @param {HTMLElement} origin The calling button
- * @param {Object} modal Our created modal for the section
+ * @param {Promise} modalPromise Our created modal for the section
  * @param {Array} sectionModules An array of all of the built module information
  * @param {Function} partialFavourite Partially applied function we need to manage favourite status
  */
-export const displayChooser = (origin, modal, sectionModules, partialFavourite) => {
-
+export const displayChooser = (modalPromise, sectionModules, partialFavourite) => {
     // Make a map so we can quickly fetch a specific module's object for either rendering or searching.
     const mappedModules = new Map();
     sectionModules.forEach((module) => {
@@ -514,39 +544,18 @@ export const displayChooser = (origin, modal, sectionModules, partialFavourite)
     });
 
     // Register event listeners.
-    registerListenerEvents(modal, mappedModules, partialFavourite);
+    modalPromise.then(modal => {
+        registerListenerEvents(modal, mappedModules, partialFavourite);
 
-    // We want to focus on the action select when the dialog is closed.
-    modal.getRoot().on(ModalEvents.hidden, () => {
-        modal.destroy();
-    });
+        // We want to focus on the first chooser option element as soon as the modal is opened.
+        setupKeyboardAccessibility(modal, mappedModules);
 
-    // We want to focus on the first chooser option element as soon as the modal is opened.
-    modal.getRoot().on(ModalEvents.shown, () => {
-        modal.getModal()[0].tabIndex = -1;
-
-        modal.getBodyPromise()
-        .then(body => {
-            $(selectors.elements.tab).on('shown.bs.tab', (e) => {
-                const activeSectionId = e.target.getAttribute("href");
-                const activeSectionChooserOptions = body[0]
-                    .querySelector(selectors.regions.getSectionChooserOptions(activeSectionId));
-                const firstChooserOption = activeSectionChooserOptions
-                    .querySelector(selectors.regions.chooserOption.container);
-                const prevActiveSectionId = e.relatedTarget.getAttribute("href");
-                const prevActiveSectionChooserOptions = body[0]
-                    .querySelector(selectors.regions.getSectionChooserOptions(prevActiveSectionId));
-
-                // Disable the focus of every chooser option in the previous active section.
-                disableFocusAllChooserOptions(prevActiveSectionChooserOptions);
-                // Enable the focus of the first chooser option in the current active section.
-                toggleFocusableChooserOption(firstChooserOption, true);
-                initChooserOptionsKeyboardNavigation(body[0], mappedModules, activeSectionChooserOptions);
-            });
-            return;
-        })
-        .catch(Notification.exception);
-    });
+        // We want to focus on the action select when the dialog is closed.
+        modal.getRoot().on(ModalEvents.hidden, () => {
+            modal.destroy();
+        });
 
-    modal.show();
+        return modal;
+    })
+    .catch();
 };
index 9ad479f..eed3774 100644 (file)
@@ -378,15 +378,10 @@ class editor {
         $PAGE->requires->js(autoloader::get_h5p_editor_library_url('scripts/h5peditor-editor.js' . $cachebuster), true);
         $PAGE->requires->js(autoloader::get_h5p_editor_library_url('scripts/h5peditor-init.js' . $cachebuster), true);
 
-        // Add translations.
+        // Load editor translations.
         $language = framework::get_language();
-        $languagescript = "language/{$language}.js";
-
-        if (!file_exists("{$CFG->dirroot}" . autoloader::get_h5p_editor_library_base($languagescript))) {
-            $languagescript = 'language/en.js';
-        }
-        $PAGE->requires->js(autoloader::get_h5p_editor_library_url($languagescript . $cachebuster),
-            true);
+        $editorstrings = $this->get_editor_translations($language);
+        $PAGE->requires->data_for_js('H5PEditor.language.core', $editorstrings, false);
 
         // Add JavaScript settings.
         $root = $CFG->wwwroot;
@@ -423,6 +418,47 @@ class editor {
         $PAGE->requires->data_for_js('H5PIntegration', $settings, true);
     }
 
+    /**
+     * Get editor translations for the defined language.
+     * Check if the editor strings have been translated in Moodle.
+     * If the strings exist, they will override the existing ones in the JS file.
+     *
+     * @param string $language The language for the translations to be returned.
+     * @return array The editor string translations.
+     */
+    private function get_editor_translations(string $language): array {
+        global $CFG;
+
+        // Add translations.
+        $languagescript = "language/{$language}.js";
+
+        if (!file_exists("{$CFG->dirroot}" . autoloader::get_h5p_editor_library_base($languagescript))) {
+            $languagescript = 'language/en.js';
+        }
+
+        // Check if the editor strings have been translated in Moodle.
+        // If the strings exist, they will override the existing ones in the JS file.
+
+        // Get existing strings from current JS language file.
+        $langcontent = file_get_contents("{$CFG->dirroot}" . autoloader::get_h5p_editor_library_base($languagescript));
+
+        // Get only the content between { } (for instance, ; at the end of the file has to be removed).
+        $langcontent = substr($langcontent, 0, strpos($langcontent, '}', -0) + 1);
+        $langcontent = substr($langcontent, strpos($langcontent, '{'));
+
+        // Parse the JS language content and get a PHP array.
+        $editorstrings = helper::parse_js_array($langcontent);
+        foreach ($editorstrings as $key => $value) {
+            $stringkey = 'editor:'.strtolower(trim($key));
+            $value = autoloader::get_h5p_string($stringkey, $language);
+            if (!empty($value)) {
+                $editorstrings[$key] = $value;
+            }
+        }
+
+        return $editorstrings;
+    }
+
     /**
      * Preprocess the data sent through the form to the H5P JS Editor Library.
      *
index bac7950..c4b575c 100644 (file)
@@ -62,7 +62,14 @@ class editor_framework implements H5peditorStorage {
         $librarykey = helper::get_cache_librarykey(core::record_to_string($library));
         $cachekey = "{$librarykey}/{$lang}";
         $translation = $langcache->get($cachekey);
-        if ($translation) {
+
+        if ($translation !== false) {
+            // When there is no translation we store it in the cache as `null`.
+            // This API requires it be returned as `false`.
+            if ($translation === null) {
+                return false;
+            }
+
             return $translation;
         }
 
@@ -88,14 +95,19 @@ class editor_framework implements H5peditorStorage {
 
         $result = $DB->get_record_sql($sql, $params);
 
-        if (!empty($result)) {
-            // If the JS language file exists, its content should be returned.
-            $fs = get_file_storage();
-            $file = $fs->get_file_by_hash($result->pathnamehash);
-            $translation = $file->get_content();
+        if (empty($result)) {
+            // Save the fact that there is no translation into the cache.
+            // The cache API cannot handle setting a literal `false` value so conver to `null` instead.
+            $langcache->set($cachekey, null);
+
+            return false;
         }
 
-        // Save translation into the cache (even if there is no translation for this language).
+        // Save translation into the cache, and return its content.
+        $fs = get_file_storage();
+        $file = $fs->get_file_by_hash($result->pathnamehash);
+        $translation = $file->get_content();
+
         $langcache->set($cachekey, $translation);
 
         return $translation;
index 41b1c9d..93780fa 100644 (file)
@@ -82,7 +82,6 @@ class helper {
         return false;
     }
 
-
     /**
      * Get the error messages stored in our H5P framework.
      *
@@ -410,4 +409,24 @@ class helper {
         // Remove whitespaces and replace '.' to '_'.
         return str_replace('.', '_', str_replace(' ', '', $library));
     }
+
+    /**
+     * Parse a JS array to a PHP array.
+     *
+     * @param  string $jscontent The JS array to parse to PHP array.
+     * @return array The JS array converted to PHP array.
+     */
+    public static function parse_js_array(string $jscontent): array {
+        $jsarray = preg_split('/,\n\s+/', substr($jscontent, 0, -1));
+        $jsarray = preg_replace('~{?\\n~', '', $jsarray);
+
+        $strings = [];
+        foreach ($jsarray as $key => $value) {
+            $splitted = explode(":", $value, 2);
+            $value = preg_replace("/^['|\"](.*)['|\"]$/", "$1", trim($splitted[1], ' ,'));
+            $strings[ trim($splitted[0]) ] = str_replace("\'", "'", $value);
+        }
+
+        return $strings;
+    }
 }
index 27b5f4e..0f7324b 100644 (file)
@@ -149,6 +149,17 @@ class autoloader {
         return component_class_callback(self::get_handler_classname(), 'get_h5p_editor_library_base', [$filepath]);
     }
 
+    /**
+     * Returns a localized string, if it exists in the h5plib plugin and the value it's different from the English version.
+     *
+     * @param string $identifier The key identifier for the localized string
+     * @param string $language Language to get the localized string.
+     * @return string|null The localized string or null if it doesn't exist in this H5P library plugin.
+     */
+    public static function get_h5p_string(string $identifier, string $language): ?string {
+        return component_class_callback(self::get_handler_classname(), 'get_h5p_string', [$identifier, $language]);
+    }
+
     /**
      * Register the H5P autoloader.
      */
index ca59674..c9c5389 100644 (file)
@@ -123,6 +123,31 @@ abstract class handler {
         return new \moodle_url(static::get_h5p_editor_library_base($filepath), $params);
     }
 
+    /**
+     * Returns a localized string, if it exists in the h5plib plugin and the value it's different from the English version.
+     *
+     * @param string $identifier The key identifier for the localized string
+     * @param string $language Language to get the localized string.
+     * @return string|null The localized string or null if it doesn't exist in this H5P library plugin.
+     */
+    public static function get_h5p_string(string $identifier, string $language): ?string {
+        $value = null;
+        $h5pversion = static::get_h5p_version();
+        $component = 'h5plib_v' . $h5pversion;
+        if (get_string_manager()->string_exists($identifier, $component)) {
+            $defaultmoodlelang = 'en';
+            // In Moodle, all the English strings always will exist because they have to be declared in order to let users
+            // to translate them. That's why, this method will only replace existing key if the value is different from
+            // the English version and the current language is not English.
+            $string = new \lang_string($identifier, $component);
+            if ($language === $defaultmoodlelang || $string->out($language) !== $string->out($defaultmoodlelang)) {
+                $value = $string->out($language);
+            }
+        }
+
+        return $value;
+    }
+
     /**
      * Return the list of classes with their location within the joubel directory.
      *
index 34f3c9f..e030a16 100644 (file)
@@ -8,6 +8,13 @@ Import procedure:
 - Copy all the files from the folder repository in this directory.
 - In the method ns.LibrarySelector.prototype.appendTo (scripts/h5peditor-library-selector.js),
   comment the line "this.$selector.appendTo($element);" to avoid the display of the Hub Selector.
+- Review strings in joubel/editor/language/en.js and compare them with
+existing ones in lang/en/h5plib_vXXX.php: add the new ones and remove the
+unexisting ones. Remember to use the AMOS script commands, such CPY, to copy
+all the existing strings from the previous version. As you'll see, all the
+strings in en.js have been converted following these rules:
+  * Prefix  "editor:" has been added.
+  * Keys have been lowercased.
 
 Removed:
  * composer.json
index 222f128..10e1f71 100644 (file)
 
 defined('MOODLE_INTERNAL') || die();
 
+$string['editor:atoz'] = 'A to Z';
+$string['editor:add'] = 'Add';
+$string['editor:addauthor'] = 'Save author';
+$string['editor:addentity'] = 'Add :entity';
+$string['editor:addfile'] = 'Add file';
+$string['editor:addnewchange'] = 'Add new change';
+$string['editor:addtitle'] = 'Add title';
+$string['editor:addvideodescription'] = 'H5P supports all external video sources formatted as mp4, webm or ogv, like Vimeo Pro, and has support for YouTube links.';
+$string['editor:avtablistlabel'] = 'Insert using';
+$string['editor:belowmin'] = 'The :property value is below the minimum of :min.';
+$string['editor:cancel'] = 'Cancel';
+$string['editor:cancellabel'] = 'Cancel';
+$string['editor:changefile'] = 'Change file';
+$string['editor:changelanguage'] = 'Change language to :language?';
+$string['editor:changelibrary'] = 'Change content type?';
+$string['editor:changelogdescription'] = 'Some licenses require that changes made to the original work, or derivatives are logged and displayed. You may log your changes here for licensing reasons or just to allow yourself and others to keep track of the changes made to this content.';
+$string['editor:close'] = 'Close';
+$string['editor:commonfields'] = 'Text overrides and translations';
+$string['editor:commonfieldsdescription'] = 'Here you can edit settings or translate texts used in this content.';
+$string['editor:confirmchangelibrary'] = 'By doing this you will lose all work done with the current content type. Are you sure you wish to change content type?';
+$string['editor:confirmdeletechangelog'] = 'Are you sure you would like to delete this change log entry?';
+$string['editor:confirmimageremoval'] = 'This will remove your image. Are you sure you wish to proceed?';
+$string['editor:confirmpastebuttontext'] = 'Replace content';
+$string['editor:confirmpastecontent'] = 'By doing this you will replace the current content with the content from your clipboard. The current content will be lost. Are you sure you wish to continue?';
+$string['editor:confirmremoval'] = 'Are you sure you wish to remove this :type?';
+$string['editor:confirmremoveauthor'] = 'Are you sure you would like to remove this author?';
+$string['editor:contenttypebackbuttonlabel'] = 'Back';
+$string['editor:contenttypecacheoutdated'] = 'Content type list outdated';
+$string['editor:contenttypecacheoutdateddesc'] = 'Your site is having difficulties connecting to H5P.org to check for content type updates. You may not be able to update or install new content types.';
+$string['editor:contenttypedemobuttonlabel'] = 'Content Demo';
+$string['editor:contenttypedetailbuttonlabel'] = 'Details';
+$string['editor:contenttypegetbuttonlabel'] = 'Get';
+$string['editor:contenttypeiconalttext'] = 'Icon';
+$string['editor:contenttypeinstallbuttonlabel'] = 'Install';
+$string['editor:contenttypeinstallerror'] = ':contentType could not be installed. Contact your administrator.';
+$string['editor:contenttypeinstallsuccess'] = ':contentType successfully installed!';
+$string['editor:contenttypeinstallingbuttonlabel'] = 'Installing';
+$string['editor:contenttypelicensepaneltitle'] = 'License';
+$string['editor:contenttypenotinstalled'] = 'Content type not installed';
+$string['editor:contenttypenotinstalleddesc'] = 'You do not have permission to install content types.';
+$string['editor:contenttypeowner'] = 'By :owner';
+$string['editor:contenttyperestricted'] = 'Restricted content type';
+$string['editor:contenttyperestricteddesc'] = 'The use of this content type has been restricted by an administrator.';
+$string['editor:contenttypesearchfieldplaceholder'] = 'Search for Content Types';
+$string['editor:contenttypesectionall'] = 'All Content Types';
+$string['editor:contenttypeunsupportedapiversioncontent'] = 'Contact your system administrator to provide you with the necessary updates';
+$string['editor:contenttypeunsupportedapiversiontitle'] = 'This content type requires a newer core version';
+$string['editor:contenttypeupdateavailable'] = 'Update available';
+$string['editor:contenttypeupdatebuttonlabel'] = 'Update';
+$string['editor:contenttypeupdatesuccess'] = ':contentType successfully updated!';
+$string['editor:contenttypeupdatingbuttonlabel'] = 'Updating';
+$string['editor:contenttypeusebuttonlabel'] = 'Use';
+$string['editor:contributetranslations'] = 'If you want to complete the translation for :language you can learn about <a href=":url" target="_new">contributing translations to H5P</a>';
+$string['editor:copiedbutton'] = 'Copied';
+$string['editor:copiedtoclipboard'] = 'Content is copied to the clipboard';
+$string['editor:copybutton'] = 'Copy';
+$string['editor:copytoclipboard'] = 'Copy H5P content to the clipboard';
+$string['editor:createcontenttablabel'] = 'Create Content';
+$string['editor:currentmenuselected'] = 'current selection';
+$string['editor:editcopyright'] = 'Edit copyright';
+$string['editor:editimage'] = 'Edit image';
+$string['editor:editmode'] = 'Editing mode';
+$string['editor:enteraudiotitle'] = 'Paste link or other audio source URL';
+$string['editor:enteraudiourl'] = 'Enter audio source URL';
+$string['editor:enterfullscreenbuttonlabel'] = 'Enter fullscreen';
+$string['editor:entervideotitle'] = 'Paste YouTube link or other video source URL';
+$string['editor:entervideourl'] = 'Enter video source URL or YouTube link';
+$string['editor:errorcalculatingmaxscore'] = 'Could not calculate the max score for this content. The max score is assumed to be 0. Contact your administrator if this isn’t correct.';
+$string['editor:errorcommunicatinghubcontent'] = 'An error occured. Please try again.';
+$string['editor:errorcommunicatinghubtitle'] = 'Not able to communicate with hub.';
+$string['editor:errorheader'] = 'An error occured';
+$string['editor:errornotsupported'] = 'Parameters contain %used which is not supported.';
+$string['editor:errorparamsbroken'] = 'Parameters are broken.';
+$string['editor:errortoohighversion'] = 'Parameters contain %used while only %supported or earlier are supported.';
+$string['editor:example'] = 'Example';
+$string['editor:exceedsmax'] = 'The :property value exceeds the maximum of :max.';
+$string['editor:exitfullscreenbuttonlabel'] = 'Exit fullscreen';
+$string['editor:expandcollapse'] = 'Expand/Collapse';
+$string['editor:filetolarge'] = 'The file you are trying to upload might be too large.';
+$string['editor:fillinthefieldsbelow'] = 'Fill in the fields below';
+$string['editor:gethelp'] = 'Get help';
+$string['editor:h5pfileuploadservererrorcontent'] = 'An unexpected error occured. Check your server error log for\' +  \' more details.';
+$string['editor:h5pfileuploadservererrortitle'] = 'The H5P file could not be uploaded';
+$string['editor:h5pfilevalidationfailedcontent'] = 'Make sure the uploaded H5P contains valid H5P content. H5P\' +  \' files containing only libraries should be uploaded through the H5P Libraries page.';
+$string['editor:h5pfilevalidationfailedtitle'] = 'Could not validate H5P file.';
+$string['editor:h5pfilewrongextensioncontent'] = 'Only files with the .h5p extension are allowed.';
+$string['editor:h5pfilewrongextensiontitle'] = 'The selected file could not be uploaded';
+$string['editor:height'] = 'height';
+$string['editor:hide'] = 'Hide';
+$string['editor:hideimportantinstructions'] = 'Hide important instructions';
+$string['editor:hubpanellabel'] = 'Select content type';
+$string['editor:illegaldecimalnumber'] = ':property can only contain numbers with max :decimals decimals.';
+$string['editor:imagelightboxprogress'] = ':num of :total';
+$string['editor:imagelightboxtitle'] = 'Images';
+$string['editor:importantinstructions'] = 'Important instructions';
+$string['editor:insert'] = 'Insert';
+$string['editor:invalidformat'] = 'Field value contains an invalid format or characters that are forbidden.';
+$string['editor:language'] = 'Language';
+$string['editor:librarymissing'] = 'Missing required library %lib.';
+$string['editor:licensecandistribute'] = 'Can distribute';
+$string['editor:licensecanholdliable'] = 'Can hold liable';
+$string['editor:licensecanmodify'] = 'Can modify';
+$string['editor:licensecansublicense'] = 'Can sublicense';
+$string['editor:licensecanusecommercially'] = 'Can use commercially';
+$string['editor:licensecannotholdliable'] = 'Cannot hold liable';
+$string['editor:licensedescription'] = 'Some of the features of this license are indicated below. Click the info icon above to read the original license text.';
+$string['editor:licensefetchdetailsfailed'] = 'Failed fetching license details';
+$string['editor:licensemodalsubtitle'] = 'Select a license to view information about proper usage';
+$string['editor:licensemodaltitle'] = 'License Details';
+$string['editor:licensemustincludecopyright'] = 'Must include copyright';
+$string['editor:licensemustincludelicense'] = 'Must include license';
+$string['editor:licenseunspecified'] = 'Unspecified';
+$string['editor:listbelowmin'] = 'The list needs at least :min items for the content to function properly.';
+$string['editor:listexceedsmax'] = 'The list exceeds the maximum of :max items.';
+$string['editor:listlabel'] = 'List';
+$string['editor:loading'] = 'Loading, please wait...';
+$string['editor:loadingimageeditor'] = 'Loading image editor, please wait...';
+$string['editor:logthischange'] = 'Log this change';
+$string['editor:loggedchanges'] = 'Logged changes';
+$string['editor:maxscoresemanticsmissing'] = 'Could not find the expected semantics in the content.';
+$string['editor:metadata'] = 'Metadata';
+$string['editor:metadatasharingandlicensinginfo'] = 'Metadata (sharing and licensing info)';
+$string['editor:missingproperty'] = 'Field :index is missing its :property property.';
+$string['editor:missingtranslation'] = '[Missing translation :key]';
+$string['editor:newchangehasbeenlogged'] = 'New change has been logged';
+$string['editor:newestfirst'] = 'Newest First';
+$string['editor:nextimage'] = 'Next image';
+$string['editor:nochangeshavebeenlogged'] = 'No changes have been logged';
+$string['editor:nocontenttypesavailable'] = 'No content types are available';
+$string['editor:nocontenttypesavailabledesc'] = 'Your site is having difficulties connecting to H5P.org and listing the available content types.';
+$string['editor:nofollow'] = 'Cannot follow field ":path".';
+$string['editor:nolanguagessupported'] = 'No languages supported';
+$string['editor:noresultsfound'] = 'No results found';
+$string['editor:noresultsfounddesc'] = 'There is no content type that matches your search criteria.';
+$string['editor:nosemantics'] = 'Error, could not load the content type form.';
+$string['editor:notalltextschanged'] = 'Not all texts were changed, there is only partial coverage for :language.';
+$string['editor:notimagefield'] = '":path" is not an image.';
+$string['editor:notimageordimensionsfield'] = '":path" is not an image or dimensions field.';
+$string['editor:numresults'] = ':num results';
+$string['editor:numberfield'] = 'number field';
+$string['editor:ok'] = 'OK';
+$string['editor:onlynumbers'] = 'The :property value can only contain numbers.';
+$string['editor:or'] = 'or';
+$string['editor:orderitemdown'] = 'Order item down';
+$string['editor:orderitemup'] = 'Order item up';
+$string['editor:outofstep'] = 'The :property value can only be changed in steps of :step.';
+$string['editor:pasteandreplacebutton'] = 'Paste & Replace';
+$string['editor:pasteandreplacefromclipboard'] = 'Replace existing content with H5P Content from the clipboard';
+$string['editor:pastebutton'] = 'Paste';
+$string['editor:pastecontent'] = 'Replace Content';
+$string['editor:pastecontentnotsupported'] = 'The content in the H5P clipboard is not supported in this context';
+$string['editor:pastecontentrestricted'] = 'The content in the clipboard has been restricted on this site';
+$string['editor:pasteerror'] = 'Cannot paste from clipboard';
+$string['editor:pastefromclipboard'] = 'Paste H5P content from the clipboard';
+$string['editor:pastenocontent'] = 'No H5P content on the clipboard';
+$string['editor:pastetoonew'] = 'The content in the H5P clipboard is of a higher version (:clip) than what is supported in this context (:local), if possible try to have this content upgraded first, and then try pasting the content here again.';
+$string['editor:pastetooold'] = 'The content in the H5P clipboard is of a lower version (:clip) than what is supported in this context (:local), if possible try to have the content you want to paste upgraded, copy it again and try pasting it here.';
+$string['editor:popularfirst'] = 'Popular First';
+$string['editor:previousimage'] = 'Previous image';
+$string['editor:proceedbuttonlabel'] = 'Proceed to save';
+$string['editor:readless'] = 'Read less';
+$string['editor:readmore'] = 'Read more';
+$string['editor:recentlyusedfirst'] = 'Recently Used First';
+$string['editor:reloadbuttonlabel'] = 'Reload';
+$string['editor:removefile'] = 'Remove file';
+$string['editor:removeimage'] = 'Remove image';
+$string['editor:removeitem'] = 'Remove item';
+$string['editor:requiredproperty'] = 'The :property is required and must have a value.';
+$string['editor:resettooriginallabel'] = 'Reset to original';
+$string['editor:savelabel'] = 'Save';
+$string['editor:savemetadata'] = 'Save metadata';
+$string['editor:screenshots'] = 'Screenshots';
+$string['editor:scriptmissing'] = 'Could not load upgrades script for %lib.';
+$string['editor:searchresults'] = 'Search Results';
+$string['editor:selectfiletoupload'] = 'Select file to upload';
+$string['editor:selectlibrary'] = 'Select the library you wish to use for your content.';
+$string['editor:semanticserror'] = 'Semantics error: :error';
+$string['editor:show'] = 'Show';
+$string['editor:showimportantinstructions'] = 'Show instructions';
+$string['editor:tabtitlebasicfileupload'] = 'File Upload';
+$string['editor:tabtitleinputlinkurl'] = 'Link/URL';
+$string['editor:textfield'] = 'text field';
+$string['editor:thecontenttype'] = 'the content type';
+$string['editor:thiswillpotentially'] = 'This will potentially reset all the text and translations. You can\'t undo this. The content itself will not be changed. Do you want to proceed?';
+$string['editor:title'] = 'Title';
+$string['editor:toolong'] = 'Field value is too long, should contain :max letters or less.';
+$string['editor:tryagain'] = 'Try again';
+$string['editor:tutorial'] = 'Tutorial';
+$string['editor:unabletointerpreterror'] = 'Unable to interpret response.';
+$string['editor:unabletointerpretsolution'] = 'Please check your error log.';
+$string['editor:unknownfieldpath'] = 'Unable to find ":path".';
+$string['editor:unknownfileuploaderror'] = 'Unknown file upload error';
+$string['editor:unknownlibrary'] = 'Unfortunately, the selected content type \'%lib\' isn\'t installed on this system.';
+$string['editor:untitled'] = 'Untitled :libraryTitle';
+$string['editor:uploadaudiotitle'] = 'Upload audio file';
+$string['editor:uploaderror'] = 'File Upload Error';
+$string['editor:uploadfilebuttonchangelabel'] = 'Change file';
+$string['editor:uploadfilebuttonlabel'] = 'Upload a file';
+$string['editor:uploadinstructionscontent'] = 'You may start with examples from <a href="https://h5p.org/content-types-and-applications" target="blank">H5P.org</a>.';
+$string['editor:uploadinstructionstitle'] = 'Upload an H5P file.';
+$string['editor:uploadplaceholder'] = 'No file chosen';
+$string['editor:uploadsuccess'] = ':title was successfully uploaded!';
+$string['editor:uploadtablabel'] = 'Upload';
+$string['editor:uploadvideotitle'] = 'Upload video file';
+$string['editor:uploading'] = 'Uploading, please wait...';
+$string['editor:uploadingthrobber'] = 'Now uploading...';
+$string['editor:usedforsearchingreportsandcopyrightinformation'] = 'Used for searching, reports and copyright information';
+$string['editor:videoquality'] = 'Video quality label';
+$string['editor:videoqualitydefaultlabel'] = 'Quality :index';
+$string['editor:videoqualitydescription'] = 'This label helps the user identify the current quality of the video. E.g. 1080p, 720p, HD or Mobile';
+$string['editor:warningchangebrowsingtoseeresults'] = 'Click <em>All</em> to get the list of all the content types you can install.';
+$string['editor:warningnocontenttypesinstalled'] = 'You don\'t have any content types installed.';
+$string['editor:warningupdateavailablebody'] = 'Update to the latest version for an improved experience.';
+$string['editor:warningupdateavailabletitle'] = 'A new version of :contentType is available.';
+$string['editor:width'] = 'width';
 $string['pluginname'] = 'H5P framework v1.24';
 $string['pluginname_help'] = 'H5P framework version 1.24.';
 $string['privacy:metadata'] = 'The H5P framework v1.24 does not store any personal data.';
index fb007bf..dff6022 100644 (file)
@@ -214,15 +214,6 @@ class editor_testcase extends advanced_testcase {
             $item = $h5pcorepath . $item . $cachebuster;
         });
 
-        // Add translation script.
-        $language = framework::get_language();
-        $languagescript = "language/{$language}.js";
-
-        if (!file_exists($CFG->dirroot . autoloader::get_h5p_editor_library_base($languagescript))) {
-            $languagescript = 'language/en.js';
-        }
-        $expectedjs[] = autoloader::get_h5p_editor_library_url($languagescript . $cachebuster)->out();
-
         $expectedjs[] = (new \moodle_url('/h5p/js/h5p_overrides.js' . $cachebuster))->out();
         $expectedjs[] = autoloader::get_h5p_editor_library_url('scripts/h5peditor-editor.js' . $cachebuster)->out();
         $expectedjs[] = autoloader::get_h5p_editor_library_url('scripts/h5peditor-init.js' . $cachebuster)->out();
diff --git a/h5p/tests/local/library/handler_test.php b/h5p/tests/local/library/handler_test.php
new file mode 100644 (file)
index 0000000..5d92f50
--- /dev/null
@@ -0,0 +1,79 @@
+<?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/>.
+
+/**
+ * Testing the H5P library handler.
+ *
+ * @package    core_h5p
+ * @category   test
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_h5p\local\library;
+
+use advanced_testcase;
+
+/**
+ * Test class covering the H5P library handler.
+ *
+ * @package    core_h5p
+ * @copyright  2020 Sara Arjona <sara@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class handler_testcase extends advanced_testcase {
+
+    /**
+     * Test the behaviour of get_h5p_string().
+     *
+     * @dataProvider get_h5p_string_provider
+     * @param  string $identifier      The key identifier for the localized string.
+     * @param  string $expectedresult  Expected result.
+     * @param  string $lang            Language to get the localized string.
+     */
+    public function test_get_h5p_string(string $identifier, ?string $expectedresult, ?string $lang = 'en'): void {
+        $result = autoloader::get_h5p_string($identifier, $lang);
+        $this->assertEquals($expectedresult, $result);
+    }
+
+    /**
+     * Data provider for test_get_h5p_string().
+     *
+     * @return array
+     */
+    public function get_h5p_string_provider(): array {
+        return [
+            'Existing string in h5plib plugin' => [
+                'editor:add',
+                'Add',
+            ],
+            'Unexisting translation for an existing string in h5plib plugin (es)' => [
+                'editor:add',
+                null,
+                'es',
+            ],
+            'Unexisting string in h5plib plugin' => [
+                'unexistingstring',
+                null,
+            ],
+            'Unexisting translation for an unexisting string in h5plib plugin (es)' => [
+                'unexistingstring',
+                null,
+                'es',
+            ],
+        ];
+    }
+}
index 0c08c50..c59daba 100644 (file)
@@ -127,6 +127,7 @@ $string['configgeneralblocks'] = 'Sets the default for including blocks in a bac
 $string['configgeneralcalendarevents'] = 'Sets the default for including calendar events in a backup.';
 $string['configgeneralcomments'] = 'Sets the default for including comments in a backup.';
 $string['configgeneralcompetencies'] = 'Sets the default for including competencies in a backup.';
+$string['configgeneralcontentbankcontent'] = 'Sets the default for including content bank content in a backup.';
 $string['configgeneralfiles'] = 'Sets the default for including files in a backup. Please note: Disabling this setting will result in a backup which only includes references to files. This is not a problem if the backup is restored on the same site and the files have not been deleted according to the setting \'Clean up trash pool files\' (filescleanupperiod).';
 $string['configgeneralfilters'] = 'Sets the default for including filters in a backup.';
 $string['configgeneralhistories'] = 'Sets the default for including user history within a backup.';
@@ -143,6 +144,7 @@ $string['configrestoreblocks'] = 'Sets the default for restoring blocks.';
 $string['configrestorecalendarevents'] = 'Sets the default for restoring calendar events.';
 $string['configrestorecomments'] = 'Sets the default for restoring comments.';
 $string['configrestorecompetencies'] = 'Sets the default for restoring competencies.';
+$string['configrestorecontentbankcontent'] = 'Sets the default for restoring content bank content.';
 $string['configrestoreenrolments'] = 'Sets the default for restoring enrolment methods.';
 $string['configrestorefilters'] = 'Sets the default for restoring filters.';
 $string['configrestorehistories'] = 'Sets the default for restoring user history if it was included in the backup.';
@@ -202,6 +204,7 @@ $string['generalblocks'] = 'Include blocks';
 $string['generalcalendarevents'] = 'Include calendar events';
 $string['generalcomments'] = 'Include comments';
 $string['generalcompetencies'] = 'Include competencies';
+$string['generalcontentbankcontent'] = 'Include content bank content';
 $string['generalenrolments'] = 'Include enrolment methods';
 $string['generalfiles'] = 'Include files';
 $string['generalfilters'] = 'Include filters';
@@ -333,6 +336,7 @@ $string['rootsettingfilters'] = 'Include filters';
 $string['rootsettingfiles'] = 'Include files';
 $string['rootsettingcomments'] = 'Include comments';
 $string['rootsettingcalendarevents'] = 'Include calendar events';
+$string['rootsettingcontentbankcontent'] = 'Include content bank content';
 $string['rootsettinguserscompletion'] = 'Include user completion details';
 $string['rootsettingquestionbank'] = 'Include question bank';
 $string['rootsettinglogs'] = 'Include course logs';
index 06958d6..e111a7c 100644 (file)
@@ -37,6 +37,7 @@ $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['file'] = 'Upload content';
 $string['file_help'] = 'Files may be stored in the content bank for use in courses. Only files used by content types enabled on the site may be uploaded.';
+$string['itemsfound'] = '{$a} items found';
 $string['name'] = 'Content';
 $string['nopermissiontodelete'] = 'You do not have permission to delete content.';
 $string['nopermissiontomanage'] = 'You do not have permission to manage content.';
@@ -50,6 +51,7 @@ $string['privacy:metadata:contentbankcontent'] = 'Stores the content of the cont
 $string['privacy:metadata:userid'] = 'The ID of the user creating or modifying content bank content.';
 $string['rename'] = 'Rename';
 $string['renamecontent'] = 'Rename content';
+$string['searchcontentbankbyname'] = 'Search for content by name';
 $string['timecreated'] = 'Time created';
 $string['unsupported'] = 'This content type is not supported.';
 $string['upload'] = 'Upload';
index bf0f119..1b3b1b4 100644 (file)
Binary files a/lib/amd/build/modal.min.js and b/lib/amd/build/modal.min.js differ
index a2acd22..6ebad58 100644 (file)
Binary files a/lib/amd/build/modal.min.js.map and b/lib/amd/build/modal.min.js.map differ
index 6f512ef..c221d1a 100644 (file)
Binary files a/lib/amd/build/user_date.min.js and b/lib/amd/build/user_date.min.js differ
index 898b39a..87973d0 100644 (file)
Binary files a/lib/amd/build/user_date.min.js.map and b/lib/amd/build/user_date.min.js.map differ
index 154f974..d04924f 100644 (file)
@@ -322,6 +322,9 @@ define([
             var contentPromise = null;
             body.css('overflow', 'hidden');
 
+            // Ensure that the `value` is a jQuery Promise.
+            value = $.when(value);
+
             if (value.state() == 'pending') {
                 // We're still waiting for the body promise to resolve so
                 // let's show a loading icon.
index 20ea44c..95efe3d 100644 (file)
@@ -107,9 +107,14 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      */
     var loadDatesFromServer = function(dates) {
         var args = dates.map(function(data) {
+            var fixDay = data.hasOwnProperty('fixday') ? data.fixday : 1;
+            var fixHour = data.hasOwnProperty('fixhour') ? data.fixhour : 1;
             return {
                 timestamp: data.timestamp,
-                format: data.format
+                format: data.format,
+                type: data.type || '',
+                fixday: fixDay,
+                fixhour: fixHour
             };
         });
 
@@ -155,7 +160,8 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      * Only dates not found in either cache will be sent to the server
      * for transforming.
      *
-     * A request object must have a timestamp key and a format key.
+     * A request object must have a timestamp key and a format key and
+     * optionally may have a type key.
      *
      * E.g.
      * var request = [
@@ -165,7 +171,10 @@ define(['jquery', 'core/ajax', 'core/sessionstorage', 'core/config'],
      *     },
      *     {
      *         timestamp: 1293876000,
-     *         format: '%A, %d %B %Y, %I:%M %p'
+     *         format: '%A, %d %B %Y, %I:%M %p',
+     *         type: 'gregorian',
+     *         fixday: false,
+     *         fixhour: false
      *     }
      * ];
      *
index a1ad313..cbb6905 100644 (file)
@@ -219,7 +219,7 @@ class behat_core_generator extends behat_generator_base {
             ],
             'contentbank content' => [
                 'datagenerator' => 'contentbank_content',
-                'required' => array('course', 'contenttype', 'user', 'contentname'),
+                'required' => array('contenttype', 'user', 'contentname'),
                 'switchids' => array('course' => 'courseid', 'user' => 'userid')
             ],
         ];
@@ -829,7 +829,14 @@ class behat_core_generator extends behat_generator_base {
      * @return void
      */
     protected function process_contentbank_content(array $data) {
-        $context = context_course::instance($data['courseid']);
+        if (empty($data['contextid'])) {
+            if (empty($data['courseid'])) {
+                throw new Exception('contentbank_content requires the field course or contextid to be specified');
+            }
+            $context = context_course::instance($data['courseid']);
+        } else {
+            $context = context::instance_by_id($data['contextid']);
+        }
         $contenttypeclass = "\\".$data['contenttype']."\\contenttype";
         if (class_exists($contenttypeclass)) {
             $contenttype = new $contenttypeclass($context);
index 917e5b5..bea9252 100644 (file)
@@ -275,6 +275,8 @@ function cron_run_inner_scheduled_task(\core\task\task_base $task) {
         }
         \core\task\manager::scheduled_task_failed($task);
     } finally {
+        // Reset back to the standard admin user.
+        cron_setup_user();
         cron_prepare_core_renderer(true);
     }
     get_mailer('close');
index 6721d2a..c8e08ab 100644 (file)
@@ -120,7 +120,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_string() result value
      *
-     * @return string
+     * @return external_description
      * @since Moodle 2.4
      */
     public static function get_string_returns() {
@@ -189,7 +189,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_string() result value
      *
-     * @return array
+     * @return external_description
      * @since Moodle 2.4
      */
     public static function get_strings_returns() {
@@ -233,6 +233,9 @@ class core_external extends external_api {
                         [
                             'timestamp' => new external_value(PARAM_INT, 'unix timestamp'),
                             'format' => new external_value(PARAM_TEXT, 'format string'),
+                            'type' => new external_value(PARAM_PLUGIN, 'The calendar type', VALUE_DEFAULT),
+                            'fixday' => new external_value(PARAM_INT, 'Remove leading zero for day', VALUE_DEFAULT, 1),
+                            'fixhour' => new external_value(PARAM_INT, 'Remove leading zero for hour', VALUE_DEFAULT, 1),
                         ]
                     )
                 )
@@ -264,7 +267,12 @@ class core_external extends external_api {
         self::validate_context($context);
 
         $formatteddates = array_map(function($timestamp) {
-            return userdate($timestamp['timestamp'], $timestamp['format']);
+
+            $calendartype = $timestamp['type'];
+            $fixday = !empty($timestamp['fixday']);
+            $fixhour = !empty($timestamp['fixhour']);
+            $calendar  = \core_calendar\type_factory::get_calendar_instance($calendartype);
+            return $calendar->timestamp_to_date_string($timestamp['timestamp'], $timestamp['format'], 99, $fixday, $fixhour);
         }, $params['timestamps']);
 
         return ['dates' => $formatteddates];
@@ -273,7 +281,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_user_dates() result value
      *
-     * @return array
+     * @return external_description
      */
     public static function get_user_dates_returns() {
         return new external_single_structure(
@@ -333,7 +341,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_component_strings() result value
      *
-     * @return array
+     * @return external_description
      * @since Moodle 2.4
      */
     public static function get_component_strings_returns() {
@@ -421,7 +429,7 @@ class core_external extends external_api {
     /**
      * Returns description of get_fragment() result value
      *
-     * @return array
+     * @return external_description
      * @since Moodle 3.1
      */
     public static function get_fragment_returns() {
index 6277bc7..bf43f49 100644 (file)
@@ -203,7 +203,6 @@ class core_external_testcase extends externallib_advanced_testcase {
     }
 
     public function test_get_user_dates() {
-        global $USER, $CFG, $DB;
         $this->resetAfterTest();
 
         $this->setAdminUser();
@@ -222,6 +221,11 @@ class core_external_testcase extends externallib_advanced_testcase {
                 'timestamp' => 1293876000,
                 'format' => '%d %m %Y'
             ],
+            [
+                'timestamp' => 1293876000,
+                'format' => '%d %m %Y',
+                'type' => 'gregorian'
+            ],
             [
                 'timestamp' => 1293876000,
                 'format' => 'some invalid format'
@@ -233,6 +237,7 @@ class core_external_testcase extends externallib_advanced_testcase {
 
         $this->assertEquals('Saturday, 1 January 2011, 6:00', $result['dates'][0]);
         $this->assertEquals('1 01 2011', $result['dates'][1]);
-        $this->assertEquals('some invalid format', $result['dates'][2]);
+        $this->assertEquals('1 01 2011', $result['dates'][2]);
+        $this->assertEquals('some invalid format', $result['dates'][3]);
     }
 }
index 4336eb1..34b4869 100644 (file)
@@ -979,15 +979,19 @@ function groups_group_visible($groupid, $course, $cm = null, $userid = null) {
 }
 
 /**
- * Get sql and parameters that will return user ids for a group
+ * Get sql and parameters that will return user ids for a group or groups
  *
- * @param int $groupid
+ * @param int|array $groupids Where this is an array of multiple groups, it will match on members of any of the groups
  * @param context $context Course context or a context within a course. Mandatory when $groupid = USERSWITHOUTGROUP
  * @return array($sql, $params)
  * @throws coding_exception if empty or invalid context submitted when $groupid = USERSWITHOUTGROUP
  */
-function groups_get_members_ids_sql($groupid, context $context = null) {
-    $groupjoin = groups_get_members_join($groupid, 'u.id', $context);
+function groups_get_members_ids_sql($groupids, context $context = null) {
+    if (!is_array($groupids)) {
+        $groupids = [$groupids];
+    }
+
+    $groupjoin = groups_get_members_join($groupids, 'u.id', $context);
 
     $sql = "SELECT DISTINCT u.id
               FROM {user} u
@@ -1003,39 +1007,61 @@ function groups_get_members_ids_sql($groupid, context $context = null) {
 /**
  * Get sql join to return users in a group
  *
- * @param int $groupid The groupid, 0 means all groups and USERSWITHOUTGROUP no group
+ * @param int|array $groupids The groupids, 0 or [] means all groups and USERSWITHOUTGROUP no group
  * @param string $useridcolumn The column of the user id from the calling SQL, e.g. u.id
- * @param context $context Course context or a context within a course. Mandatory when $groupid = USERSWITHOUTGROUP
+ * @param context $context Course context or a context within a course. Mandatory when $groupids includes USERSWITHOUTGROUP
  * @return \core\dml\sql_join Contains joins, wheres, params
  * @throws coding_exception if empty or invalid context submitted when $groupid = USERSWITHOUTGROUP
  */
-function groups_get_members_join($groupid, $useridcolumn, context $context = null) {
+function groups_get_members_join($groupids, $useridcolumn, context $context = null) {
+    global $DB;
+
     // Use unique prefix just in case somebody makes some SQL magic with the result.
     static $i = 0;
     $i++;
     $prefix = 'gm' . $i . '_';
 
+    if (!is_array($groupids)) {
+        $groupids = $groupids ? [$groupids] : [];
+    }
+
     $coursecontext = (!empty($context)) ? $context->get_course_context() : null;
-    if ($groupid == USERSWITHOUTGROUP && empty($coursecontext)) {
+    if (in_array(USERSWITHOUTGROUP, $groupids) && empty($coursecontext)) {
         // Throw an exception if $context is empty or invalid because it's needed to get the users without any group.
         throw new coding_exception('Missing or wrong $context parameter in an attempt to get members without any group');
     }
 
-    if ($groupid == USERSWITHOUTGROUP) {
+    // Handle cases where we need to include users not in any groups.
+    if (($nogroupskey = array_search(USERSWITHOUTGROUP, $groupids)) !== false) {
         // Get members without any group.
         $join = "LEFT JOIN (
                     SELECT g.courseid, m.groupid, m.userid
                     FROM {groups_members} m
                     JOIN {groups} g ON g.id = m.groupid
-                ) {$prefix}gm ON ({$prefix}gm.userid = $useridcolumn AND {$prefix}gm.courseid = :{$prefix}gcourseid)";
+                ) {$prefix}gm ON ({$prefix}gm.userid = {$useridcolumn} AND {$prefix}gm.courseid = :{$prefix}gcourseid)";
         $where = "{$prefix}gm.userid IS NULL";
-        $param = array("{$prefix}gcourseid" => $coursecontext->instanceid);
+        $param = ["{$prefix}gcourseid" => $coursecontext->instanceid];
+        unset($groupids[$nogroupskey]);
+
+        // Handle any groups that also need to be included (eg searching for users in no groups OR within specified groups).
+        if (!empty($groupids)) {
+            list($groupssql, $groupsparams) = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED, $prefix);
+
+            $join .= "LEFT JOIN {groups_members} {$prefix}gm2
+                             ON ({$prefix}gm2.userid = {$useridcolumn} AND {$prefix}gm2.groupid {$groupssql})";
+            // TODO: This only handles 'Any' (logical OR) of the provided groups. MDL-68348 will add 'All' and 'None' support.
+            $where = "({$where} OR {$prefix}gm2.userid IS NOT NULL)";
+            $param = array_merge($param, $groupsparams);
+        }
+
     } else {
-        // Get members of defined groupid.
+        // Get members of defined group IDs only.
+        list($groupssql, $param) = $DB->get_in_or_equal($groupids, SQL_PARAMS_NAMED, $prefix);
+
+        // TODO: This only handles 'Any' (logical OR) of the provided groups. MDL-68348 will add 'All' and 'None' support.
         $join = "JOIN {groups_members} {$prefix}gm
-                ON ({$prefix}gm.userid = $useridcolumn AND {$prefix}gm.groupid = :{$prefix}gmid)";
+                   ON ({$prefix}gm.userid = {$useridcolumn} AND {$prefix}gm.groupid {$groupssql})";
         $where = '';
-        $param = array("{$prefix}gmid" => $groupid);
     }
 
     return new \core\dml\sql_join($join, $where, $param);
index 02a55e2..e02f4fd 100644 (file)
@@ -2845,8 +2845,8 @@ class global_navigation extends navigation_node {
             return true;
         }
 
-        $sitecontext = context_system::instance();
-        $navoptions = course_get_user_navigation_options($sitecontext, $course);
+        $systemcontext = context_system::instance();
+        $navoptions = course_get_user_navigation_options($systemcontext, $course);
 
         // Hidden node that we use to determine if the front page navigation is loaded.
         // This required as there are not other guaranteed nodes that may be loaded.
@@ -2918,23 +2918,29 @@ class global_navigation extends navigation_node {
             $context = $this->page->context;
             switch ($context->contextlevel) {
                 case CONTEXT_COURSECAT:
-                    $type = self::TYPE_CATEGORY;
+                    // OK, expected context level.
                     break;
                 case CONTEXT_COURSE:
-                    $type = self::TYPE_COURSE;
+                    // OK, expected context level if not on frontpage.
                     if ($COURSE->id != $SITE->id) {
                         break;
                     }
                 default:
-                    $type = self::TYPE_CUSTOM;
-                    $context = $sitecontext;
+                    // If this context is part of a course (excluding frontpage), use the course context.
+                    // Otherwise, use the system context.
+                    $coursecontext = $context->get_course_context(false);
+                    if ($coursecontext && $coursecontext->instanceid !== $SITE->id) {
+                        $context = $coursecontext;
+                    } else {
+                        $context = $systemcontext;
+                    }
             }
 
             $params = ['contextid' => $context->id];
             if (has_capability('moodle/contentbank:access', $context)) {
                 $url = new moodle_url('/contentbank/index.php', $params);
                 $node = $coursenode->add(get_string('contentbank'), $url,
-                    $type, null, 'contentbank', new pix_icon('i/contentbank', ''));
+                    self::TYPE_CUSTOM, null, 'contentbank', new pix_icon('i/contentbank', ''));
                 $node->showinflatnavigation = true;
             }
         }
index 7e17307..99366fc 100644 (file)
@@ -1572,6 +1572,15 @@ class moodle_page {
         $this->_wherethemewasinitialised = debug_backtrace();
     }
 
+    /**
+     * For diagnostic/debugging purposes, find where the theme setup was triggered.
+     *
+     * @return null|array null if theme not yet setup. Stacktrace if it was.
+     */
+    public function get_where_theme_was_initialised() {
+        return $this->_wherethemewasinitialised;
+    }
+
     /**
      * Reset the theme and output for a new context. This only makes sense from
      * external::validate_context(). Do not cheat.
index 597077c..b8a5c03 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js and b/lib/table/amd/build/dynamic.min.js differ
index d14c064..5c5e815 100644 (file)
Binary files a/lib/table/amd/build/dynamic.min.js.map and b/lib/table/amd/build/dynamic.min.js.map differ
index 18e35fb..a53dc6f 100644 (file)
@@ -24,6 +24,7 @@
 import {fetch as fetchTableData} from 'core_table/local/dynamic/repository';
 import * as Selectors from 'core_table/local/dynamic/selectors';
 import Events from './local/dynamic/events';
+import {addIconToContainer} from 'core/loadingicon';
 
 let watching = false;
 
@@ -66,6 +67,7 @@ const getFiltersetFromTable = tableRoot => {
  */
 export const refreshTableContent = (tableRoot, resetContent = false) => {
     const filterset = getFiltersetFromTable(tableRoot);
+    addIconToContainer(tableRoot);
 
     return fetchTableData(
         tableRoot.dataset.tableComponent,
index 6cb2a6b..2c46b2f 100644 (file)
@@ -1645,6 +1645,7 @@ class flexible_table {
         if (is_a($this, \core_table\dynamic::class)) {
             $sortdata = $this->get_sort_order();
             return html_writer::start_tag('div', [
+                'class' => 'table-dynamic position-relative',
                 'data-region' => 'core_table/dynamic',
                 'data-table-handler' => $this->get_handler(),
                 'data-table-component' => $this->get_component(),
diff --git a/lib/templates/time_element.mustache b/lib/templates/time_element.mustache
new file mode 100644 (file)
index 0000000..f225448
--- /dev/null
@@ -0,0 +1,92 @@
+{{!
+    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/time_element
+
+    Template to display an HTML time element.
+
+    Classes required for JS:
+    * none
+
+    Data attributes required for JS:
+    * data-timestamp Number - The timestamp for the element.
+    * data-datetimeformat String - A valid format for the datetime attribute.
+
+    Context variables required for this template:
+    * timestamp Number - The timestamp for the element.
+    * userdateformat String - The user-facing date format
+    * datetimeformat String - A valid format for the datetime attribute. Defaults to the ISO-8601 format of '%Y-%m-%dT%H:%M%z'.
+    Example context (json):
+    {
+        "timestamp": 0,
+        "userdateformat": "%d %b %Y",
+        "datetimeformat": "%Y-%m-%dT%H:%M%z"
+    }
+}}
+<time id="time-{{$elementid}}{{uniqid}}{{/elementid}}" class="{{$elementclass}}{{timeclass}}{{/elementclass}}" datetime="{{$datetimeval}}{{datetime}}{{/datetimeval}}"
+      data-timestamp="{{$timestampval}}{{timestamp}}{{/timestampval}}"
+      data-datetimeformat="{{$datetimeformatval}}{{#datetimeformat}}{{.}}{{/datetimeformat}}{{^datetimeformat}}%Y-%m-%dT%H:%M%z{{/datetimeformat}}{{/datetimeformatval}}">
+    {{$datedisplay}}
+        {{#userdate}} {{$timestampval}}{{timestamp}}{{/timestampval}}, {{$userdateformatval}}{{userdateformat}}{{/userdateformatval}} {{/userdate}}
+    {{/datedisplay}}
+</time>
+{{#js}}
+    /** Fetches the formatted date/time for the time element's datetime attribute. */
+    require(['core/user_date'], function(UserDate) {
+        var root = document.getElementById('time-{{$elementid}}{{uniqid}}{{/elementid}}');
+        // Fetch value for the datetime attribute using core/user_date, if it's not available.
+        if (!root.getAttribute('datetime')) {
+            var dateTimeFormat = root.getAttribute('data-datetimeformat');
+            var timestamp = root.getAttribute('data-timestamp');
+
+            if (!dateTimeFormat.match(/%(?![YmdHMSzZ])./g)) {
+                var zeroPad = function(nNum, nPad) {
+                    return ((Math.pow(10, nPad) + nNum) + '').slice(1);
+                };
+
+                var date = new Date(timestamp * 1000);
+
+                var datetime = dateTimeFormat.replace(/%./g, function(sMatch) {
+                    return (({
+                        '%Y': date.getFullYear(),
+                        '%m': zeroPad(date.getMonth() + 1, 2),
+                        '%d': zeroPad(date.getDate(), 2),
+                        '%H': zeroPad(date.getHours(), 2),
+                        '%M': zeroPad(date.getMinutes(), 2),
+                        '%S': zeroPad(date.getSeconds(), 2),
+                        '%z': date.toTimeString().replace(/.+GMT([+-]\d+).+/, '$1'),
+                        '%Z': date.toTimeString().replace(/.+\((.+?)\)$/, '$1')
+                    }[sMatch] || '') + '') || sMatch;
+                });
+                root.setAttribute('datetime', datetime);
+            }  else {
+                // Otherwise, use core/user_date.
+                var timestamps = [{
+                    timestamp: timestamp,
+                    format: dateTimeFormat,
+                    type: 'gregorian',
+                    fixday: 0,
+                    fixhour: 0
+                }];
+                UserDate.get(timestamps).done(function(dates) {
+                    var datetime = dates.pop();
+                    root.setAttribute('datetime', datetime);
+                });
+            }
+        }
+    });
+{{/js}}
index ce5da1f..3c4d9a4 100644 (file)
@@ -114,10 +114,14 @@ abstract class testing_block_generator extends component_generator_base {
      * @return stdClass the block_instance record that has just been created.
      */
     public function create_instance($record = null, $options = array()) {
-        global $DB;
+        global $DB, $PAGE;
 
         $this->instancecount++;
 
+        // Creating a block is a back end operation, which should not cause any output to happen.
+        // This will allow us to check that the theme was not initialised while creating the block instance.
+        $outputstartedbefore = $PAGE->get_where_theme_was_initialised();
+
         $record = (object)(array)$record;
         $this->preprocess_record($record, $options);
         $record = $this->prepare_record($record);
@@ -133,6 +137,19 @@ abstract class testing_block_generator extends component_generator_base {
         context_block::instance($id);
 
         $instance = $DB->get_record('block_instances', array('id' => $id), '*', MUST_EXIST);
+
+        // If the theme was initialised while creating the block instance, something somewhere called an output
+        // function. Rather than leaving this as a hard-to-debug situation, let's make it fail with a clear error.
+        $outputstartedafter = $PAGE->get_where_theme_was_initialised();
+
+        if ($outputstartedbefore === null && $outputstartedafter !== null) {
+            throw new coding_exception('Creating a block_' . $this->get_blockname() . ' initialised the theme and output!',
+                'This should not happen. Creating a block should be a pure back-end operation. Unnecessarily initialising ' .
+                'the output mechanism at the wrong time can cause subtle bugs and is a significant performance hit. There is ' .
+                'likely a call to an output function that caused it:' . PHP_EOL . PHP_EOL .
+                format_backtrace($outputstartedafter, true));
+        }
+
         return $instance;
     }
 
index 5400648..4a2396e 100644 (file)
@@ -222,11 +222,15 @@ abstract class testing_module_generator extends component_generator_base {
      *     cmid (corresponding id in course_modules table)
      */
     public function create_instance($record = null, array $options = null) {
-        global $CFG, $DB;
+        global $CFG, $DB, $PAGE;
         require_once($CFG->dirroot.'/course/modlib.php');
 
         $this->instancecount++;
 
+        // Creating an activity is a back end operation, which should not cause any output to happen.
+        // This will allow us to check that the theme was not initialised while creating the module instance.
+        $outputstartedbefore = $PAGE->get_where_theme_was_initialised();
+
         // Merge options into record and add default values.
         $record = $this->prepare_moduleinfo_record($record, $options);
 
@@ -269,6 +273,19 @@ abstract class testing_module_generator extends component_generator_base {
         // Prepare object to return with additional field cmid.
         $instance = $DB->get_record($this->get_modulename(), array('id' => $moduleinfo->instance), '*', MUST_EXIST);
         $instance->cmid = $moduleinfo->coursemodule;
+
+        // If the theme was initialised while creating the module instance, something somewhere called an output
+        // function. Rather than leaving this as a hard-to-debug situation, let's make it fail with a clear error.
+        $outputstartedafter = $PAGE->get_where_theme_was_initialised();
+
+        if ($outputstartedbefore === null && $outputstartedafter !== null) {
+            throw new coding_exception('Creating a mod_' . $this->get_modulename() . ' activity initialised the theme and output!',
+                'This should not happen. Creating an activity should be a pure back-end operation. Unnecessarily initialising ' .
+                'the output mechanism at the wrong time can cause subtle bugs and is a significant performance hit. There is ' .
+                'likely a call to an output function that caused it:' . PHP_EOL . PHP_EOL .
+                format_backtrace($outputstartedafter, true));
+        }
+
         return $instance;
     }
 
index 3d5f560..21a4066 100644 (file)
@@ -111,12 +111,16 @@ class testing_repository_generator extends component_generator_base {
      * @return stdClass repository instance record
      */
     public function create_instance($record = null, array $options = null) {
-        global $CFG, $DB;
+        global $CFG, $DB, $PAGE;
         require_once($CFG->dirroot . '/repository/lib.php');
 
         $this->instancecount++;
         $record = (array) $record;
 
+        // Creating a repository is a back end operation, which should not cause any output to happen.
+        // This will allow us to check that the theme was not initialised while creating the repository instance.
+        $outputstartedbefore = $PAGE->get_where_theme_was_initialised();
+
         $typeid = $DB->get_field('repository', 'id', array('type' => $this->get_typename()), MUST_EXIST);
         $instanceoptions = repository::static_function($this->get_typename(), 'get_instance_option_names');
 
@@ -146,6 +150,18 @@ class testing_repository_generator extends component_generator_base {
             $id = repository::static_function($this->get_typename(), 'create', $this->get_typename(), 0, $context, $record);
         }
 
+        // If the theme was initialised while creating the repository instance, something somewhere called an output
+        // function. Rather than leaving this as a hard-to-debug situation, let's make it fail with a clear error.
+        $outputstartedafter = $PAGE->get_where_theme_was_initialised();
+
+        if ($outputstartedbefore === null && $outputstartedafter !== null) {
+            throw new coding_exception('Creating a repository_' . $this->get_typename() . ' initialised the theme and output!',
+                'This should not happen. Creating a repository should be a pure back-end operation. Unnecessarily initialising ' .
+                'the output mechanism at the wrong time can cause subtle bugs and is a significant performance hit. There is ' .
+                'likely a call to an output function that caused it:' . PHP_EOL . PHP_EOL .
+                format_backtrace($outputstartedafter, true));
+        }
+
         return $DB->get_record('repository_instances', array('id' => $id), '*', MUST_EXIST);
     }
 
index d63c084..117d323 100644 (file)
@@ -213,6 +213,53 @@ class core_grouplib_testcase extends advanced_testcase {
         $this->assertTrue(array_key_exists($student1->id, $users));
     }
 
+    public function test_groups_get_members_ids_sql_multiple_groups() {
+        global $DB;
+
+        $this->resetAfterTest(true);
+
+        $generator = $this->getDataGenerator();
+
+        $course = $generator->create_course();
+        $student1 = $generator->create_user();
+        $student2 = $generator->create_user();
+        $plugin = enrol_get_plugin('manual');
+        $role = $DB->get_record('role', array('shortname' => 'student'));
+        $group1 = $generator->create_group(array('courseid' => $course->id));
+        $group2 = $generator->create_group(array('courseid' => $course->id));
+        $groupids = [
+            $group1->id,
+            $group2->id,
+        ];
+        $instance = $DB->get_record('enrol', array(
+                'courseid' => $course->id,
+                'enrol' => 'manual',
+        ));
+
+        $this->assertNotEquals($instance, false);
+
+        // Enrol users in the course.
+        $plugin->enrol_user($instance, $student1->id, $role->id);
+        $plugin->enrol_user($instance, $student2->id, $role->id);
+
+        list($sql, $params) = groups_get_members_ids_sql($groupids);
+
+        // Test an empty group.
+        $users = $DB->get_records_sql($sql, $params);
+        $this->assertFalse(array_key_exists($student1->id, $users));
+
+        // Test with a member of one of the two group.
+        groups_add_member($group1->id, $student1->id);
+        $users = $DB->get_records_sql($sql, $params);
+        $this->assertTrue(array_key_exists($student1->id, $users));
+
+        // Test with members of two groups.
+        groups_add_member($group2->id, $student2->id);
+        $users = $DB->get_records_sql($sql, $params);
+        $this->assertTrue(array_key_exists($student1->id, $users));
+        $this->assertTrue(array_key_exists($student2->id, $users));
+    }
+
     public function test_groups_get_members_ids_sql_valid_context() {
         global $DB;
 
index 68379ea..db9c6a0 100644 (file)
@@ -60,6 +60,13 @@ information provided here is intended especially for developers.
   The confirmation dialogue no longer has a configurable "No" button as per similar changes in MDL-59759.
   This set of confirmation modals was unintentionally missed from that deprecation process.
 * The download_as_dataformat() method has been deprecated. Please use \core\dataformat::download_data() instead
+* The following functions have been updated to support passing in an array of group IDs (but still support passing in a single ID):
+  * groups_get_members_join()
+  * groups_get_members_ids_sql()
+* Additional parameters were added to core_get_user_dates:
+    - type: specifies the calendar type. Optional, defaults to Gregorian.
+    - fixday: Whether to remove leading zero for day. Optional, defaults to 1.
+    - fixhour: Whether to remove leading zero for hour. Optional, defaults to 1.
 
 === 3.8 ===
 * Add CLI option to notify all cron tasks to stop: admin/cli/cron.php --stop
index 5f65f2e..d9ded66 100644 (file)
@@ -297,7 +297,8 @@ function assign_update_events($assign, $override = null) {
 
         $event = new stdClass();
         $event->type = CALENDAR_EVENT_TYPE_ACTION;
-        $event->description = format_module_intro('assign', $assigninstance, $cmid);
+        $event->description = format_module_intro('assign', $assigninstance, $cmid, false);
+        $event->format = FORMAT_HTML;
         // Events module won't show user events when the courseid is nonzero.
         $event->courseid    = ($userid) ? 0 : $assigninstance->course;
         $event->groupid     = $groupid;
index 503023c..478fe8b 100644 (file)
@@ -123,7 +123,8 @@ function chat_add_instance($chat) {
         $event = new stdClass();
         $event->type        = CALENDAR_EVENT_TYPE_ACTION;
         $event->name        = $chat->name;
-        $event->description = format_module_intro('chat', $chat, $chat->coursemodule);
+        $event->description = format_module_intro('chat', $chat, $chat->coursemodule, false);
+        $event->format      = FORMAT_HTML;
         $event->courseid    = $chat->course;
         $event->groupid     = 0;
         $event->userid      = 0;
@@ -169,7 +170,8 @@ function chat_update_instance($chat) {
         if ($chat->schedule > 0) {
             $event->type        = CALENDAR_EVENT_TYPE_ACTION;
             $event->name        = $chat->name;
-            $event->description = format_module_intro('chat', $chat, $chat->coursemodule);
+            $event->description = format_module_intro('chat', $chat, $chat->coursemodule, false);
+            $event->format      = FORMAT_HTML;
             $event->timestart   = $chat->chattime;
             $event->timesort    = $chat->chattime;
 
@@ -186,7 +188,8 @@ function chat_update_instance($chat) {
             $event = new stdClass();
             $event->type        = CALENDAR_EVENT_TYPE_ACTION;
             $event->name        = $chat->name;
-            $event->description = format_module_intro('chat', $chat, $chat->coursemodule);
+            $event->description = format_module_intro('chat', $chat, $chat->coursemodule, false);
+            $event->format      = FORMAT_HTML;
             $event->courseid    = $chat->course;
             $event->groupid     = 0;
             $event->userid      = 0;
@@ -460,7 +463,8 @@ function chat_prepare_update_events($chat, $cm = null) {
     $event = new stdClass();
     $event->name        = $chat->name;
     $event->type        = CALENDAR_EVENT_TYPE_ACTION;
-    $event->description = format_module_intro('chat', $chat, $cm->id);
+    $event->description = format_module_intro('chat', $chat, $cm->id, false);
+    $event->format      = FORMAT_HTML;
     $event->timestart   = $chat->chattime;
     $event->timesort    = $chat->chattime;
     if ($event->id = $DB->get_field('event', 'id', array('modulename' => 'chat', 'instance' => $chat->id,
index b5cb29b..a00641d 100644 (file)
@@ -52,7 +52,8 @@ function choice_set_events($choice) {
         if ((!empty($choice->timeopen)) && ($choice->timeopen > 0)) {
             // Calendar event exists so update it.
             $event->name         = get_string('calendarstart', 'choice', $choice->name);
-            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule);
+            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->timestart    = $choice->timeopen;
             $event->timesort     = $choice->timeopen;
             $event->visible      = instance_is_visible('choice', $choice);
@@ -68,7 +69,8 @@ function choice_set_events($choice) {
         // Event doesn't exist so create one.
         if ((!empty($choice->timeopen)) && ($choice->timeopen > 0)) {
             $event->name         = get_string('calendarstart', 'choice', $choice->name);
-            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule);
+            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->courseid     = $choice->course;
             $event->groupid      = 0;
             $event->userid       = 0;
@@ -91,7 +93,8 @@ function choice_set_events($choice) {
         if ((!empty($choice->timeclose)) && ($choice->timeclose > 0)) {
             // Calendar event exists so update it.
             $event->name         = get_string('calendarend', 'choice', $choice->name);
-            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule);
+            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->timestart    = $choice->timeclose;
             $event->timesort     = $choice->timeclose;
             $event->visible      = instance_is_visible('choice', $choice);
@@ -107,7 +110,8 @@ function choice_set_events($choice) {
         // Event doesn't exist so create one.
         if ((!empty($choice->timeclose)) && ($choice->timeclose > 0)) {
             $event->name         = get_string('calendarend', 'choice', $choice->name);
-            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule);
+            $event->description  = format_module_intro('choice', $choice, $choice->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->courseid     = $choice->course;
             $event->groupid      = 0;
             $event->userid       = 0;
index b8bad67..c915408 100644 (file)
@@ -607,7 +607,8 @@ function data_set_events($data) {
         if ($data->timeavailablefrom > 0) {
             // Calendar event exists so update it.
             $event->name         = get_string('calendarstart', 'data', $data->name);
-            $event->description  = format_module_intro('data', $data, $data->coursemodule);
+            $event->description  = format_module_intro('data', $data, $data->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->timestart    = $data->timeavailablefrom;
             $event->timesort     = $data->timeavailablefrom;
             $event->visible      = instance_is_visible('data', $data);
@@ -623,7 +624,8 @@ function data_set_events($data) {
         // Event doesn't exist so create one.
         if (isset($data->timeavailablefrom) && $data->timeavailablefrom > 0) {
             $event->name         = get_string('calendarstart', 'data', $data->name);
-            $event->description  = format_module_intro('data', $data, $data->coursemodule);
+            $event->description  = format_module_intro('data', $data, $data->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->courseid     = $data->course;
             $event->groupid      = 0;
             $event->userid       = 0;
@@ -646,7 +648,8 @@ function data_set_events($data) {
         if ($data->timeavailableto > 0) {
             // Calendar event exists so update it.
             $event->name         = get_string('calendarend', 'data', $data->name);
-            $event->description  = format_module_intro('data', $data, $data->coursemodule);
+            $event->description  = format_module_intro('data', $data, $data->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->timestart    = $data->timeavailableto;
             $event->timesort     = $data->timeavailableto;
             $event->visible      = instance_is_visible('data', $data);
@@ -662,7 +665,8 @@ function data_set_events($data) {
         // Event doesn't exist so create one.
         if (isset($data->timeavailableto) && $data->timeavailableto > 0) {
             $event->name         = get_string('calendarend', 'data', $data->name);
-            $event->description  = format_module_intro('data', $data, $data->coursemodule);
+            $event->description  = format_module_intro('data', $data, $data->coursemodule, false);
+            $event->format       = FORMAT_HTML;
             $event->courseid     = $data->course;
             $event->groupid      = 0;
             $event->userid       = 0;
index a318641..458cb73 100644 (file)
@@ -809,7 +809,8 @@ function feedback_set_events($feedback) {
         $event->eventtype    = FEEDBACK_EVENT_TYPE_OPEN;
         $event->type         = empty($feedback->timeclose) ? CALENDAR_EVENT_TYPE_ACTION : CALENDAR_EVENT_TYPE_STANDARD;
         $event->name         = get_string('calendarstart', 'feedback', $feedback->name);
-        $event->description  = format_module_intro('feedback', $feedback, $feedback->coursemodule);
+        $event->description  = format_module_intro('feedback', $feedback, $feedback->coursemodule, false);
+        $event->format       = FORMAT_HTML;
         $event->timestart    = $feedback->timeopen;
         $event->timesort     = $feedback->timeopen;
         $event->visible      = instance_is_visible('feedback', $feedback);
@@ -844,7 +845,8 @@ function feedback_set_events($feedback) {
         $event->type         = CALENDAR_EVENT_TYPE_ACTION;
         $event->eventtype    = FEEDBACK_EVENT_TYPE_CLOSE;
         $event->name         = get_string('calendarend', 'feedback', $feedback->name);
-        $event->description  = format_module_intro('feedback', $feedback, $feedback->coursemodule);
+        $event->description  = format_module_intro('feedback', $feedback, $feedback->coursemodule, false);
+        $event->format       = FORMAT_HTML;
         $event->timestart    = $feedback->timeclose;
         $event->timesort     = $feedback->timeclose;
         $event->visible      = instance_is_visible('feedback', $feedback);
index 8faf0f9..a1919f6 100644 (file)
@@ -638,9 +638,8 @@ class post extends exporter {
     private function get_author_subheading_html(stdClass $exportedauthor, int $timecreated) : string {
         $fullname = $exportedauthor->fullname;
         $profileurl = $exportedauthor->urls['profile'] ?? null;
-        $formatteddate = userdate($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
         $name = $profileurl ? "<a href=\"{$profileurl}\">{$fullname}</a>" : $fullname;
-        $date = "<time>{$formatteddate}</time>";
+        $date = userdate_htmltime($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
         return get_string('bynameondate', 'mod_forum', ['name' => $name, 'date' => $date]);
     }
 }
index ab333a9..d13042d 100644 (file)
@@ -723,7 +723,8 @@ function forum_update_calendar($forum, $cmid) {
 
     if (!empty($forum->duedate)) {
         $event->name = get_string('calendardue', 'forum', $forum->name);
-        $event->description = format_module_intro('forum', $forum, $cmid);
+        $event->description = format_module_intro('forum', $forum, $cmid, false);
+        $event->format = FORMAT_HTML;
         $event->courseid = $forum->course;
         $event->modulename = 'forum';
         $event->instance = $forum->id;
index 50cc04d..da67f20 100644 (file)
                                         <div class="author-info align-middle">
                                             <div class="mb-1 line-height-3 text-truncate">{{fullname}}</div>
                                             <div class="line-height-3">
-                                                {{#userdate}}{{discussion.times.created}}, {{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdate}}
+                                                {{< core/time_element }}
+                                                    {{$elementid}}created-{{discussion.id}}{{/elementid}}
+                                                    {{$timestampval}}{{discussion.times.created}}{{/timestampval}}
+                                                    {{$userdateformatval}}{{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdateformatval}}
+                                                {{/core/time_element}}
                                             </div>
                                         </div>
                                     </div>
                                             <div class="line-height-3">
                                                 {{#latestpostid}}
                                                     <a href="{{{discussion.urls.viewlatest}}}" title="{{#userdate}}{{discussion.times.modified}},{{#str}}strftimerecentfull{{/str}}{{/userdate}}">
-                                                        {{#userdate}}{{discussion.times.modified}}, {{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdate}}
+                                                        {{< core/time_element }}
+                                                            {{$elementid}}modified-{{discussion.id}}{{/elementid}}
+                                                            {{$timestampval}}{{discussion.times.modified}}{{/timestampval}}
+                                                            {{$userdateformatval}}{{#str}}strftimedatemonthabbr, langconfig{{/str}}{{/userdateformatval}}
+                                                        {{/ core/time_element }}
                                                     </a>
                                                 {{/latestpostid}}
                                             </div>
index 32b44d0..f138cd2 100644 (file)
@@ -77,7 +77,7 @@
             <header id="post-header-{{id}}-{{uniqid}}">
                 {{^isdeleted}}
                     <div class="d-flex flex-wrap align-items-center mb-1">
-                        <address class="mb-0 mr-2" tabindex="-1">
+                        <div class="mr-2" tabindex="-1">
                             {{#author}}
                                 <h4 class="h6 d-lg-inline-block mb-0 author-header mr-1">
                                     {{#parentauthorname}}
                                     {{/parentauthorname}}
                                 </h4>
                             {{/author}}
-                            <time class="text-muted">
-                                {{#userdate}} {{timecreated}}, {{#str}} strftimerecentfull, core_langconfig {{/str}} {{/userdate}}
-                            </time>
-                        </address>
+                            {{< core/time_element }}
+                                {{$elementid}}created-{{id}}-{{uniqid}}{{/elementid}}
+                                {{$elementclass}}text-muted{{/elementclass}}
+                                {{$timestampval}}{{timecreated}}{{/timestampval}}
+                                {{$userdateformatval}}{{#str}} strftimerecentfull, core_langconfig {{/str}}{{/userdateformatval}}
+                            {{/core/time_element}}
+                        </div>
 
                         <div class="d-flex align-items-center ml-auto">
                             {{#author.groups}}
index dad785c..868c1e8 100644 (file)
                             }}>{{$subject}}{{{subject}}}{{/subject}}</h3>
                     {{/subjectheading}}
                     {{^isdeleted}}
-                        <address tabindex="-1">
+                        <div class="mb-3" tabindex="-1">
                             {{#html.authorsubheading}}{{{.}}}{{/html.authorsubheading}}
                             {{^html.authorsubheading}}
-                                <time>
-                                    {{#userdate}} {{timecreated}}, {{#str}} strftimedaydatetime, core_langconfig {{/str}} {{/userdate}}
-                                </time>
+                                {{< core/time_element }}
+                                    {{$elementid}}created-{{id}}-{{uniqid}}{{/elementid}}
+                                    {{$timestampval}}{{timecreated}}{{/timestampval}}
+                                    {{$userdateformatval}}{{#str}} strftimedaydatetime, core_langconfig {{/str}}{{/userdateformatval}}
+                                {{/core/time_element}}
                             {{/html.authorsubheading}}
-                        </address>
+                        </div>
                     {{/isdeleted}}
                     {{#isprivatereply}}
                         <div class="privatereplyinfo">
index fd4c034..a4819dd 100644 (file)
@@ -38,9 +38,7 @@
 >
     <a href="{{{urls.viewisolated}}}">{{subject}}</a>
     {{^isdeleted}}
-        <address class="d-inline-block mb-0">
-            {{{html.authorsubheading}}}
-        </address>
+        {{{html.authorsubheading}}}
     {{/isdeleted}}
 
     <div data-region="replies-container">
index 303c195..bb071a0 100644 (file)
@@ -371,9 +371,8 @@ class mod_forum_generator extends testing_module_generator {
     public function get_author_subheading_html(stdClass $exportedauthor, int $timecreated) : string {
         $fullname = $exportedauthor->fullname;
         $profileurl = $exportedauthor->urls['profile'] ?? null;
-        $formatteddate = userdate($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
         $name = $profileurl ? "<a href=\"{$profileurl}\">{$fullname}</a>" : $fullname;
-        $date = "<time>{$formatteddate}</time>";
+        $date = userdate_htmltime($timecreated, get_string('strftimedaydatetime', 'core_langconfig'));
         return get_string('bynameondate', 'mod_forum', ['name' => $name, 'date' => $date]);
     }
 }
index 5c9f43e..9514e74 100644 (file)
@@ -108,7 +108,7 @@ $PAGE->set_heading($course->fullname);
 $PAGE->set_button(forum_search_form($course, $search));
 
 if ($istypesingle && $displaymode == FORUM_MODE_NESTED_V2) {
-    $PAGE->add_body_class('reset-style');
+    $PAGE->add_body_class('nested-v2-display-mode reset-style');
     $settingstrigger = $OUTPUT->render_from_template('mod_forum/settings_drawer_trigger', null);
     $PAGE->add_header_action($settingstrigger);
 }
index 107a023..76a4522 100644 (file)
@@ -41,7 +41,7 @@ class backup_h5pactivity_activity_structure_step extends backup_activity_structu
         // Replace with the attributes and final elements that the element will handle.
         $attributes = ['id'];
         $finalelements = ['name', 'timecreated', 'timemodified', 'intro',
-                'introformat', 'grade', 'displayoptions'];
+                'introformat', 'grade', 'displayoptions', 'enabletracking', 'grademethod'];
         $root = new backup_nested_element('h5pactivity', $attributes, $finalelements);
 
         $attempts = new backup_nested_element('attempts');
index e4221fb..e037ebb 100644 (file)
@@ -28,26 +28,28 @@ namespace mod_h5pactivity\local;
 use stdClass;
 use core_xapi\local\statement;
 
-defined('MOODLE_INTERNAL') || die();
-
 /**
  * Class attempt for H5P activity
  *
  * @package    mod_h5pactivity
  * @since      Moodle 3.9
  * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 class attempt {
 
     /** @var stdClass the h5pactivity_attempts record. */
     private $record;
 
+    /** @var boolean if the DB statement has been updated. */
+    private $scoreupdated = false;
+
     /**
      * Create a new attempt object.
      *
      * @param stdClass $record the h5pactivity_attempts record
      */
-    protected function __construct(stdClass $record) {
+    public function __construct(stdClass $record) {
         $this->record = $record;
         $this->results = null;
     }
@@ -199,12 +201,15 @@ class attempt {
         }
 
         // If no subcontent provided, results are propagated to the attempt itself.
-        if (empty($subcontent) && $record->rawscore) {
-            $this->record->rawscore = $record->rawscore;
-            $this->record->maxscore = $record->maxscore;
-            $this->record->duration = $record->duration;
-            $this->record->completion = $record->completion ?? null;
-            $this->record->success = $record->success ?? null;
+        if (empty($subcontent)) {
+            $this->set_duration($record->duration);
+            $this->set_completion($record->completion ?? null);
+            $this->set_success($record->success ?? null);
+            // If Maxscore is not empty means that the rawscore is valid (even if it's 0)
+            // and scaled score can be calculated.
+            if ($record->maxscore) {
+                $this->set_score($record->rawscore, $record->maxscore);
+            }
         }
         // Refresh current attempt.
         return $this->save();
@@ -218,9 +223,56 @@ class attempt {
     public function save(): bool {
         global $DB;
         $this->record->timemodified = time();
+        // Calculate scaled score.
+        if ($this->scoreupdated) {
+            if (empty($this->record->maxscore)) {
+                $this->record->scaled = 0;
+            } else {
+                $this->record->scaled = $this->record->rawscore / $this->record->maxscore;
+            }
+        }
         return $DB->update_record('h5pactivity_attempts', $this->record);
     }
 
+    /**
+     * Set the attempt score.
+     *
+     * @param int|null $rawscore the attempt rawscore
+     * @param int|null $maxscore the attempt maxscore
+     */
+    public function set_score(?int $rawscore, ?int $maxscore): void {
+        $this->record->rawscore = $rawscore;
+        $this->record->maxscore = $maxscore;
+        $this->scoreupdated = true;
+    }
+
+    /**
+     * Set the attempt duration.
+     *
+     * @param int|null $duration the attempt duration
+     */
+    public function set_duration(?int $duration): void {
+        $this->record->duration = $duration;
+    }
+
+    /**
+     * Set the attempt completion.
+     *
+     * @param int|null $completion the attempt completion
+     */
+    public function set_completion(?int $completion): void {
+        $this->record->completion = $completion;
+    }
+
+    /**
+     * Set the attempt success.
+     *
+     * @param int|null $success the attempt success
+     */
+    public function set_success(?int $success): void {
+        $this->record->success = $success;
+    }
+
     /**
      * Delete the current attempt results from the DB.
      */
@@ -376,15 +428,15 @@ class attempt {
      * @return int the rawscore value
      */
     public function get_rawscore(): int {
-        return $this->record->maxscore;
+        return $this->record->rawscore;
     }
 
     /**
      * Return the attempt duration.
      *
-     * @return int the duration value
+     * @return int|null the duration value
      */
-    public function get_duration(): int {
+    public function get_duration(): ?int {
         return $this->record->duration;
     }
 
@@ -405,4 +457,16 @@ class attempt {
     public function get_success(): ?int {
         return $this->record->success;
     }
+
+    /**
+     * Return if the attempt has been modified.
+     *
+     * Note: adding a result only add track information unless the statement does
+     * not specify subcontent. In this case this will update also the statement.
+     *
+     * @return bool if the attempt score have been modified
+     */
+    public function get_scoreupdated(): bool {
+        return $this->scoreupdated;
+    }
 }
diff --git a/mod/h5pactivity/classes/local/grader.php b/mod/h5pactivity/classes/local/grader.php
new file mode 100644 (file)
index 0000000..85feba7
--- /dev/null
@@ -0,0 +1,214 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * H5P activity grader class.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use context_module;
+use cm_info;
+use moodle_recordset;
+use stdClass;
+
+/**
+ * Class for handling H5P activity grading.
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class grader {
+
+    /** @var stdClass course_module record. */
+    private $instance;
+
+    /** @var string idnumber course_modules idnumber. */
+    private $idnumber;
+
+    /**
+     * Class contructor.
+     *
+     * @param stdClass $instance H5Pactivity instance object
+     * @param string $idnumber course_modules idnumber
+     */
+    public function __construct(stdClass $instance, string $idnumber = '') {
+        $this->instance = $instance;
+        $this->idnumber = $idnumber;
+    }
+
+    /**
+     * Delete grade item for given mod_h5pactivity instance.
+     *
+     * @return int Returns GRADE_UPDATE_OK, GRADE_UPDATE_FAILED, GRADE_UPDATE_MULTIPLE or GRADE_UPDATE_ITEM_LOCKED
+     */
+    public function grade_item_delete(): ?int {
+        global $CFG;
+        require_once($CFG->libdir.'/gradelib.php');
+
+        return grade_update('mod/h5pactivity', $this->instance->course, 'mod', 'h5pactivity',
+                $this->instance->id, 0, null, ['deleted' => 1]);
+    }
+
+    /**
+     * Creates or updates grade item for the given mod_h5pactivity instance.
+     *
+     * @param mixed $grades optional array/object of grade(s); 'reset' means reset grades in gradebook
+     * @return int 0 if ok, error code otherwise
+     */
+    public function grade_item_update($grades = null): int {
+        global $CFG;
+        require_once($CFG->libdir.'/gradelib.php');
+
+        $item = [];
+        $item['itemname'] = clean_param($this->instance->name, PARAM_NOTAGS);
+        $item['gradetype'] = GRADE_TYPE_VALUE;
+        if (!empty($this->idnumber)) {
+            $item['idnumber'] = $this->idnumber;
+        }
+
+        if ($this->instance->grade > 0) {
+            $item['gradetype'] = GRADE_TYPE_VALUE;
+            $item['grademax']  = $this->instance->grade;
+            $item['grademin']  = 0;
+        } else if ($this->instance->grade < 0) {
+            $item['gradetype'] = GRADE_TYPE_SCALE;
+            $item['scaleid']   = -$this->instance->grade;
+        } else {
+            $item['gradetype'] = GRADE_TYPE_NONE;
+        }
+
+        if ($grades === 'reset') {
+            $item['reset'] = true;
+            $grades = null;
+        }
+
+        return grade_update('mod/h5pactivity', $this->instance->course, 'mod',
+                'h5pactivity', $this->instance->id, 0, $grades, $item);
+    }
+
+    /**
+     * Update grades in the gradebook.
+     *
+     * @param int $userid Update grade of specific user only, 0 means all participants.
+     */
+    public function update_grades(int $userid = 0): void {
+        // Scaled and none grading doesn't have grade calculation.
+        if ($this->instance->grade <= 0) {
+            $this->grade_item_update();
+            return;
+        }
+        // Populate array of grade objects indexed by userid.
+        $grades = $this->get_user_grades_for_gradebook($userid);
+
+        if (!empty($grades)) {
+            $this->grade_item_update($grades);
+        } else {
+            $this->grade_item_update();
+        }
+    }
+
+    /**
+     * Get an updated list of user grades and feedback for the gradebook.
+     *
+     * @param int $userid int or 0 for all users
+     * @return array of grade data formated for the gradebook api
+     *         The data required by the gradebook api is userid,
+     *                                                   rawgrade,
+     *                                                   feedback,
+     *                                                   feedbackformat,
+     *                                                   usermodified,
+     *                                                   dategraded,
+     *                                                   datesubmitted
+     */
+    private function get_user_grades_for_gradebook(int $userid = 0): array {
+        $grades = [];
+
+        // In case of using manual grading this update must delete previous automatic gradings.
+        if ($this->instance->grademethod == manager::GRADEMANUAL || !$this->instance->enabletracking) {
+            return $this->get_user_grades_for_deletion($userid);
+        }
+
+        $manager = manager::create_from_instance($this->instance);
+
+        $scores = $manager->get_users_scaled_score($userid);
+        if (!$scores) {
+            return $grades;
+        }
+
+        // Maxgrade depends on the type of grade used:
+        // - grade > 0: regular quantitative grading.
+        // - grade = 0: no grading.
+        // - grade < 0: scale used.
+        $maxgrade = floatval($this->instance->grade);
+
+        // Convert scaled scores into gradebok compatible objects.
+        foreach ($scores as $userid => $score) {
+            $grades[$userid] = [
+                'userid' => $userid,
+                'rawgrade' => $maxgrade * $score->scaled,
+                'dategraded' => $score->timemodified,
+                'datesubmitted' => $score->timemodified,
+            ];
+        }
+
+        return $grades;
+    }
+
+    /**
+     * Get an deletion list of user grades and feedback for the gradebook.
+     *
+     * This method is used to delete all autmatic gradings when grading method is set to manual.
+     *
+     * @param int $userid int or 0 for all users
+     * @return array of grade data formated for the gradebook api
+     *         The data required by the gradebook api is userid,
+     *                                                   rawgrade (null to delete),
+     *                                                   dategraded,
+     *                                                   datesubmitted
+     */
+    private function get_user_grades_for_deletion (int $userid = 0): array {
+        $grades = [];
+
+        if ($userid) {
+            $grades[$userid] = [
+                'userid' => $userid,
+                'rawgrade' => null,
+                'dategraded' => time(),
+                'datesubmitted' => time(),
+            ];
+        } else {
+            $manager = manager::create_from_instance($this->instance);
+            $users = get_enrolled_users($manager->get_context(), 'mod/h5pactivity:submit');
+            foreach ($users as $user) {
+                $grades[$user->id] = [
+                    'userid' => $user->id,
+                    'rawgrade' => null,
+                    'dategraded' => time(),
+                    'datesubmitted' => time(),
+                ];
+            }
+        }
+        return $grades;
+    }
+}
diff --git a/mod/h5pactivity/classes/local/manager.php b/mod/h5pactivity/classes/local/manager.php
new file mode 100644 (file)
index 0000000..466995a
--- /dev/null
@@ -0,0 +1,237 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * H5P activity manager class
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use context_module;
+use cm_info;
+use moodle_recordset;
+use stdClass;
+
+/**
+ * Class manager for H5P activity
+ *
+ * @package    mod_h5pactivity
+ * @since      Moodle 3.9
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class manager {
+
+    /** No automathic grading using attempt results. */
+    const GRADEMANUAL = 0;
+
+    /** Use highest attempt results for grading. */
+    const GRADEHIGHESTATTEMPT = 1;
+
+    /** Use average attempt results for grading. */
+    const GRADEAVERAGEATTEMPT = 2;
+
+    /** Use last attempt results for grading. */
+    const GRADELASTATTEMPT = 3;
+
+    /** Use first attempt results for grading. */
+    const GRADEFIRSTATTEMPT = 4;
+
+    /** @var stdClass course_module record. */
+    private $instance;
+
+    /** @var context_module the current context. */
+    private $context;
+
+    /** @var cm_info course_modules record. */
+    private $coursemodule;
+
+    /**
+     * Class contructor.
+     *
+     * @param cm_info $coursemodule course module info object
+     * @param stdClass $instance H5Pactivity instance object.
+     */
+    public function __construct(cm_info $coursemodule, stdClass $instance) {
+        $this->coursemodule = $coursemodule;
+        $this->instance = $instance;
+        $this->context = context_module::instance($coursemodule->id);
+        $this->instance->cmidnumber = $coursemodule->idnumber;
+    }
+
+    /**
+     * Create a manager instance from an instance record.
+     *
+     * @param stdClass $instance a h5pactivity record
+     * @return manager
+     */
+    public static function create_from_instance(stdClass $instance): self {
+        $coursemodule = get_coursemodule_from_instance('h5pactivity', $instance->id);
+        // Ensure that $this->coursemodule is a cm_info object.
+        $coursemodule = cm_info::create($coursemodule);
+        return new self($coursemodule, $instance);
+    }
+
+    /**
+     * Create a manager instance from an course_modules record.
+     *
+     * @param stdClass|cm_info $coursemodule a h5pactivity record
+     * @return manager
+     */
+    public static function create_from_coursemodule($coursemodule): self {
+        global $DB;
+        // Ensure that $this->coursemodule is a cm_info object.
+        $coursemodule = cm_info::create($coursemodule);
+        $instance = $DB->get_record('h5pactivity', ['id' => $coursemodule->instance], '*', MUST_EXIST);
+        return new self($coursemodule, $instance);
+    }
+
+    /**
+     * Return the available grading methods.
+     * @return string[] an array "option value" => "option description"
+     */
+    public static function get_grading_methods(): array {
+        return [
+            self::GRADEHIGHESTATTEMPT => get_string('grade_highest_attempt', 'mod_h5pactivity'),
+            self::GRADEAVERAGEATTEMPT => get_string('grade_average_attempt', 'mod_h5pactivity'),
+            self::GRADELASTATTEMPT => get_string('grade_last_attempt', 'mod_h5pactivity'),
+            self::GRADEFIRSTATTEMPT => get_string('grade_first_attempt', 'mod_h5pactivity'),
+            self::GRADEMANUAL => get_string('grade_manual', 'mod_h5pactivity'),
+        ];
+    }
+
+    /**
+     * Check if tracking is enabled in a particular h5pactivity for a specific user.
+     *
+     * @param stdClass|null $user user record (default $USER)
+     * @return bool if tracking is enabled in this activity
+     */
+    public function is_tracking_enabled(stdClass $user = null): bool {
+        global $USER;
+        if (!$this->instance->enabletracking) {
+            return false;
+        }
+        if (empty($user)) {
+            $user = $USER;
+        }
+        return has_capability('mod/h5pactivity:submit', $this->context, $user, false);
+    }
+
+    /**
+     * Return a relation of userid and the valid attempt's scaled score.
+     *
+     * The returned elements contain a record
+     * of userid, scaled value, attemptid and timemodified. In case the grading method is "GRADEAVERAGEATTEMPT"
+     * the attemptid will be zero. In case that tracking is disabled or grading method is "GRADEMANUAL"
+     * the method will return null.
+     *
+     * @param int $userid a specific userid or 0 for all user attempts.
+     * @return array|null of userid, scaled value and, if exists, the attempt id
+     */
+    public function get_users_scaled_score(int $userid = 0): ?array {
+        global $DB;
+
+        $scaled = [];
+        if (!$this->instance->enabletracking) {
+            return null;
+        }
+
+        if ($this->instance->grademethod == self::GRADEMANUAL) {
+            return null;
+        }
+
+        $sql = '';
+
+        // General filter.
+        $where = 'a.h5pactivityid = :h5pactivityid';
+        $params['h5pactivityid'] = $this->instance->id;
+
+        if ($userid) {
+            $where .= ' AND a.userid = :userid';
+            $params['userid'] = $userid;
+        }
+
+        // Average grading needs aggregation query.
+        if ($this->instance->grademethod == self::GRADEAVERAGEATTEMPT) {
+            $sql = "SELECT a.userid, AVG(a.scaled) AS scaled, 0 AS attemptid, MAX(timemodified) AS timemodified
+                      FROM {h5pactivity_attempts} a
+                     WHERE $where AND a.completion = 1
+                  GROUP BY a.userid";
+        }
+
+        if (empty($sql)) {
+            // Decide which attempt is used for the calculation.
+            $condition = [
+                self::GRADEHIGHESTATTEMPT => "a.scaled < b.scaled",
+                self::GRADELASTATTEMPT => "a.attempt < b.attempt",
+                self::GRADEFIRSTATTEMPT => "a.attempt > b.attempt",
+            ];
+            $join = $condition[$this->instance->grademethod] ?? $condition[self::GRADEHIGHESTATTEMPT];
+
+            $sql = "SELECT a.userid, a.scaled, MAX(a.id) AS attemptid, MAX(a.timemodified) AS timemodified
+                      FROM {h5pactivity_attempts} a
+                 LEFT JOIN {h5pactivity_attempts} b ON a.h5pactivityid = b.h5pactivityid
+                           AND a.userid = b.userid AND b.completion = 1
+                           AND $join
+                     WHERE $where AND b.id IS NULL AND a.completion = 1
+                  GROUP BY a.userid, a.scaled";
+        }
+
+        return $DB->get_records_sql($sql, $params);
+    }
+
+    /**
+     * Return the current context.
+     *
+     * @return context_module
+     */
+    public function get_context(): context_module {
+        return $this->context;
+    }
+
+    /**
+     * Return the current context.
+     *
+     * @return stdClass the instance record
+     */
+    public function get_instance(): stdClass {
+        return $this->instance;
+    }
+
+    /**
+     * Return the current cm_info.
+     *
+     * @return cm_info the course module
+     */
+    public function get_coursemodule(): cm_info {
+        return $this->coursemodule;
+    }
+
+    /**
+     * Return the specific grader object for this activity.
+     *
+     * @return grader
+     */
+    public function get_grader(): grader {
+        $idnumber = $this->coursemodule->idnumber ?? '';
+        return new grader($this->instance, $idnumber);
+    }
+}
index 8f6f176..729d68e 100644 (file)
@@ -35,8 +35,6 @@ use core_privacy\local\request\userlist;
 use core_privacy\local\request\writer;
 use stdClass;
 
-defined('MOODLE_INTERNAL') || die();
-
 /**
  * Privacy API implementation for the H5P activity plugin.
  *
index 69667de..b05a910 100644 (file)
@@ -26,6 +26,7 @@
 namespace mod_h5pactivity\xapi;
 
 use mod_h5pactivity\local\attempt;
+use mod_h5pactivity\local\manager;
 use mod_h5pactivity\event\statement_received;
 use core_xapi\local\statement;
 use core_xapi\handler as handler_base;
@@ -34,6 +35,9 @@ use context_module;
 
 defined('MOODLE_INTERNAL') || die();
 
+global $CFG;
+require_once($CFG->dirroot.'/mod/h5pactivity/lib.php');
+
 /**
  * Class xapi_handler for H5P statements.
  *
@@ -94,15 +98,18 @@ class handler extends handler_base {
         if (!has_capability('mod/h5pactivity:view', $context, $user)) {
             return null;
         }
-        if (!has_capability('mod/h5pactivity:submit', $context, $user, false)) {
-            return null;
-        }
 
         $cm = get_coursemodule_from_id('h5pactivity', $context->instanceid, 0, false);
         if (!$cm) {
             return null;
         }
 
+        $manager = manager::create_from_coursemodule($cm);
+
+        if (!$manager->is_tracking_enabled($user)) {
+            return null;
+        }
+
         // For now, attempts are only processed on a single batch starting with the final "completed"
         // and "answered" statements (this could change in the future). This initial statement have no
         // subcontent defined as they are the main finishing statement. For this reason, this statement
@@ -122,7 +129,11 @@ class handler extends handler_base {
             return null;
         }
 
-        // TODO: update grading if necessary.
+        // Update activity if necessary.
+        if ($attempt->get_scoreupdated()) {
+            $grader = $manager->get_grader();
+            $grader->update_grades($user->id);
+        }
 
         // Convert into a Moodle event.
         $minstatement = $statement->minify();
index 549df46..0484154 100644 (file)
@@ -1,5 +1,5 @@
 <?xml version="1.0" encoding="UTF-8" ?>
-<XMLDB PATH="mod/h5pactivity/db" VERSION="20200414" COMMENT="XMLDB file for Moodle mod_h5pactivity"
+<XMLDB PATH="mod/h5pactivity/db" VERSION="20200422" COMMENT="XMLDB file for Moodle mod_h5pactivity"
     xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
     xsi:noNamespaceSchemaLocation="../../../lib/xmldb/xmldb.xsd"
 >
@@ -15,6 +15,8 @@
         <FIELD NAME="introformat" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="The format of the intro field."/>
         <FIELD NAME="grade" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="displayoptions" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="0" SEQUENCE="false" COMMENT="H5P Button display options"/>
+        <FIELD NAME="enabletracking" TYPE="int" LENGTH="1" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Enable xAPI tracking"/>
+        <FIELD NAME="grademethod" TYPE="int" LENGTH="4" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Which H5P attempt is used for grading"/>
       </FIELDS>
       <KEYS>
         <KEY NAME="primary" TYPE="primary" FIELDS="id"/>
@@ -31,6 +33,7 @@
         <FIELD NAME="attempt" TYPE="int" LENGTH="6" NOTNULL="true" DEFAULT="1" SEQUENCE="false" COMMENT="Attempt number"/>
         <FIELD NAME="rawscore" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
         <FIELD NAME="maxscore" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false"/>
+        <FIELD NAME="scaled" TYPE="number" LENGTH="10" NOTNULL="true" DEFAULT="0" SEQUENCE="false" DECIMALS="5" COMMENT="Number 0..1 that reflects the performance of the learner"/>
         <FIELD NAME="duration" TYPE="int" LENGTH="10" NOTNULL="false" DEFAULT="0" SEQUENCE="false" COMMENT="Number of second inverted in that attempt (provided by the statement)"/>
         <FIELD NAME="completion" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false" COMMENT="Store the xAPI tracking completion result."/>
         <FIELD NAME="success" TYPE="int" LENGTH="1" NOTNULL="false" SEQUENCE="false" COMMENT="Store the xAPI tracking success result."/>
index a5932e3..d0fb851 100644 (file)
  * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-/**
- * H5P activity module upgrade.
- *
- * @package    mod_h5pactivity
- * @copyright  2017 Stephen Bourget
- * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
- */
-
 defined('MOODLE_INTERNAL') || die();
 
 /**
@@ -190,5 +182,48 @@ function xmldb_h5pactivity_upgrade($oldversion) {
         upgrade_mod_savepoint(true, 2020041400, 'h5pactivity');
     }
 
+    if ($oldversion < 2020041401) {
+
+        // Define field enabletracking to be added to h5pactivity.
+        $table = new xmldb_table('h5pactivity');
+        $field = new xmldb_field('enabletracking', XMLDB_TYPE_INTEGER, '1', null, XMLDB_NOTNULL, null, '1', 'displayoptions');
+
+        // Conditionally launch add field enabletracking.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field grademethod to be added to h5pactivity.
+        $field = new xmldb_field('grademethod', XMLDB_TYPE_INTEGER, '4', null, XMLDB_NOTNULL, null, '1', 'enabletracking');
+
+        // Conditionally launch add field grademethod.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Define field scaled to be added to h5pactivity_attempts.
+        $table = new xmldb_table('h5pactivity_attempts');
+        $field = new xmldb_field('scaled', XMLDB_TYPE_NUMBER, '10, 5', null, XMLDB_NOTNULL, null, '0', 'maxscore');
+
+        // Conditionally launch add field scaled.
+        if (!$dbman->field_exists($table, $field)) {
+            $dbman->add_field($table, $field);
+        }
+
+        // Calculate all scaled values from current attempts.
+        $rs = $DB->get_recordset('h5pactivity_attempts');
+        foreach ($rs as $record) {
+            if (empty($record->maxscore)) {
+                continue;
+            }
+            $record->scaled = $record->rawscore / $record->maxscore;
+            $DB->update_record('h5pactivity_attempts', $record);
+        }
+        $rs->close();
+
+        // H5pactivity savepoint reached.
+        upgrade_mod_savepoint(true, 2020041401, 'h5pactivity');
+    }
+
     return true;
 }
index c76316d..c855830 100644 (file)
@@ -27,16 +27,32 @@ defined('MOODLE_INTERNAL') || die();
 
 $string['areapackage'] = 'Package file';
 $string['attempt'] = 'Attempt';
+$string['attempts'] = 'Attempts';
 $string['deleteallattempts'] = 'Delete all H5P attempts';
 $string['displayexport'] = 'Allow download';
 $string['displayembed'] = 'Embed button';
 $string['displaycopyright'] = 'Copyright button';
+$string['enabletracking'] = 'Enable attempt tracking';
+$string['grade_grademethod'] = 'Grading method';
+$string['grade_grademethod_help'] = 'When using point grading, the following methods are available for calculating the final grade:
+
+* Highest grade of all attempts
+* Average (mean) grade of all attempts
+* First attempt (all other attempts are ignored)
+* Last attempt (all other attempts are ignored)
+* Don\'t use attempts for grading (disable grading calculation)';
+$string['grade_manual'] = 'Don\'t calculate a grade';
+$string['grade_highest_attempt'] = 'Highest grade';
+$string['grade_average_attempt'] = 'Average grade';
+$string['grade_last_attempt'] = 'Last attempt';
+$string['grade_first_attempt'] = 'First attempt';
 $string['h5pactivity:addinstance'] = 'Add a new H5P';
 $string['h5pactivity:submit'] = 'Submit H5P attempts';
 $string['h5pactivity:view'] = 'View H5P';
 $string['h5pactivityfieldset'] = 'H5P settings';
 $string['h5pactivityname'] = 'H5P';
 $string['h5pactivitysettings'] = 'Settings';
+$string['h5pattempts'] = 'Attempt options';
 $string['h5pdisplay'] = 'H5P options';
 $string['modulename'] = 'H5P';
 $string['modulename_help'] = 'H5P is an abbreviation for HTML5 Package - interactive content such as presentations, videos and other multimedia, questions, quizzes, games and more. The H5P activity enables H5P to be uploaded and added to a course.
@@ -59,4 +75,5 @@ $string['privacy:metadata:userid'] = 'The ID of the user who accessed the H5P ac
 $string['privacy:metadata:xapi_track'] = 'Attempt tracking information';
 $string['privacy:metadata:xapi_track_results'] = 'Attempt results tracking information';
 $string['statement_received'] = 'xAPI statement received';
+$string['tracking_messages'] = 'Some H5P provide attempt tracking data for advanced reporting such as number of attempts, responses and grades. Note: Some H5P don\'t provide attempt tracking data. In such cases, the following settings will have no effect.';
 $string['view'] = 'View';
index 5f2fff8..b3cb285 100644 (file)
@@ -24,6 +24,9 @@
 
 defined('MOODLE_INTERNAL') || die();
 
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\local\grader;
+
 /**
  * Checks if H5P activity supports a specific feature.
  *
@@ -113,10 +116,15 @@ function h5pactivity_update_instance(stdClass $data, mod_h5pactivity_mod_form $m
 
     h5pactivity_set_mainfile($data);
 
-    // Extra fields required in grade related functions.
+    // Update gradings if grading method or tracking are modified.
     $data->cmid = $data->coursemodule;
-    h5pactivity_grade_item_update($data);
-    h5pactivity_update_grades($data);
+    $moduleinstance = $DB->get_record('h5pactivity', ['id' => $data->id]);
+    if (($moduleinstance->grademethod != $data->grademethod)
+            || $data->enabletracking != $moduleinstance->enabletracking) {
+        h5pactivity_update_grades($data);
+    } else {
+        h5pactivity_grade_item_update($data);
+    }
 
     return $DB->update_record('h5pactivity', $data);
 }
@@ -169,33 +177,10 @@ function h5pactivity_scale_used_anywhere(int $scaleid): bool {
  * @param mixed $grades optional array/object of grade(s); 'reset' means reset grades in gradebook
  * @return int int 0 if ok, error code otherwise
  */
-function h5pactivity_grade_item_update(stdClass $moduleinstance, $grades=null): int {
-    global $CFG;
-    require_once($CFG->libdir.'/gradelib.php');
-
-    $item = [];
-    $item['itemname'] = clean_param($moduleinstance->name, PARAM_NOTAGS);
-    $item['gradetype'] = GRADE_TYPE_VALUE;
-    if (isset($moduleinstance->cmidnumber)) {
-        $item['idnumber'] = $moduleinstance->cmidnumber;
-    }
-
-    if ($moduleinstance->grade > 0) {
-        $item['gradetype'] = GRADE_TYPE_VALUE;
-        $item['grademax']  = $moduleinstance->grade;
-        $item['grademin']  = 0;
-    } else if ($moduleinstance->grade < 0) {
-        $item['gradetype'] = GRADE_TYPE_SCALE;
-        $item['scaleid']   = -$moduleinstance->grade;
-    } else {
-        $item['gradetype'] = GRADE_TYPE_NONE;
-    }
-    if ($grades === 'reset') {
-        $params['reset'] = true;
-        $grades = null;
-    }
-    return grade_update('mod/h5pactivity', $moduleinstance->course, 'mod',
-            'h5pactivity', $moduleinstance->id, 0, null, $item);
+function h5pactivity_grade_item_update(stdClass $moduleinstance, $grades = null): int {
+    $idnumber = $moduleinstance->idnumber ?? '';
+    $grader = new grader($moduleinstance, $idnumber);
+    return $grader->grade_item_update($grades);
 }
 
 /**
@@ -205,11 +190,9 @@ function h5pactivity_grade_item_update(stdClass $moduleinstance, $grades=null):
  * @return int Returns GRADE_UPDATE_OK, GRADE_UPDATE_FAILED, GRADE_UPDATE_MULTIPLE or GRADE_UPDATE_ITEM_LOCKED
  */
 function h5pactivity_grade_item_delete(stdClass $moduleinstance): ?int {
-    global $CFG;
-    require_once($CFG->libdir.'/gradelib.php');
-
-    return grade_update('mod/h5pactivity', $moduleinstance->course, 'mod', 'h5pactivity',
-            $moduleinstance->id, 0, null, ['deleted' => 1]);
+    $idnumber = $moduleinstance->idnumber ?? '';
+    $grader = new grader($moduleinstance, $idnumber);
+    return $grader->grade_item_delete();
 }
 
 /**
@@ -221,13 +204,29 @@ function h5pactivity_grade_item_delete(stdClass $moduleinstance): ?int {
  * @param int $userid Update grade of specific user only, 0 means all participants.
  */
 function h5pactivity_update_grades(stdClass $moduleinstance, int $userid = 0): void {
-    global $CFG;
-    require_once($CFG->libdir.'/gradelib.php');
+    $idnumber = $moduleinstance->idnumber ?? '';
+    $grader = new grader($moduleinstance, $idnumber);
+    $grader->update_grades($userid);
+}
 
-    // Populate array of grade objects indexed by userid.
-    $grades = [];
-    grade_update('mod/h5pactivity', $moduleinstance->course, 'mod',
-            'h5pactivity', $moduleinstance->id, 0, $grades);
+/**
+ * Rescale all grades for this activity and push the new grades to the gradebook.
+ *
+ * @param stdClass $course Course db record
+ * @param stdClass $cm Course module db record
+ * @param float $oldmin
+ * @param float $oldmax
+ * @param float $newmin
+ * @param float $newmax
+ * @return bool true if reescale is successful
+ */
+function h5pactivity_rescale_activity_grades(stdClass $course, stdClass $cm, float $oldmin,
+        float $oldmax, float $newmin, float $newmax): bool {
+
+    $manager = manager::create_from_coursemodule($cm);
+    $grader = $manager->get_grader();
+    $grader->update_grades();
+    return true;
 }
 
 /**
@@ -306,7 +305,7 @@ function h5pactivity_reset_gradebook(int $courseid, string $type=''): void {
 
     if ($activities = $DB->get_records_sql($sql, [$courseid])) {
         foreach ($activities as $activity) {
-            h5pactivity_grade_item_update($activity, true);
+            h5pactivity_grade_item_update($activity, 'reset');
         }
     }
 }
index 3965174..d3c20d0 100644 (file)
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+
 defined('MOODLE_INTERNAL') || die();
 
+global $CFG;
 require_once($CFG->dirroot.'/course/moodleform_mod.php');
 
 /**
@@ -85,6 +88,22 @@ class mod_h5pactivity_mod_form extends moodleform_mod {
         // Add standard grading elements.
         $this->standard_grading_coursemodule_elements();
 
+        // Attempt options.
+        $mform->addElement('header', 'h5pattempts', get_string('h5pattempts', 'mod_h5pactivity'));
+
+        $mform->addElement('static', 'trackingwarning', '', get_string('tracking_messages', 'mod_h5pactivity'));
+
+        $options = [1 => get_string('yes'), 0 => get_string('no')];
+        $mform->addElement('select', 'enabletracking', get_string('enabletracking', 'mod_h5pactivity'), $options);
+        $mform->setDefault('enabletracking', 1);
+
+        $options = manager::get_grading_methods();
+        $mform->addElement('select', 'grademethod', get_string('grade_grademethod', 'mod_h5pactivity'), $options);
+        $mform->setType('grademethod', PARAM_INT);
+        $mform->hideIf('grademethod', 'enabletracking', 'neq', 1);
+        $mform->disabledIf('grademethod', 'grade[modgrade_type]', 'neq', 'point');
+        $mform->addHelpButton('grademethod', 'grade_grademethod', 'mod_h5pactivity');
+
         // Add standard elements.
         $this->standard_coursemodule_elements();
 
@@ -178,5 +197,9 @@ class mod_h5pactivity_mod_form extends moodleform_mod {
             $config = \core_h5p\helper::decode_display_options($core);
         }
         $data->displayoptions = \core_h5p\helper::get_display_options($core, $config);
+
+        if (!isset($data->enabletracking)) {
+            $data->enabletracking = 0;
+        }
     }
 }
diff --git a/mod/h5pactivity/tests/behat/define_settings.feature b/mod/h5pactivity/tests/behat/define_settings.feature
new file mode 100644 (file)
index 0000000..4346224
--- /dev/null
@@ -0,0 +1,50 @@
+@mod @mod_h5pactivity @core_h5p @_file_upload @_switch_iframe @javascript
+Feature: Set up attempt grading options into H5P activity
+  In order to use automatic grading in H5P activity
+  As a teacher
+  I need to be able to configure the attempt settings
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+    And the following "permission overrides" exist:
+      | capability                 | permission | role           | contextlevel | reference |
+      | moodle/h5p:updatelibraries | Allow      | editingteacher | System       |           |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "H5P" to section "1"
+
+  Scenario: Default values should have tracking and grading
+    When the field "Type" matches value "Point"
+    Then the "Grading method" "select" should be enabled
+
+  Scenario: Scale grading should not have a grading method.
+    When I set the following fields to these values:
+          | Name        | Awesome H5P package |
+          | Type        | Scale               |
+    Then the "Grading method" "select" should be disabled
+
+  Scenario: None grading should not have a grading method.
+    When I set the following fields to these values:
+          | Name        | Awesome H5P package |
+          | Type        | None                |
+    Then the "Grading method" "select" should be disabled
+
+  Scenario: Point grading should have a grading method.
+    When I set the following fields to these values:
+          | Name        | Awesome H5P package |
+          | Type        | Point               |
+    Then the "Grading method" "select" should be enabled
+
+  Scenario: Disable tracking should make grading method disappear.
+    When I set the following fields to these values:
+          | Name                    | Awesome H5P package |
+          | Enable attempt tracking | No                   |
+    Then "Grading method" "field" should not be visible
diff --git a/mod/h5pactivity/tests/behat/grading_attempts.feature b/mod/h5pactivity/tests/behat/grading_attempts.feature
new file mode 100644 (file)
index 0000000..a381891
--- /dev/null
@@ -0,0 +1,174 @@
+@mod @mod_h5pactivity @core_h5p @_file_upload @_switch_iframe
+Feature: Change grading options in an H5P activity
+  In order to let students do a H5P attempt
+  As a teacher
+  I need to define what students attempts are used for grading
+
+  Background:
+    Given the following "users" exist:
+      | username | firstname | lastname | email                |
+      | teacher1 | Teacher   | 1        | teacher1@example.com |
+      | student1 | Student   | 1        | student1@example.com |
+    And the following "courses" exist:
+      | fullname | shortname | category |
+      | Course 1 | C1        | 0        |
+    And the following "course enrolments" exist:
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
+    And the following "permission overrides" exist:
+      | capability                 | permission | role           | contextlevel | reference |
+      | moodle/h5p:updatelibraries | Allow      | editingteacher | System       |           |
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage with editing mode on
+    And I add a "H5P" to section "1"
+    And I set the following fields to these values:
+      | Name        | Awesome H5P package |
+      | Description | Description         |
+    And I upload "h5p/tests/fixtures/multiple-choice-2-6.h5p" file to "Package file" filemanager
+    And I click on "Save and display" "button"
+    And I log out
+    And I log in as "student1"
+    And I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I click on "Wrong one" "text" in the ".h5p-question-content" "css_element"
+    And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
+    And I click on "Retry" "button" in the ".h5p-question-buttons" "css_element"
+    And I click on "Correct one" "text" in the ".h5p-question-content" "css_element"
+    And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
+    And I switch to the main frame
+    # H5P does not allow to Retry if the user checks the correct answer, we need to refresh the page.
+    And I reload the page
+    And I switch to "h5p-player" class iframe
+    And I switch to "h5p-iframe" class iframe
+    And I click on "Wrong one" "text" in the ".h5p-question-content" "css_element"
+    And I click on "Check" "button" in the ".h5p-question-buttons" "css_element"
+    And I switch to the main frame
+    And I log out
+    And I log in as "teacher1"
+    And I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+
+  @javascript
+  Scenario: Default grading is max attempt grade
+    When I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade  | Percentage |
+      | Awesome H5P package | 100.00 | 100.00 %   |
+
+  @javascript
+  Scenario: Change setting to first attempt
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | First attempt |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage |
+      | Awesome H5P package | 0.00  | 0.00 %     |
+
+  @javascript
+  Scenario: Change setting to first attempt
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Last attempt |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage |
+      | Awesome H5P package | 0.00  | 0.00 %     |
+
+  @javascript
+  Scenario: Change setting to average attempt
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Average grade |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage  |
+      | Awesome H5P package | 33.33 | 33.33 %     |
+
+  @javascript
+  Scenario: Change setting to manual grading
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Don't calculate a grade |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage |
+      | Awesome H5P package | -     | -          |
+
+  @javascript
+  Scenario: Disable tracking
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Enable attempt tracking | No |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Percentage |
+      | Awesome H5P package | -     | -          |
+
+  @javascript
+  Scenario: Reescale existing grades changing the maximum grade
+    # First we set to average and recalculate grades.
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Average grade |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Range | Percentage  |
+      | Awesome H5P package | 33.33 | 0–100 | 33.33 %     |
+
+    # Now we modify the maximum grade with rescaling.
+    When I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Rescale existing grades | Yes |
+      | Maximum grade           | 50  |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Range | Percentage  |
+      | Awesome H5P package | 16.67 | 0–50  | 33.33 %     |
+
+  @javascript
+  Scenario: Change maximum grade without rescaling grade
+    # First we set to average and recalculate grades.
+    When I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Grading method | Average grade |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Range | Percentage  |
+      | Awesome H5P package | 33.33 | 0–100 | 33.33 %     |
+
+    # Now we modify the maximum grade with rescaling.
+    When I am on "Course 1" course homepage
+    And I follow "Awesome H5P package"
+    And I navigate to "Edit settings" in current page administration
+    And I set the following fields to these values:
+      | Rescale existing grades | No |
+      | Maximum grade           | 50 |
+    And I click on "Save and return to course" "button"
+    And I navigate to "View > User report" in the course gradebook
+    And I set the field "Select all or one user" to "Student 1"
+    Then the following should exist in the "user-grade" table:
+      | Grade item          | Grade | Range | Percentage  |
+      | Awesome H5P package | 33.33 | 0–50  | 66.67 %     |
index c381eea..15142c9 100644 (file)
@@ -28,8 +28,6 @@ use advanced_testcase;
 use context_course;
 use context_module;
 
-defined('MOODLE_INTERNAL') || die();
-
 /**
  * H5P activity events test cases.
  *
index 57d2fe6..4813fce 100644 (file)
@@ -28,8 +28,6 @@ use advanced_testcase;
 use context_course;
 use context_module;
 
-defined('MOODLE_INTERNAL') || die();
-
 /**
  * H5P activity events test cases.
  *
index 1769460..021043d 100644 (file)
@@ -28,8 +28,6 @@ use advanced_testcase;
 use context_course;
 use context_module;
 
-defined('MOODLE_INTERNAL') || die();
-
 /**
  * H5P activity events test cases.
  *
index cbcf84d..b12ffad 100644 (file)
@@ -22,6 +22,8 @@
  * @license http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+
 defined('MOODLE_INTERNAL') || die();
 
 
@@ -63,6 +65,12 @@ class mod_h5pactivity_generator extends testing_module_generator {
             $config = \core_h5p\helper::decode_display_options($core);
             $record->displayoptions = \core_h5p\helper::get_display_options($core, $config);
         }
+        if (!isset($record->enabletracking)) {
+            $record->enabletracking = 1;
+        }
+        if (!isset($record->grademethod)) {
+            $record->grademethod = manager::GRADEHIGHESTATTEMPT;
+        }
 
         // The 'packagefile' value corresponds to the draft file area ID. If not specified, create from packagefilepath.
         if (empty($record->packagefile)) {
index 1f2cad8..dbb7cfa 100644 (file)
@@ -23,7 +23,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
-defined('MOODLE_INTERNAL') || die();
+use mod_h5pactivity\local\manager;
 
 /**
  * Genarator tests class for mod_h5pactivity.
@@ -54,10 +54,15 @@ class mod_h5pactivity_generator_testcase extends advanced_testcase {
         $this->assertTrue(array_key_exists($activity->id, $records));
 
         // Create a second one with different name and dusplay options.
-        $params = ['course' => $course->id, 'name' => 'Another h5pactivity', 'displayoptions' => 6];
+        $params = [
+            'course' => $course->id, 'name' => 'Another h5pactivity', 'displayoptions' => 6,
+            'enabletracking' => 0, 'grademethod' => manager::GRADELASTATTEMPT,
+        ];
         $activity = $this->getDataGenerator()->create_module('h5pactivity', $params);
         $records = $DB->get_records('h5pactivity', ['course' => $course->id], 'id');
         $this->assertEquals(6, $activity->displayoptions);
+        $this->assertEquals(0, $activity->enabletracking);
+        $this->assertEquals(manager::GRADELASTATTEMPT, $activity->grademethod);
         $this->assertEquals(2, count($records));
         $this->assertEquals('Another h5pactivity', $records[$activity->id]->name);
 
index 1af4c92..6cec3c2 100644 (file)
@@ -15,7 +15,7 @@
 // along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
 
 /**
- * mod_h5pactivity generator tests
+ * mod_h5pactivity attempt tests
  *
  * @package    mod_h5pactivity
  * @category   test
@@ -34,8 +34,6 @@ use \core_xapi\local\statement\item_verb;
 use \core_xapi\local\statement\item_result;
 use stdClass;
 
-defined('MOODLE_INTERNAL') || die();
-
 /**
  * Attempt tests class for mod_h5pactivity.
  *
@@ -138,6 +136,7 @@ class attempt_testcase extends \advanced_testcase {
         $this->assertEquals(0, $attempt->get_duration());
         $this->assertNull($attempt->get_completion());
         $this->assertNull($attempt->get_success());
+        $this->assertFalse($attempt->get_scoreupdated());
 
         $statement = $this->generate_statement($hasdefinition, $hasresult);
         $result = $attempt->save_statement($statement, $subcontent);
@@ -148,6 +147,11 @@ class attempt_testcase extends \advanced_testcase {
         $this->assertEquals($results[4], $attempt->get_duration());
         $this->assertEquals($results[5], $attempt->get_completion());
         $this->assertEquals($results[6], $attempt->get_success());
+        if ($results[5]) {
+            $this->assertTrue($attempt->get_scoreupdated());
+        } else {
+            $this->assertFalse($attempt->get_scoreupdated());
+        }
     }
 
     /**
@@ -298,6 +302,110 @@ class attempt_testcase extends \advanced_testcase {
         ];
     }
 
+    /**
+     * Test set_score method.
+     *
+     */
+    public function test_set_score(): void {
+        global $DB;
+
+        list($cm, $student, $course) = $this->generate_testing_scenario();
+
+        // Generate one attempt.
+        $attempt = $this->generate_full_attempt($student, $cm);
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->rawscore, $attempt->get_rawscore());
+        $this->assertEquals(2, $dbattempt->rawscore);
+        $this->assertEquals($dbattempt->maxscore, $attempt->get_maxscore());
+        $this->assertEquals(2, $dbattempt->maxscore);
+        $this->assertEquals(1, $dbattempt->scaled);
+
+        // Set attempt score.
+        $attempt->set_score(5, 10);
+
+        $this->assertEquals(5, $attempt->get_rawscore());
+        $this->assertEquals(10, $attempt->get_maxscore());
+        $this->assertTrue($attempt->get_scoreupdated());
+
+        // Save new score into DB.
+        $attempt->save();
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->rawscore, $attempt->get_rawscore());
+        $this->assertEquals(5, $dbattempt->rawscore);
+        $this->assertEquals($dbattempt->maxscore, $attempt->get_maxscore());
+        $this->assertEquals(10, $dbattempt->maxscore);
+        $this->assertEquals(0.5, $dbattempt->scaled);
+    }
+
+    /**
+     * Test set_duration method.
+     *
+     * @dataProvider basic_setters_data
+     * @param string $attribute the stribute to test
+     * @param int $oldvalue attribute old value
+     * @param int $newvalue attribute new expected value
+     */
+    public function test_basic_setters(string $attribute, int $oldvalue, int $newvalue): void {
+        global $DB;
+
+        list($cm, $student, $course) = $this->generate_testing_scenario();
+
+        // Generate one attempt.
+        $attempt = $this->generate_full_attempt($student, $cm);
+
+        $setmethod = 'set_'.$attribute;
+        $getmethod = 'get_'.$attribute;
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->$attribute, $attempt->$getmethod());
+        $this->assertEquals($oldvalue, $dbattempt->$attribute);
+
+        // Set attempt attribute.
+        $attempt->$setmethod($newvalue);
+
+        $this->assertEquals($newvalue, $attempt->$getmethod());
+
+        // Save new score into DB.
+        $attempt->save();
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->$attribute, $attempt->$getmethod());
+        $this->assertEquals($newvalue, $dbattempt->$attribute);
+
+        // Set null $attribute.
+        $attempt->$setmethod(null);
+
+        $this->assertNull($attempt->$getmethod());
+
+        // Save new score into DB.
+        $attempt->save();
+
+        $dbattempt = $DB->get_record('h5pactivity_attempts', ['id' => $attempt->get_id()]);
+        $this->assertEquals($dbattempt->$attribute, $attempt->$getmethod());
+        $this->assertNull($dbattempt->$attribute);
+    }
+
+    /**
+     * Data provider for testing basic setters.
+     *
+     * @return array
+     */
+    public function basic_setters_data(): array {
+        return [
+            'Set attempt duration' => [
+                'duration', 25, 35
+            ],
+            'Set attempt completion' => [
+                'completion', 1, 0
+            ],
+            'Set attempt success' => [
+                'success', 1, 0
+            ],
+        ];
+    }
+
     /**
      * Generate a fake attempt with two results.
      *
diff --git a/mod/h5pactivity/tests/local/grader_test.php b/mod/h5pactivity/tests/local/grader_test.php
new file mode 100644 (file)
index 0000000..ffa9763
--- /dev/null
@@ -0,0 +1,360 @@
+<?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/>.
+
+/**
+ * mod_h5pactivity grader tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+
+use grade_item;
+use stdClass;
+
+/**
+ * Grader tests class for mod_h5pactivity.
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class grader_testcase extends \advanced_testcase {
+
+    /**
+     * Setup to ensure that fixtures are loaded.
+     */
+    public static function setupBeforeClass(): void {
+        global $CFG;
+        require_once($CFG->libdir.'/gradelib.php');
+    }
+
+    /**
+     * Test for grade item delete.
+     */
+    public function test_grade_item_delete() {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        $grader = new grader($activity);
+
+        // Force a user grade.
+        $this->generate_fake_attempt($activity, $user, 5, 10);
+        $grader->update_grades($user->id);
+
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, $user->id);
+        $this->assertNotEquals(0, count($gradeinfo->items));
+        $this->assertArrayHasKey($user->id, $gradeinfo->items[0]->grades);
+
+        $grader->grade_item_delete();
+
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, $user->id);
+        $this->assertEquals(0, count($gradeinfo->items));
+    }
+
+    /**
+     * Test for grade item update.
+     *
+     * @dataProvider grade_item_update_data
+     * @param int $newgrade new activity grade
+     * @param bool $reset if has to reset grades
+     * @param string $idnumber the new idnumber
+     */
+    public function test_grade_item_update(int $newgrade, bool $reset, string $idnumber) {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $user = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Force a user initial grade.
+        $grader = new grader($activity);
+        $this->generate_fake_attempt($activity, $user, 5, 10);
+        $grader->update_grades($user->id);
+
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, $user->id);
+        $this->assertNotEquals(0, count($gradeinfo->items));
+        $item = array_shift($gradeinfo->items);
+        $this->assertArrayHasKey($user->id, $item->grades);
+        $this->assertEquals(50, round($item->grades[$user->id]->grade));
+
+        // Module grade value determine the way gradebook acts. That means that the expected
+        // result depends on this value.
+        // - Grade > 0: regular max grade value.
+        // - Grade = 0: no grading is used (but grademax remains the same).
+        // - Grade < 0: a scaleid is used (value = -scaleid).
+        if ($newgrade > 0) {
+            $grademax = $newgrade;
+            $scaleid = null;
+            $usergrade = ($newgrade > 50) ? 50 : $newgrade;
+        } else if ($newgrade == 0) {
+            $grademax = 100;
+            $scaleid = null;
+            $usergrade = null; // No user grades expected.
+        } else if ($newgrade < 0) {
+            $scale = $this->getDataGenerator()->create_scale(array("scale" => "value1, value2, value3"));
+            $newgrade = -1 * $scale->id;
+            $grademax = 3;
+            $scaleid = $scale->id;
+            $usergrade = 3; // 50 value will ve converted to "value 3" on scale.
+        }
+
+        // Update grade item.
+        $activity->grade = $newgrade;
+
+        // In case a reset is need, usergrade will be empty.
+        if ($reset) {
+            $param = 'reset';
+            $usergrade = null;
+        } else {
+            // Individual user gradings will be tested as a subcall of update_grades.
+            $param = null;
+        }
+
+        $grader = new grader($activity, $idnumber);
+        $grader->grade_item_update($param);
+
+        // Check new grade item and grades.
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, $user->id);
+        $item = array_shift($gradeinfo->items);
+        $this->assertEquals($scaleid, $item->scaleid);
+        $this->assertEquals($grademax, $item->grademax);
+        $this->assertArrayHasKey($user->id, $item->grades);
+        if ($usergrade) {
+            $this->assertEquals($usergrade, round($item->grades[$user->id]->grade));
+        } else {
+            $this->assertEmpty($item->grades[$user->id]->grade);
+        }
+        if (!empty($idnumber)) {
+            $gradeitem = grade_item::fetch(['idnumber' => $idnumber, 'courseid' => $course->id]);
+            $this->assertInstanceOf('grade_item', $gradeitem);
+        }
+    }
+
+    /**
+     * Data provider for test_grade_item_update.
+     *
+     * @return array
+     */
+    public function grade_item_update_data(): array {
+        return [
+            'Change idnumber' => [
+                100, false, 'newidnumber'
+            ],
+            'Increase max grade to 110' => [
+                110, false, ''
+            ],
+            'Decrease max grade to 80' => [
+                40, false, ''
+            ],
+            'Decrease max grade to 40 (less than actual grades)' => [
+                40, false, ''
+            ],
+            'Reset grades' => [
+                100, true, ''
+            ],
+            'Disable grades' => [
+                0, false, ''
+            ],
+            'Use scales' => [
+                -1, false, ''
+            ],
+            'Use scales with reset' => [
+                -1, true, ''
+            ],
+        ];
+    }
+
+    /**
+     * Test for grade update.
+     *
+     * @dataProvider update_grades_data
+     * @param int $newgrade the new activity grade
+     * @param bool $all if has to be applied to all students or just to one
+     * @param int $completion 1 all student have the activity completed, 0 one have incompleted
+     * @param array $results expected results (user1 grade, user2 grade)
+     */
+    public function test_update_grades(int $newgrade, bool $all, int $completion, array $results) {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $user1 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $user2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+
+        // Force a user initial grade.
+        $grader = new grader($activity);
+        $this->generate_fake_attempt($activity, $user1, 5, 10);
+        $this->generate_fake_attempt($activity, $user2, 3, 12, $completion);
+        $grader->update_grades();
+
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, [$user1->id, $user2->id]);
+        $this->assertNotEquals(0, count($gradeinfo->items));
+        $item = array_shift($gradeinfo->items);
+        $this->assertArrayHasKey($user1->id, $item->grades);
+        $this->assertArrayHasKey($user2->id, $item->grades);
+        $this->assertEquals(50, $item->grades[$user1->id]->grade);
+        // Uncompleted attempts does not generate grades.
+        if ($completion) {
+            $this->assertEquals(25, $item->grades[$user2->id]->grade);
+        } else {
+            $this->assertNull($item->grades[$user2->id]->grade);
+
+        }
+
+        // Module grade value determine the way gradebook acts. That means that the expected
+        // result depends on this value.
+        // - Grade > 0: regular max grade value.
+        // - Grade <= 0: no grade calculation is used (scale and no grading).
+        if ($newgrade < 0) {
+            $scale = $this->getDataGenerator()->create_scale(array("scale" => "value1, value2, value3"));
+            $activity->grade = -1 * $scale->id;
+        } else {
+            $activity->grade = $newgrade;
+        }
+
+        $userid = ($all) ? 0 : $user1->id;
+
+        $grader = new grader($activity);
+        $grader->update_grades($userid);
+
+        // Check new grade item and grades.
+        $gradeinfo = grade_get_grades($course->id, 'mod', 'h5pactivity', $activity->id, [$user1->id, $user2->id]);
+        $item = array_shift($gradeinfo->items);
+        $this->assertArrayHasKey($user1->id, $item->grades);
+        $this->assertArrayHasKey($user2->id, $item->grades);
+        $this->assertEquals($results[0], $item->grades[$user1->id]->grade);
+        $this->assertEquals($results[1], $item->grades[$user2->id]->grade);
+    }
+
+    /**
+     * Data provider for test_grade_item_update.
+     *
+     * @return array
+     */
+    public function update_grades_data(): array {
+        return [
+            // Quantitative grade, all attempts completed.
+            'Same grademax, all users, all completed' => [
+                100, true, 1, [50, 25]
+            ],
+            'Same grademax, one user, all completed' => [
+                100, false, 1, [50, 25]
+            ],
+            'Increade max, all users, all completed' => [
+                200, true, 1, [100, 50]
+            ],
+            'Increade max, one user, all completed' => [
+                200, false, 1, [100, 25]
+            ],
+            'Decrease max, all users, all completed' => [
+                50, true, 1, [25, 12.5]
+            ],
+            'Decrease max, one user, all completed' => [
+                50, false, 1, [25, 25]
+            ],
+            // Quantitative grade, some attempts not completed.
+            'Same grademax, all users, not completed' => [
+                100, true, 0, [50, null]
+            ],
+            'Same grademax, one user, not completed' => [
+                100, false, 0, [50, null]
+            ],
+            'Increade max, all users, not completed' => [
+                200, true, 0, [100, null]
+            ],
+            'Increade max, one user, not completed' => [
+                200, false, 0, [100, null]
+            ],
+            'Decrease max, all users, not completed' => [
+                50, true, 0, [25, null]
+            ],
+            'Decrease max, one user, not completed' => [
+                50, false, 0, [25, null]
+            ],
+            // No grade (no grade will be used).
+            'No grade, all users, all completed' => [
+                0, true, 1, [null, null]
+            ],
+            'No grade, one user, all completed' => [
+                0, false, 1, [null, null]
+            ],
+            'No grade, all users, not completed' => [
+                0, true, 0, [null, null]
+            ],
+            'No grade, one user, not completed' => [
+                0, false, 0, [null, null]
+            ],
+            // Scale (grate item will updated but without regrading).
+            'Scale, all users, all completed' => [
+                -1, true, 1, [3, 3]
+            ],
+            'Scale, one user, all completed' => [
+                -1, false, 1, [3, 3]
+            ],
+            'Scale, all users, not completed' => [
+                -1, true, 0, [3, null]
+            ],
+            'Scale, one user, not completed' => [
+                -1, false, 0, [3, null]
+            ],
+        ];
+    }
+
+    /**
+     * Create a fake attempt for a specific user.
+     *
+     * @param stdClass $activity activity instance record.
+     * @param stdClass $user user record
+     * @param int $rawscore score obtained
+     * @param int $maxscore attempt max score
+     * @param int $completion 1 for activity completed, 0 for not completed yet
+     * @return stdClass the attempt record
+     */
+    private function generate_fake_attempt(stdClass $activity, stdClass $user,
+            int $rawscore, int $maxscore, int $completion = 1): stdClass {
+        global $DB;
+
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => 10,
+            'timemodified' => 20,
+            'attempt' => 1,
+            'rawscore' => $rawscore,
+            'maxscore' => $maxscore,
+            'duration' => 2,
+            'completion' => $completion,
+            'success' => 0,
+        ];
+        $attempt->scaled = $attempt->rawscore / $attempt->maxscore;
+        $attempt->id = $DB->insert_record('h5pactivity_attempts', $attempt);
+        return $attempt;
+    }
+}
diff --git a/mod/h5pactivity/tests/local/manager_test.php b/mod/h5pactivity/tests/local/manager_test.php
new file mode 100644 (file)
index 0000000..e1b63f8
--- /dev/null
@@ -0,0 +1,356 @@
+<?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/>.
+
+/**
+ * mod_h5pactivity manager tests
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace mod_h5pactivity\local;
+use context_module;
+use stdClass;
+
+/**
+ * Manager tests class for mod_h5pactivity.
+ *
+ * @package    mod_h5pactivity
+ * @category   test
+ * @copyright  2020 Ferran Recio <ferran@moodle.com>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class manager_testcase extends \advanced_testcase {
+
+    /**
+     * Test for static create methods.
+     */
+    public function test_create() {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+        $context = context_module::instance($cm->id);
+
+        $manager = manager::create_from_instance($activity);
+        $manageractivity = $manager->get_instance();
+        $this->assertEquals($activity->id, $manageractivity->id);
+        $managercm = $manager->get_coursemodule();
+        $this->assertEquals($cm->id, $managercm->id);
+        $managercontext = $manager->get_context();
+        $this->assertEquals($context->id, $managercontext->id);
+
+        $manager = manager::create_from_coursemodule($cm);
+        $manageractivity = $manager->get_instance();
+        $this->assertEquals($activity->id, $manageractivity->id);
+        $managercm = $manager->get_coursemodule();
+        $this->assertEquals($cm->id, $managercm->id);
+        $managercontext = $manager->get_context();
+        $this->assertEquals($context->id, $managercontext->id);
+    }
+
+    /**
+     * Test for is_tracking_enabled.
+     *
+     * @dataProvider is_tracking_enabled_data
+     * @param bool $login if the user is logged in
+     * @param string $role user role in course
+     * @param int $enabletracking if tracking is enabled
+     * @param bool $expected expected result
+     */
+    public function test_is_tracking_enabled(bool $login, string $role, int $enabletracking, bool $expected) {
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity',
+                ['course' => $course, 'enabletracking' => $enabletracking]);
+
+        $user = $this->getDataGenerator()->create_and_enrol($course, $role);
+        if ($login) {
+            $this->setUser($user);
+            $param = null;
+        } else {
+            $param = $user;
+        }
+
+        $manager = manager::create_from_instance($activity);
+        $this->assertEquals($expected, $manager->is_tracking_enabled($param));
+    }
+
+    /**
+     * Data provider for is_tracking_enabled.
+     *
+     * @return array
+     */
+    public function is_tracking_enabled_data(): array {
+        return [
+            'Logged student, tracking enabled' => [
+                true, 'student', 1, true
+            ],
+            'Logged student, tracking disabled' => [
+                true, 'student', 0, false
+            ],
+            'Logged teacher, tracking enabled' => [
+                true, 'editingteacher', 1, false
+            ],
+            'Logged teacher, tracking disabled' => [
+                true, 'editingteacher', 0, false
+            ],
+            'No logged student, tracking enabled' => [
+                true, 'student', 1, true
+            ],
+            'No logged student, tracking disabled' => [
+                true, 'student', 0, false
+            ],
+            'No logged teacher, tracking enabled' => [
+                true, 'editingteacher', 1, false
+            ],
+            'No logged teacher, tracking disabled' => [
+                true, 'editingteacher', 0, false
+            ],
+        ];
+    }
+
+    /**
+     * Test for get_users_scaled_score.
+     *
+     * @dataProvider get_users_scaled_score_data
+     * @param int $enabletracking if tracking is enabled
+     * @param int $gradingmethod new grading method
+     * @param array $result1 student 1 results (scaled, timemodified, attempt number)
+     * @param array $result2 student 2 results (scaled, timemodified, attempt number)
+     */
+    public function test_get_users_scaled_score(int $enabletracking, int $gradingmethod, array $result1, array $result2) {
+        global $DB;
+
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity',
+                ['course' => $course, 'enabletracking' => $enabletracking, 'grademethod' => $gradingmethod]);
+
+        // Generate two users with 4 attempts each.
+        $user1 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $this->generate_fake_attempts($activity, $user1, 1);
+        $user2 = $this->getDataGenerator()->create_and_enrol($course, 'student');
+        $this->generate_fake_attempts($activity, $user2, 2);
+
+        $manager = manager::create_from_instance($activity);
+
+        // Get all users scaled scores.
+        $scaleds = $manager->get_users_scaled_score();
+
+        // No results will be returned if tracking is dsabled or manual grading method is defined.
+        if (empty($result1)) {
+            $this->assertNull($scaleds);
+            return;
+        }
+
+        $this->assertCount(2, $scaleds);
+
+        // Check expected user1 scaled score.
+        $scaled = $scaleds[$user1->id];
+        $this->assertEquals($user1->id, $scaled->userid);
+        $this->assertEquals($result1[0], $scaled->scaled);
+        $this->assertEquals($result1[1], $scaled->timemodified);
+        if ($result1[2]) {
+            $attempt = $DB->get_record('h5pactivity_attempts', ['id' => $scaled->attemptid]);
+            $this->assertEquals($attempt->h5pactivityid, $activity->id);
+            $this->assertEquals($attempt->userid, $scaled->userid);
+            $this->assertEquals($attempt->scaled, round($scaled->scaled, 5));
+            $this->assertEquals($attempt->timemodified, $scaled->timemodified);
+            $this->assertEquals($result1[2], $attempt->attempt);
+        } else {
+            $this->assertEquals(0, $scaled->attemptid);
+        }
+
+        // Check expected user2 scaled score.
+        $scaled = $scaleds[$user2->id];
+        $this->assertEquals($user2->id, $scaled->userid);
+        $this->assertEquals($result2[0], round($scaled->scaled, 5));
+        $this->assertEquals($result2[1], $scaled->timemodified);
+        if ($result2[2]) {
+            $attempt = $DB->get_record('h5pactivity_attempts', ['id' => $scaled->attemptid]);
+            $this->assertEquals($attempt->h5pactivityid, $activity->id);
+            $this->assertEquals($attempt->userid, $scaled->userid);
+            $this->assertEquals($attempt->scaled, $scaled->scaled);
+            $this->assertEquals($attempt->timemodified, $scaled->timemodified);
+            $this->assertEquals($result2[2], $attempt->attempt);
+        } else {
+            $this->assertEquals(0, $scaled->attemptid);
+        }
+
+        // Now check a single user record.
+        $scaleds = $manager->get_users_scaled_score($user2->id);
+        $this->assertCount(1, $scaleds);
+        $scaled2 = $scaleds[$user2->id];
+        $this->assertEquals($scaled->userid, $scaled2->userid);
+        $this->assertEquals($scaled->scaled, $scaled2->scaled);
+        $this->assertEquals($scaled->attemptid, $scaled2->attemptid);
+        $this->assertEquals($scaled->timemodified, $scaled2->timemodified);
+    }
+
+    /**
+     * Data provider for get_users_scaled_score.
+     *
+     * @return array
+     */
+    public function get_users_scaled_score_data(): array {
+        return [
+            'Tracking with max attempt method' => [
+                1, manager::GRADEHIGHESTATTEMPT, [1.00000, 31, 2], [0.66667, 32, 2]
+            ],
+            'Tracking with average attempt method' => [
+                1, manager::GRADEAVERAGEATTEMPT, [0.61111, 51, 0], [0.52222, 52, 0]
+            ],
+            'Tracking with last attempt method' => [
+                1, manager::GRADELASTATTEMPT, [0.33333, 51, 3], [0.40000, 52, 3]
+            ],
+            'Tracking with first attempt method' => [
+                1, manager::GRADEFIRSTATTEMPT, [0.50000, 11, 1], [0.50000, 12, 1]
+            ],
+            'Tracking with manual attempt grading' => [
+                1, manager::GRADEMANUAL, [], []
+            ],
+            'No tracking with max attempt method' => [
+                0, manager::GRADEHIGHESTATTEMPT, [], []
+            ],
+            'No tracking with average attempt method' => [
+                0, manager::GRADEAVERAGEATTEMPT, [], []
+            ],
+            'No tracking with last attempt method' => [
+                0, manager::GRADELASTATTEMPT, [], []
+            ],
+            'No tracking with first attempt method' => [
+                0, manager::GRADEFIRSTATTEMPT, [], []
+            ],
+            'No tracking with manual attempt grading' => [
+                0, manager::GRADEMANUAL, [], []
+            ],
+        ];
+    }
+
+    /**
+     * Test static get_grading_methods.
+     */
+    public function test_get_grading_methods() {
+        $methods = manager::get_grading_methods();
+        $this->assertCount(5, $methods);
+        $this->assertNotEmpty($methods[manager::GRADEHIGHESTATTEMPT]);
+        $this->assertNotEmpty($methods[manager::GRADEAVERAGEATTEMPT]);
+        $this->assertNotEmpty($methods[manager::GRADELASTATTEMPT]);
+        $this->assertNotEmpty($methods[manager::GRADEFIRSTATTEMPT]);
+        $this->assertNotEmpty($methods[manager::GRADEMANUAL]);
+    }
+
+    /**
+     * Test get_grader method.
+     */
+    public function test_get_grader() {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        $course = $this->getDataGenerator()->create_course();
+        $activity = $this->getDataGenerator()->create_module('h5pactivity', ['course' => $course]);
+        $cm = get_coursemodule_from_id('h5pactivity', $activity->cmid, 0, false, MUST_EXIST);
+        $context = context_module::instance($cm->id);
+
+        $manager = manager::create_from_instance($activity);
+        $grader = $manager->get_grader();
+
+        $this->assertInstanceOf('mod_h5pactivity\local\grader', $grader);
+    }
+
+    /**
+     * Insert fake attempt data into h5pactiviyt_attempts.
+     *
+     * This function insert 4 attempts. 3 of them finished with different gradings
+     * and timestamps and 1 unfinished.
+     *
+     * @param stdClass $activity the activity record
+     * @param stdClass $user user record
+     * @param int $basescore a score to be used to generate all attempts
+     */
+    private function generate_fake_attempts(stdClass $activity, stdClass $user, int $basescore) {
+        global $DB;
+
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => $basescore,
+            'timemodified' => ($basescore + 10),
+            'attempt' => 1,
+            'rawscore' => $basescore,
+            'maxscore' => ($basescore + $basescore),
+            'duration' => $basescore,
+            'completion' => 1,
+            'success' => 1,
+        ];
+        $attempt->scaled = $attempt->rawscore / $attempt->maxscore;
+        $DB->insert_record('h5pactivity_attempts', $attempt);
+
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => ($basescore + 20),
+            'timemodified' => ($basescore + 30),
+            'attempt' => 2,
+            'rawscore' => $basescore,
+            'maxscore' => ($basescore + $basescore - 1),
+            'duration' => $basescore,
+            'completion' => 1,
+            'success' => 1,
+        ];
+        $attempt->scaled = $attempt->rawscore / $attempt->maxscore;
+        $DB->insert_record('h5pactivity_attempts', $attempt);
+
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => ($basescore + 40),
+            'timemodified' => ($basescore + 50),
+            'attempt' => 3,
+            'rawscore' => $basescore,
+            'maxscore' => ($basescore + $basescore + 1),
+            'duration' => $basescore,
+            'completion' => 1,
+            'success' => 0,
+        ];
+        $attempt->scaled = $attempt->rawscore / $attempt->maxscore;
+        $DB->insert_record('h5pactivity_attempts', $attempt);
+
+        // Unfinished attempt.
+        $attempt = (object)[
+            'h5pactivityid' => $activity->id,
+            'userid' => $user->id,
+            'timecreated' => ($basescore + 60),
+            'timemodified' => ($basescore + 60),
+            'attempt' => 4,
+            'rawscore' => $basescore,
+            'maxscore' => $basescore,
+            'duration' => $basescore,
+        ];
+        $DB->insert_record('h5pactivity_attempts', $attempt);
+    }
+}
index bc3b77a..9966abb 100644 (file)
@@ -25,8 +25,6 @@
 
 namespace mod_h5pactivity\privacy;
 
-defined('MOODLE_INTERNAL') || die();
-
 use \core_privacy\local\request\approved_contextlist;
 use \core_privacy\local\request\approved_userlist;
 use \core_privacy\local\request\writer;
index ca3f0da..53dc802 100644 (file)
@@ -111,6 +111,8 @@ class mod_h5pactivity_restore_testcase extends advanced_testcase {
         $this->assertEquals($activity->introformat, $activity2->introformat);
         $this->assertEquals($activity->grade, $activity2->grade);
         $this->assertEquals($activity->displayoptions, $activity2->displayoptions);
+        $this->assertEquals($activity->enabletracking, $activity2->enabletracking);
+        $this->assertEquals($activity->grademethod, $activity2->grademethod);
 
         // Compare attempts.
         if ($content && $userdata) {
index 68d42bb..43c4eba 100644 (file)
@@ -35,8 +35,6 @@ use \core_xapi\local\statement\item_result;
 use context_module;
 use stdClass;
 
-defined('MOODLE_INTERNAL') || die();
-
 /**
  * Attempt tests class for mod_h5pactivity.
  *
index b200f90..2d8caf8 100644 (file)
@@ -25,5 +25,5 @@
 defined('MOODLE_INTERNAL') || die();
 
 $plugin->component = 'mod_h5pactivity';
-$plugin->version = 2020041400;
+$plugin->version = 2020041401;
 $plugin->requires = 2020013000;
index be2c11d..7a47edb 100644 (file)
  * @license     http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+use mod_h5pactivity\local\manager;
+use mod_h5pactivity\event\course_module_viewed;
+use core_h5p\factory;
+use core_h5p\player;
+use core_h5p\helper;
+
 require(__DIR__.'/../../config.php');
 require_once(__DIR__.'/lib.php');
 require_once($CFG->libdir.'/completionlib.php');
@@ -32,11 +38,13 @@ list ($course, $cm) = get_course_and_cm_from_cmid($id, 'h5pactivity');
 
 require_login($course, true, $cm);
 
-$moduleinstance = $DB->get_record('h5pactivity', ['id' => $cm->instance], '*', MUST_EXIST);
+$manager = manager::create_from_coursemodule($cm);
+
+$moduleinstance = $manager->get_instance();
 
-$context = context_module::instance($cm->id);
+$context = $manager->get_context();
 
-$event = \mod_h5pactivity\event\course_module_viewed::create([
+$event = course_module_viewed::create([
     'objectid' => $moduleinstance->id,
     'context' => $context
 ]);
@@ -49,9 +57,9 @@ $completion = new completion_info($course);
 $completion->set_module_viewed($cm);
 
 // Convert display options to a valid object.
-$factory = new \core_h5p\factory();
+$factory = new factory();
 $core = $factory->get_core();
-$config = \core_h5p\helper::decode_display_options($core, $moduleinstance->displayoptions);
+$config = core_h5p\helper::decode_display_options($core, $moduleinstance->displayoptions);
 
 // Instantiate player.
 $fs = get_file_storage();
@@ -73,7 +81,7 @@ $PAGE->set_context($context);
 echo $OUTPUT->header();
 echo $OUTPUT->heading(format_string($moduleinstance->name));
 
-if (has_capability('mod/h5pactivity:submit', $context, null, false)) {
+if ($manager->is_tracking_enabled()) {
     $trackcomponent = 'mod_h5pactivity';
 } else {
     $trackcomponent = '';
@@ -81,6 +89,6 @@ if (has_capability('mod/h5pactivity:submit', $context, null, false)) {
     echo $OUTPUT->notification($message, \core\output\notification::NOTIFY_WARNING);
 }
 
-echo \core_h5p\player::display($fileurl, $config, true, $trackcomponent);
+echo player::display($fileurl, $config, true, $trackcomponent);
 
 echo $OUTPUT->footer();
index 1758dc3..45444fc 100644 (file)
@@ -162,7 +162,8 @@ function lesson_update_events($lesson, $override = null) {
 
         $event = new stdClass();
         $event->type = !$deadline ? CALENDAR_EVENT_TYPE_ACTION : CALENDAR_EVENT_TYPE_STANDARD;
-        $event->description = format_module_intro('lesson', $lesson, $cmid);
+        $event->description = format_module_intro('lesson', $lesson, $cmid, false);
+        $event->format = FORMAT_HTML;
         // Events module won't show user events when the courseid is nonzero.
         $event->courseid    = ($userid) ? 0 : $lesson->course;
         $event->groupid     = $groupid;
index 20fe537..7330c3e 100644 (file)
@@ -1260,7 +1260,8 @@ function quiz_update_events($quiz, $override = null) {
 
         $event = new stdClass();
         $event->type = !$timeclose ? CALENDAR_EVENT_TYPE_ACTION : CALENDAR_EVENT_TYPE_STANDARD;
-        $event->description = format_module_intro('quiz', $quiz, $cmid);
+        $event->description = format_module_intro('quiz', $quiz, $cmid, false);
+        $event->format = FORMAT_HTML;
         // Events module won't show user events when the courseid is nonzero.
         $event->courseid    = ($userid) ? 0 : $quiz->course;
         $event->groupid     = $groupid;
index f2dd536..6647a2b 100644 (file)
@@ -2424,7 +2424,8 @@ function scorm_update_calendar(stdClass $scorm, $cmid) {
         if ((!empty($scorm->timeopen)) && ($scorm->timeopen > 0)) {
             // Calendar event exists so update it.
             $event->name = get_string('calendarstart', 'scorm', $scorm->name);
-            $event->description = format_module_intro('scorm', $scorm, $cmid);
+            $event->description = format_module_intro('scorm', $scorm, $cmid, false);
+            $event->format = FORMAT_HTML;
             $event->timestart = $scorm->timeopen;
             $event->timesort = $scorm->timeopen;
             $event->visible = instance_is_visible('scorm', $scorm);
@@ -2441,7 +2442,8 @@ function scorm_update_calendar(stdClass $scorm, $cmid) {
         // Event doesn't exist so create one.
         if ((!empty($scorm->timeopen)) && ($scorm->timeopen > 0)) {
             $event->name = get_string('calendarstart', 'scorm', $scorm->name);
-            $event->description = format_module_intro('scorm', $scorm, $cmid);
+            $event->description = format_module_intro('scorm', $scorm, $cmid, false);
+            $event->format = FORMAT_HTML;
             $event->courseid = $scorm->course;
             $event->groupid = 0;
             $event->userid = 0;
@@ -2465,7 +2467,8 @@ function scorm_update_calendar(stdClass $scorm, $cmid) {
         if ((!empty($scorm->timeclose)) && ($scorm->timeclose > 0)) {
             // Calendar event exists so update it.
             $event->name = get_string('calendarend', 'scorm', $scorm->name);
-            $event->description = format_module_intro('scorm', $scorm, $cmid);
+            $event->description = format_module_intro('scorm', $scorm, $cmid, false);
+            $event->format = FORMAT_HTML;
             $event->timestart = $scorm->timeclose;
             $event->timesort = $scorm->timeclose;
             $event->visible = instance_is_visible('scorm', $scorm);
@@ -2482,7 +2485,8 @@ function scorm_update_calendar(stdClass $scorm, $cmid) {
         // Event doesn't exist so create one.
         if ((!empty($scorm->timeclose)) && ($scorm->timeclose > 0)) {
             $event->name = get_string('calendarend', 'scorm', $scorm->name);
-            $event->description = format_module_intro('scorm', $scorm, $cmid);
+            $event->description = format_module_intro('scorm', $scorm, $cmid, false);
+            $event->format = FORMAT_HTML;
             $event->courseid = $scorm->course;
             $event->groupid = 0;
             $event->userid = 0;
index 3c25f25..998bce0 100644 (file)
@@ -5,6 +5,27 @@ information provided here is intended especially for developers.
 
 * The callback get_shortcuts() is now deprecated. Please use get_course_content_items and get_all_content_items instead.
   See source code examples in get_course_content_items() and get_all_content_items() in mod/lti/lib.php for details.
+* When creating the calendar events and setting the event description to match the module intro description, the filters
+  must not be applied on the passed description text. Doing so leads to loosing some expected text filters features and
+  causes unnecessarily early theme and output initialisation in unit tests. If your activity creates calendar events,
+  you probably have code like:
+    ```
+    $event->description = format_module_intro('quiz', $quiz, $cmid);
+    ```
+  You need to change it to:
+    ```
+    $event->description = format_module_intro('quiz', $quiz, $cmid, false);
+    $event->format = FORMAT_HTML;
+    ```
+  Even this is still technically wrong. Content should normally only be formatted just before it is output. Ideally, we
+  should pass the raw description text, format and have a way to copy the embedded files; or provide another way for the
+  calendar to call the right format_text() later. The calendar API does not allow us to do these things easily at the
+  moment. Therefore, this compromise approach is used. The false parameter added ensures that text filters are not run
+  at this time which is important. And the format must be set to HTML, because otherwise it would use the current user's
+  preferred editor default format.
+* Related to the above and to help with detecting the problematic places in contributed 3rd party modules, the
+  testing_module_generator::create_instance() now throws coding_exception if creating a module instance initialised the
+  theme and output as a side effect.
 
 === 3.8 ===
 
index c38199a..951b631 100644 (file)
@@ -1668,6 +1668,7 @@ function workshop_calendar_update(stdClass $workshop, $cmid) {
     // the common properties for all events
     $base = new stdClass();
     $base->description  = format_module_intro('workshop', $workshop, $cmid, false);
+    $base->format       = FORMAT_HTML;
     $base->courseid     = $workshop->course;
     $base->groupid      = 0;
     $base->userid       = 0;
diff --git a/pix/i/contentbank.png b/pix/i/contentbank.png
new file mode 100644 (file)
index 0000000..970c30e
Binary files /dev/null and b/pix/i/contentbank.png differ
diff --git a/pix/i/contentbank.svg b/pix/i/contentbank.svg
new file mode 100644 (file)
index 0000000..3d87f77
--- /dev/null
@@ -0,0 +1,2 @@
+<?xml version="1.0" encoding="utf-8"?>
+<svg width="16" height="16" viewBox="0 0 1792 1792" xmlns="http://www.w3.org/2000/svg"><path d="M1615 0q70 0 122.5 46.5t52.5 116.5q0 63-45 151-332 629-465 752-97 91-218 91-126 0-216.5-92.5t-90.5-219.5q0-128 92-212l638-579q59-54 130-54zm-909 1034q39 76 106.5 130t150.5 76l1 71q4 213-129.5 347t-348.5 134q-123 0-218-46.5t-152.5-127.5-86.5-183-29-220q7 5 41 30t62 44.5 59 36.5 46 17q41 0 55-37 25-66 57.5-112.5t69.5-76 88-47.5 103-25.5 125-10.5z" fill="#999"/></svg>
\ No newline at end of file
index 5927f78..3909387 100644 (file)
@@ -90,7 +90,7 @@ class report_table extends \table_sql implements \renderable {
         $fields = 'cl.id, cl.timemodified, cl.plugin, cl.name, cl.value, cl.oldvalue, cl.userid, ' . $userfields;
 
         $from = '{config_log} cl
-            JOIN {user} u ON u.id = cl.userid';
+            LEFT JOIN {user} u ON u.id = cl.userid';
 
         // Report search.
         $where = '1=1';
@@ -143,6 +143,24 @@ class report_table extends \table_sql implements \renderable {
         return userdate($row->timemodified);
     }
 
+    /**
+     * Format fullname field
+     *
+     * @param stdClass $row
+     * @return string
+     */
+    public function col_fullname($row) {
+
+        $userid = $row->{$this->useridfield};
+        if (empty($userid)) {
+            // If the user id is empty it must have been set via the
+            // admin/cli/cfg.php script or during the initial install.
+            return get_string('usernone', 'report_configlog');
+        } else {
+            return parent::col_fullname($row);
+        }
+    }
+
     /**
      * Format report plugin field
      *
index 40645fd..643c7b6 100644 (file)
@@ -31,6 +31,7 @@ $string['pluginname'] = 'Config changes';
 $string['setting'] = 'Setting';
 $string['timemodified'] = 'Date';
 $string['user'] = 'User';
+$string['usernone'] = 'CLI or install';
 $string['user_help'] = 'Search by user first name or surname';
 $string['value'] = 'Value';
 $string['value_help'] = 'Search by new or original value of the configuration';
index b6f91f1..3f34cca 100644 (file)
@@ -1511,7 +1511,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
  */
 .modchooser .modal-body {
     padding: 0;
-    height: 640px;
+    min-height: 640px;
     overflow-y: auto;
 
     .loading-icon {
@@ -1521,9 +1521,11 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
             font-size: 3em;
             height: 1em;
             width: 1em;
-            margin: 5em auto;
         }
     }
+    .carousel-item .loading-icon .icon {
+        margin: 5em auto;
+    }
 }
 
 .modchoosercontainer.noscroll {
@@ -1582,7 +1584,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
     background-color: $white;
     overflow-x: hidden;
     overflow-y: auto;
-    height: 640px;
+    min-height: 640px;
 
     .content {
         overflow-y: auto;
@@ -2359,6 +2361,12 @@ body.h5p-embed {
     word-break: break-all;
 }
 
+.matchtext {
+    background-color: lighten($primary, 40%);
+    color: $body-color;
+    height: 1.5rem;
+}
+
 // Emoji picker.
 $picker-width: 350px !default;
 $picker-width-xs: 320px !default;
index 2d523f7..c920380 100644 (file)
@@ -477,12 +477,6 @@ $message-day-color: color-yiq($message-app-bg) !default;
         }
     }
 
-    .matchtext {
-        background-color: lighten($primary, 40%);
-        color: $body-color;
-        height: 1.5rem;
-    }
-
     .contact-status {
         position: absolute;
         left: 39px;
index d5e8939..f27cd9b 100644 (file)
@@ -46,3 +46,14 @@ table {
         caption-side: top;
     }
 }
+
+.table-dynamic .loading-icon {
+    position: absolute;
+    left: calc(50% - 1.5rem);
+    top: 200px;
+    .icon {
+        height: 3rem;
+        width: 3rem;
+        font-size: 3rem;
+    }
+}
index 5e6e4b3..0d2408f 100644 (file)
@@ -236,7 +236,7 @@ $allow-reset-style: true !default;
                     vertical-align: top;
 
                     div[role="main"] {
-                        flex: 1;
+                        flex: 1 0 auto;
                     }
 
                     .activity-navigation {
index 74d9792..aa6c0db 100644 (file)
@@ -10653,7 +10653,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
  */
 .modchooser .modal-body {
   padding: 0;
-  height: 640px;
+  min-height: 640px;
   overflow-y: auto; }
   .modchooser .modal-body .loading-icon {
     opacity: 1; }
@@ -10661,8 +10661,9 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
       display: block;
       font-size: 3em;
       height: 1em;
-      width: 1em;
-      margin: 5em auto; }
+      width: 1em; }
+  .modchooser .modal-body .carousel-item .loading-icon .icon {
+    margin: 5em auto; }
 
 .modchoosercontainer.noscroll {
   overflow-y: hidden; }
@@ -10708,7 +10709,7 @@ body#page-lib-editor-tinymce-plugins-moodlemedia-preview {
   background-color: #fff;
   overflow-x: hidden;
   overflow-y: auto;
-  height: 640px; }
+  min-height: 640px; }
   .modchooser .modal-body .optionsummary .content {
     overflow-y: auto; }
     .modchooser .modal-body .optionsummary .content .heading .icon {
@@