Merge branch 'MDL-66796' of git://github.com/timhunt/moodle
authorJake Dallimore <jake@moodle.com>
Tue, 15 Oct 2019 02:52:45 +0000 (10:52 +0800)
committerJake Dallimore <jake@moodle.com>
Tue, 15 Oct 2019 02:52:45 +0000 (10:52 +0800)
135 files changed:
admin/tool/dataprivacy/amd/build/expand_contract.min.js
admin/tool/dataprivacy/amd/build/expand_contract.min.js.map
admin/tool/dataprivacy/amd/src/expand_contract.js
auth/tests/behat/behat_auth.php
badges/upgradelib.php [new file with mode: 0644]
blocks/starredcourses/classes/external.php
blog/classes/privacy/provider.php
blog/tests/privacy_test.php
config-dist.php
lang/en/question.php
lib/badgeslib.php
lib/behat/behat_base.php
lib/behat/classes/behat_context_helper.php
lib/behat/classes/behat_selectors.php
lib/behat/classes/component_named_replacement.php [new file with mode: 0644]
lib/behat/classes/component_named_selector.php [new file with mode: 0644]
lib/behat/classes/exact_named_selector.php
lib/behat/classes/named_selector.php [new file with mode: 0644]
lib/behat/classes/partial_named_selector.php
lib/behat/classes/util.php
lib/classes/output/checkbox_toggleall.php
lib/db/install.php
lib/db/upgrade.php
lib/filelib.php
lib/filestorage/file_storage.php
lib/filestorage/file_system.php
lib/filestorage/stored_file.php
lib/moodlelib.php
lib/outputrenderers.php
lib/questionlib.php
lib/tablelib.php
lib/templates/checkbox-toggleall-master-button.mustache
lib/templates/checkbox-toggleall-master.mustache
lib/templates/checkbox-toggleall-slave.mustache
lib/tests/behat/behat_hooks.php
lib/tests/behat/behat_navigation.php
lib/tests/tablelib_test.php
lib/upgrade.txt
login/index.php
message/tests/behat/behat_message.php
message/tests/behat/delete_messages.feature
message/tests/behat/favourite_conversations.feature
message/tests/behat/group_conversation.feature
message/tests/behat/message_delete_conversation.feature
message/tests/behat/message_send_messages.feature
message/tests/behat/mute_conversations.feature
message/tests/behat/self_conversation.feature
message/tests/behat/unread_messages.feature
mod/assign/feedback/editpdf/classes/document_services.php
mod/assign/submission/file/locallib.php
mod/assign/submissionplugin.php
mod/assign/upgrade.txt
mod/choice/renderer.php
mod/data/lib.php
mod/data/view.php
mod/feedback/show_nonrespondents.php
mod/forum/report/summary/classes/summary_table.php
mod/forum/report/summary/index.php
mod/quiz/classes/question/bank/custom_view.php
mod/quiz/classes/question/bank/question_name_text_column.php
mod/quiz/locallib.php
mod/quiz/styles.css
mod/quiz/tests/behat/add_quiz.feature
mod/quiz/tests/behat/attempt_basic.feature
mod/quiz/tests/behat/attempt_begin.feature
mod/quiz/tests/behat/attempt_redo_questions.feature
mod/quiz/tests/behat/attempt_require_previous.feature
mod/quiz/tests/behat/backup.feature
mod/quiz/tests/behat/behat_mod_quiz.php
mod/quiz/tests/behat/editing_add.feature
mod/quiz/tests/behat/editing_add_from_question_bank.feature
mod/quiz/tests/behat/editing_add_random.feature
mod/quiz/tests/behat/editing_move_by_click.feature
mod/quiz/tests/behat/editing_remove_multiple_questions.feature
mod/quiz/tests/behat/editing_remove_question.feature
mod/quiz/tests/behat/editing_repaginate.feature
mod/quiz/tests/behat/editing_require_previous.feature
mod/quiz/tests/behat/editing_section_headings.feature
mod/quiz/tests/behat/editing_set_marks_no_attempts.feature
mod/quiz/tests/behat/editing_set_marks_with_attempts.feature
mod/quiz/tests/behat/manually_mark_question.feature
mod/quiz/tests/behat/preview.feature
mod/quiz/tests/behat/quiz_group_override.feature
mod/quiz/tests/behat/quiz_no_calendar_capabilities.feature
mod/quiz/tests/behat/quiz_reset.feature
phpunit.xml.dist
question/category_class.php
question/classes/bank/column_base.php
question/classes/bank/question_name_idnumber_tags_column.php [new file with mode: 0644]
question/classes/bank/view.php
question/tests/behat/behat_question.php
question/tests/behat/copy_questions.feature
question/tests/behat/delete_questions.feature
question/tests/behat/edit_questions.feature
question/tests/behat/filter_questions_by_tag.feature
question/tests/behat/preview_question.feature
question/tests/behat/question_categories.feature
question/tests/behat/question_categories_idnumber.feature
question/tests/behat/sort_questions.feature
question/type/ddimageortext/tests/behat/backup_and_restore.feature
question/type/ddimageortext/tests/behat/edit.feature
question/type/ddimageortext/tests/behat/preview.feature
question/type/ddmarker/tests/behat/backup_and_restore.feature
question/type/ddmarker/tests/behat/edit.feature
question/type/ddmarker/tests/behat/preview.feature
question/type/ddwtos/tests/behat/backup_and_restore.feature
question/type/ddwtos/tests/behat/edit.feature
question/type/ddwtos/tests/behat/preview.feature
question/type/description/tests/behat/backup_and_restore.feature
question/type/description/tests/behat/edit.feature
question/type/description/tests/behat/preview.feature
question/type/essay/tests/behat/backup_and_restore.feature
question/type/essay/tests/behat/edit.feature
question/type/essay/tests/behat/preview.feature
question/type/gapselect/tests/behat/basic_test.feature
question/type/match/tests/behat/backup_and_restore.feature
question/type/match/tests/behat/edit.feature
question/type/match/tests/behat/preview.feature
question/type/multichoice/tests/behat/backup_and_restore.feature
question/type/multichoice/tests/behat/edit.feature
question/type/multichoice/tests/behat/preview.feature
question/type/numerical/tests/behat/backup_and_restore.feature
question/type/numerical/tests/behat/edit.feature
question/type/numerical/tests/behat/preview.feature
question/type/shortanswer/tests/behat/backup_and_restore.feature
question/type/shortanswer/tests/behat/edit.feature
question/type/shortanswer/tests/behat/preview.feature
question/type/truefalse/tests/behat/backup_and_restore.feature
question/type/truefalse/tests/behat/edit.feature
question/type/truefalse/tests/behat/preview.feature
question/type/upgrade.txt
question/upgrade.txt
tag/classes/output/taglist.php
tag/classes/tag.php
tag/templates/taglist.mustache

index ac0d11f..7f73e4c 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/expand_contract.min.js and b/admin/tool/dataprivacy/amd/build/expand_contract.min.js differ
index 9ee561c..5f7d8ab 100644 (file)
Binary files a/admin/tool/dataprivacy/amd/build/expand_contract.min.js.map and b/admin/tool/dataprivacy/amd/build/expand_contract.min.js.map differ
index cf509b5..a369f7c 100644 (file)
@@ -28,6 +28,14 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
     var expandedImage = $('<img alt="" src="' + url.imageUrl('t/expanded') + '"/>');
     var collapsedImage = $('<img alt="" src="' + url.imageUrl('t/collapsed') + '"/>');
 
+    /*
+     * Class names to apply when expanding/collapsing nodes.
+     */
+    var CLASSES = {
+        EXPAND: 'fa-caret-right',
+        COLLAPSE: 'fa-caret-down'
+    };
+
     return /** @alias module:tool_dataprivacy/expand-collapse */ {
         /**
          * Expand or collapse a selected node.
@@ -40,15 +48,15 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
                 targetnode.removeClass('hide');
                 targetnode.addClass('visible');
                 targetnode.attr('aria-expanded', true);
-                thisnode.find(':header i.fa').removeClass('fa-plus-square');
-                thisnode.find(':header i.fa').addClass('fa-minus-square');
+                thisnode.find(':header i.fa').removeClass(CLASSES.EXPAND);
+                thisnode.find(':header i.fa').addClass(CLASSES.COLLAPSE);
                 thisnode.find(':header img.icon').attr('src', expandedImage.attr('src'));
             } else {
                 targetnode.removeClass('visible');
                 targetnode.addClass('hide');
                 targetnode.attr('aria-expanded', false);
-                thisnode.find(':header i.fa').removeClass('fa-minus-square');
-                thisnode.find(':header i.fa').addClass('fa-plus-square');
+                thisnode.find(':header i.fa').removeClass(CLASSES.COLLAPSE);
+                thisnode.find(':header i.fa').addClass(CLASSES.EXPAND);
                 thisnode.find(':header img.icon').attr('src', collapsedImage.attr('src'));
             }
         },
@@ -61,8 +69,8 @@ define(['jquery', 'core/url', 'core/str'], function($, url, str) {
         expandCollapseAll: function(nextstate) {
             var currentstate = (nextstate == 'visible') ? 'hide' : 'visible';
             var ariaexpandedstate = (nextstate == 'visible') ? true : false;
-            var iconclassnow = (nextstate == 'visible') ? 'fa-plus-square' : 'fa-minus-square';
-            var iconclassnext = (nextstate == 'visible') ? 'fa-minus-square' : 'fa-plus-square';
+            var iconclassnow = (nextstate == 'visible') ? CLASSES.EXPAND : CLASSES.COLLAPSE;
+            var iconclassnext = (nextstate == 'visible') ? CLASSES.COLLAPSE : CLASSES.EXPAND;
             var imagenow = (nextstate == 'visible') ? expandedImage.attr('src') : collapsedImage.attr('src');
             $('.' + currentstate).each(function() {
                 $(this).removeClass(currentstate);
index 30d1691..e7b65e5 100644 (file)
@@ -42,16 +42,23 @@ class behat_auth extends behat_base {
      * Logs in the user. There should exist a user with the same value as username and password.
      *
      * @Given /^I log in as "(?P<username_string>(?:[^"]|\\")*)"$/
+     * @param string $username the user to log in as.
+     * @param moodle_url|null $wantsurl optional, URL to go to after logging in.
      */
-    public function i_log_in_as($username) {
-        // In the mobile app the required tasks are different.
+    public function i_log_in_as(string $username, moodle_url $wantsurl = null) {
+        // In the mobile app the required tasks are different (does not support $wantsurl).
         if ($this->is_in_app()) {
             $this->execute('behat_app::login', [$username]);
             return;
         }
 
+        $loginurl = new moodle_url('/login/index.php');
+        if ($wantsurl !== null) {
+            $loginurl->param('wantsurl', $wantsurl->out_as_local_url());
+        }
+
         // Visit login page.
-        $this->getSession()->visit($this->locate_path('login/index.php'));
+        $this->getSession()->visit($this->locate_path($loginurl->out_as_local_url()));
 
         // Enter username and password.
         $this->execute('behat_forms::i_set_the_field_to', array('Username', $this->escape($username)));
diff --git a/badges/upgradelib.php b/badges/upgradelib.php
new file mode 100644 (file)
index 0000000..d1acd8b
--- /dev/null
@@ -0,0 +1,65 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * Contains upgrade and install functions for badges.
+ *
+ * @package    core_badges
+ * @copyright  2019 Damyon Wiese
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+defined('MOODLE_INTERNAL') || die();
+
+/**
+ * Called on install or upgrade to create default list of backpacks a user can connect to.
+ * Don't use the global defines from badgeslib because this is for install/upgrade.
+ *
+ * @return void
+ */
+function badges_install_default_backpacks() {
+    global $DB;
+
+    $record = new stdClass();
+    $record->backpackweburl = 'https://backpack.openbadges.org';
+    $record->backpackapiurl = 'https://backpack.openbadges.org';
+    $record->apiversion = 1;
+    $record->sortorder = 0;
+    $record->password = '';
+
+    if (!($bp = $DB->get_record('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl)))) {
+        $bpid = $DB->insert_record('badge_external_backpack', $record);
+    } else {
+        $bpid = $bp->id;
+    }
+    set_config('badges_site_backpack', $bpid);
+
+    // All existing backpacks default to V1.
+    $DB->set_field('badge_backpack', 'externalbackpackid', $bpid);
+
+    $record = new stdClass();
+    $record->backpackapiurl = 'https://api.badgr.io/v2';
+    $record->backpackweburl = 'https://badgr.io';
+    $record->apiversion = 2;
+    $record->sortorder = 1;
+    $record->password = '';
+
+    if (!$DB->record_exists('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl))) {
+        $DB->insert_record('badge_external_backpack', $record);
+    }
+
+}
+
index f5cb1c2..d1ace2c 100644 (file)
@@ -88,13 +88,18 @@ class block_starredcourses_external extends core_course_external {
             return ($a->timemodified > $b->timemodified) ? -1 : 1;
         });
 
-        $formattedcourses = array_map(function($favourite) use ($renderer) {
+        $formattedcourses = array();
+        foreach ($favourites as $favourite) {
             $course = get_course($favourite->itemid);
             $context = \context_course::instance($favourite->itemid);
-
-            $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]);
-            return $exporter->export($renderer);
-        }, $favourites);
+            $canviewhiddencourses = has_capability('moodle/course:viewhiddencourses', $context);
+
+            if ($course->visible || $canviewhiddencourses) {
+                $exporter = new course_summary_exporter($course, ['context' => $context, 'isfavourite' => true]);
+                $formattedcourse = $exporter->export($renderer);
+                $formattedcourses[] = $formattedcourse;
+            }
+        }
 
         return $formattedcourses;
     }
index 4bbc744..8ff19c3 100644 (file)
@@ -460,8 +460,7 @@ class provider implements
             $params = array_merge($inparams, ['userid' => $userid]);
             $associds = $DB->get_fieldset_sql($sql, $params);
 
-            list($insql, $inparams) = $DB->get_in_or_equal($associds, SQL_PARAMS_NAMED, 'param', true);
-            $DB->delete_records_select('blog_association', "id $insql", $inparams);
+            $DB->delete_records_list('blog_association', 'id', $associds);
         }
     }
 
index 8db5bdd..7002552 100644 (file)
@@ -370,6 +370,37 @@ class core_blog_privacy_testcase extends provider_testcase {
         $this->assertTrue($DB->record_exists('post', ['courseid' => $c1->id, 'userid' => $u1->id, 'module' => 'notes']));
     }
 
+    /**
+     * Test provider delete_data_for_user with a context that contains no entries
+     *
+     * @return void
+     */
+    public function test_delete_data_for_user_empty_context() {
+        global $DB;
+
+        $user = $this->getDataGenerator()->create_user();
+        $course = $this->getDataGenerator()->create_course();
+        $context = context_course::instance($course->id);
+
+        // Create a blog entry for user, associated with course.
+        $entry = new blog_entry($this->create_post(['userid' => $user->id, 'courseid' => $course->id])->id);
+        $entry->add_association($context->id);
+
+        // Generate list of contexts for user.
+        $contexts = provider::get_contexts_for_userid($user->id);
+        $this->assertContains($context->id, $contexts->get_contextids());
+
+        // Now delete the blog entry.
+        $entry->delete();
+
+        // Try to delete user data using contexts obtained prior to entry deletion.
+        $contextlist = new approved_contextlist($user, 'core_blog', $contexts->get_contextids());
+        provider::delete_data_for_user($contextlist);
+
+        // Sanity check to ensure blog_associations is really empty.
+        $this->assertEmpty($DB->get_records('blog_association', ['contextid' => $context->id]));
+    }
+
     public function test_delete_data_for_all_users_in_context() {
         global $DB;
 
index 5227584..ba04c38 100644 (file)
@@ -626,6 +626,23 @@ $CFG->admin = 'admin';
 //
 //      $CFG->uninstallclionly = true;
 //
+//
+// Customise question bank display
+//
+// The display of Moodle's question bank is made up of a number of columns.
+// You can customise this display by giving a comma-separated list of column class
+// names here. Each class must be a subclass of \core_question\bank\column_base.
+// For example you might define a class like
+//      class \local_qbank_extensions\my_column extends \core_question\bank\column_base
+// in a local plugin, then add it to the list here. At the time of writing,
+// the default question bank display is equivalent to the following, but you  might like
+// to check the latest default in question/classes/bank/view.php before setting this.
+//
+//      $CFG->questionbankcolumns = 'checkbox_column,question_type_column,'
+//              . 'question_name_idnumber_tags_column,tags_action_column,edit_action_column,'
+//              . 'copy_action_column,preview_action_column,delete_action_column,'
+//              . 'creator_name_column,modifier_name_column';
+//
 //=========================================================================
 // 7. SETTINGS FOR DEVELOPMENT SERVERS - not intended for production use!!!
 //=========================================================================
index 7dc290e..8da8aa4 100644 (file)
@@ -70,6 +70,9 @@ $string['categoryinfo'] = 'Category info';
 $string['categorymove'] = 'The category \'{$a->name}\' contains {$a->count} questions (some of which may be hidden questions or random questions that are still in use in a quiz). Please choose another category to move them to.';
 $string['categorymoveto'] = 'Save in category';
 $string['categorynamecantbeblank'] = 'The category name cannot be blank.';
+$string['categorynamewithcount'] = '{$a->name} ({$a->questioncount})';
+$string['categorynamewithidnumber'] = '{$a->name} [{$a->idnumber}]';
+$string['categorynamewithidnumberandcount'] = '{$a->name} [{$a->idnumber}] ({$a->questioncount})';
 $string['clickflag'] = 'Flag question';
 $string['clicktoflag'] = 'Flag this question for future reference';
 $string['clicktounflag'] = 'Remove flag';
index 986e503..572a520 100644 (file)
@@ -233,13 +233,16 @@ function badges_calculate_message_schedule($schedule) {
 
     switch ($schedule) {
         case BADGE_MESSAGE_DAILY:
-            $nextcron = time() + 60 * 60 * 24;
+            $tomorrow = new DateTime("1 day", core_date::get_server_timezone_object());
+            $nextcron = $tomorrow->getTimestamp();
             break;
         case BADGE_MESSAGE_WEEKLY:
-            $nextcron = time() + 60 * 60 * 24 * 7;
+            $nextweek = new DateTime("1 week", core_date::get_server_timezone_object());
+            $nextcron = $nextweek->getTimestamp();
             break;
         case BADGE_MESSAGE_MONTHLY:
-            $nextcron = time() + 60 * 60 * 24 * 7 * 30;
+            $nextmonth = new DateTime("1 month", core_date::get_server_timezone_object());
+            $nextcron = $nextmonth->getTimestamp();
             break;
     }
 
@@ -856,45 +859,6 @@ function badges_get_badge_api_versions() {
     ];
 }
 
-/**
- * Called on install or upgrade to create default list of backpacks a user can connect to.
- *
- * @return void
- */
-function badges_install_default_backpacks() {
-    global $DB;
-
-    $record = new stdClass();
-    $record->backpackweburl = BADGE_BACKPACKWEBURL;
-    $record->backpackapiurl = BADGE_BACKPACKAPIURL;
-    $record->apiversion = OPEN_BADGES_V1;
-    $record->sortorder = 0;
-    $record->password = '';
-
-    $bpid = 0;
-    if (!($bp = $DB->get_record('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl)))) {
-        $bpid = $DB->insert_record('badge_external_backpack', $record);
-    } else {
-        $bpid = $bp->id;
-    }
-    set_config('badges_site_backpack', $bpid);
-
-    // All existing backpacks default to V1.
-    $DB->set_field('badge_backpack', 'externalbackpackid', $bpid);
-
-    $record = new stdClass();
-    $record->backpackapiurl = BADGRIO_BACKPACKAPIURL;
-    $record->backpackweburl = BADGRIO_BACKPACKWEBURL;
-    $record->apiversion = OPEN_BADGES_V2;
-    $record->sortorder = 1;
-    $record->password = '';
-
-    if (!$DB->record_exists('badge_external_backpack', array('backpackapiurl' => $record->backpackapiurl))) {
-        $DB->insert_record('badge_external_backpack', $record);
-    }
-
-}
-
 /**
  * Get the default issuer for a badge from this site.
  *
index c6053b9..7a23e2d 100644 (file)
@@ -35,6 +35,9 @@ use Behat\Mink\Element\NodeElement;
 use Behat\Mink\Element\Element;
 use Behat\Mink\Session;
 
+require_once(__DIR__ . '/classes/component_named_selector.php');
+require_once(__DIR__ . '/classes/component_named_replacement.php');
+
 /**
  * Steps definitions base class.
  *
@@ -216,10 +219,22 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
             $selector = 'xpath';
         }
 
-        // Convert to named_partial where the selector type is not named_partial, named_exact, xpath, or css.
+        // Convert to a named selector where the selector type is not a known selector.
         $converttonamed = !$this->getSession()->getSelectorsHandler()->isSelectorRegistered($selector);
         $converttonamed = $converttonamed && 'xpath' !== $selector;
         if ($converttonamed) {
+            if (behat_partial_named_selector::is_deprecated_selector($selector)) {
+                if ($replacement = behat_partial_named_selector::get_deprecated_replacement($selector)) {
+                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
+                    $selector = $replacement;
+                }
+            } else if (behat_exact_named_selector::is_deprecated_selector($selector)) {
+                if ($replacement = behat_exact_named_selector::get_deprecated_replacement($selector)) {
+                    error_log("The '{$selector}' selector has been replaced with {$replacement}");
+                    $selector = $replacement;
+                }
+            }
+
             $allowedpartialselectors = behat_partial_named_selector::get_allowed_selectors();
             $allowedexactselectors = behat_exact_named_selector::get_allowed_selectors();
             if (isset($allowedpartialselectors[$selector])) {
@@ -229,7 +244,7 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
                 $locator = behat_selectors::normalise_named_selector($allowedexactselectors[$selector], $locator);
                 $selector = 'named_exact';
             } else {
-                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this);
+                throw new ExpectationException("The '{$selector}' selector type is not registered.", $this->getSession()->getDriver());
             }
         }
 
@@ -1043,6 +1058,56 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
         }
     }
 
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
+     *
+     * You should override this as appropriate for your plugin. The method
+     * {@link behat_navigation::resolve_core_page_url()} is a good example.
+     *
+     * Your overridden method should document the recognised page types with
+     * a table like this:
+     *
+     * Recognised page names are:
+     * | Page            | Description                                                    |
+     *
+     * @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_page_url(string $page): moodle_url {
+        throw new Exception('Component "' . get_class($this) .
+                '" does not support the generic \'When I am on the "' . $page .
+                '" page\' navigation step.');
+    }
+
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
+     *
+     * A typical example might be:
+     *     When I am on the "Test quiz" "mod_quiz > Responses report" page
+     * which would cause this method in behat_mod_quiz to be called with
+     * arguments 'Responses report', 'Test quiz'.
+     *
+     * You should override this as appropriate for your plugin. The method
+     * {@link behat_navigation::resolve_core_page_instance_url()} is a good example.
+     *
+     * Your overridden method should document the recognised page types with
+     * a table like this:
+     *
+     * Recognised page names are:
+     * | Type      | identifier meaning | Description                                     |
+     *
+     * @param string $type identifies which type of page this is, e.g. 'Attempt review'.
+     * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
+        throw new Exception('Component "' . get_class($this) .
+                '" does not support the generic \'When I am on the "' . $identifier .
+                '" "' . $type . '" page\' navigation step.');
+    }
+
     /**
      * Gets the required timeout in seconds.
      *
@@ -1092,4 +1157,58 @@ class behat_base extends Behat\MinkExtension\Context\RawMinkContext {
     public static function get_extended_timeout() : int {
         return self::get_real_timeout(10);
     }
+
+    /**
+     * Return a list of the exact named selectors for the component.
+     *
+     * Named selectors are what make Behat steps like
+     *   Then I should see "Useful text" in the "General" "fieldset"
+     * work. Here, "fieldset" is the named selector, and "General" is the locator.
+     *
+     * If you override this method in your plugin (e.g. mod_mymod), to define
+     * new selectors specific to your plugin. For example, if you returned
+     *   new behat_component_named_selector('Thingy',
+     *           [".//some/xpath//img[contains(@alt, %locator%)]/.."])
+     * then
+     *   Then I should see "Useful text" in the "Whatever" "mod_mymod > Thingy"
+     * would work.
+     *
+     * This method should return a list of {@link behat_component_named_selector} and
+     * the docs on that class explain how it works.
+     *
+     * @return behat_component_named_selector[]
+     */
+    public static function get_exact_named_selectors(): array {
+        return [];
+    }
+
+    /**
+     * Return a list of the partial named selectors for the component.
+     *
+     * Like the exact named selectors above, but the locator only
+     * needs to match part of the text. For example, the standard
+     * "button" is a partial selector, so:
+     *   When I click "Save" "button"
+     * will activate "Save changes".
+     *
+     * @return behat_component_named_selector[]
+     */
+    public static function get_partial_named_selectors(): array {
+        return [];
+    }
+
+    /**
+     * Return a list of the Mink named replacements for the component.
+     *
+     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
+     * xpaths.
+     *
+     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
+     * how it works.
+     *
+     * @return behat_component_named_replacement[]
+     */
+    public static function get_named_replacements(): array {
+        return [];
+    }
 }
index 8b050cc..d7a2b60 100644 (file)
@@ -90,12 +90,8 @@ class behat_context_helper {
      * @return behat_base
      */
     public static function get($classname) {
-        $contexts = self::$environment->getContexts();
-
-        foreach ($contexts as $context) {
-            if (is_a($context, $classname)) {
-                return $context;
-            }
+        if (self::$environment->hasContextClass($classname)) {
+            return self::$environment->getContext($classname);
         }
 
         $suitename = self::$environment->getSuite()->getName();
@@ -121,6 +117,16 @@ class behat_context_helper {
         return self::$environment->getContext($classname);
     }
 
+    /**
+     * Return whether there is a context of the specified classname.
+     *
+     * @param string $classname
+     * @return bool
+     */
+    public static function has_context(string $classname): bool {
+        return self::$environment->hasContextClass($classname);
+    }
+
     /**
      * Translates string to XPath literal.
      *
index f093e75..e90ec56 100644 (file)
@@ -23,6 +23,7 @@
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
 
+require_once(__DIR__ . '/named_selector.php');
 require_once(__DIR__ . '/exact_named_selector.php');
 require_once(__DIR__ . '/partial_named_selector.php');
 
diff --git a/lib/behat/classes/component_named_replacement.php b/lib/behat/classes/component_named_replacement.php
new file mode 100644 (file)
index 0000000..f11a4ce
--- /dev/null
@@ -0,0 +1,99 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A class for recording the definition of Mink replacements.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * A class for recording the definition of Mink replacements for use in Mink selectors.
+ *
+ * These are comprised of a source string, and a replacement.
+ *
+ * During use the source string is converted from the string to be in the format:
+ *
+ *      %[component]/[string]%
+ *
+ * For example:
+ *
+ *      %mod_forum/title%
+ *
+ * Mink replacements are used in xpath translation to translate regularly used items such as title.
+ * Here is an example from the upstream Mink project:
+ *
+ * '%tagTextMatch%' => 'contains(normalize-space(string(.)), %locator%)'
+ *
+ * And can be used in an xpath:
+ *
+ *      .//label[%tagTextMatch%]
+ *
+ * This would be expanded to:
+ *
+ *      .//label[contains(normalize-space(string(.)), %locator%)]
+ *
+ * Replacements can also be used in other replacements, as long as that replacement is defined later.
+ *
+ *      '%linkMatch%' => '(%idMatch% or %tagTextMatch% or %titleMatch% or %relMatch%)'
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_component_named_replacement {
+    /** @var string */
+    protected $from;
+
+    /** @var string */
+    protected $to;
+
+    /**
+     * Create the replacement.
+     *
+     * @param string $from this is the old selector that should no longer be used.
+     *      For example 'group_message'.
+     * @param string $to this is the new equivalent that should be used instead.
+     *      For example 'core_message > Message'.
+     */
+    public function __construct(string $from, string $to) {
+        $this->from = $from;
+        $this->to = $to;
+    }
+
+    /**
+     * Get the 'from' part of the replacement, formatted for the component.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_from(string $component): string {
+        return "%{$component}/{$this->from}%";
+    }
+
+    /**
+     * Get the 'to' part of the replacement.
+     *
+     * @return string Target xpath
+     */
+    public function get_to(): string {
+        return $this->to;
+    }
+}
diff --git a/lib/behat/classes/component_named_selector.php b/lib/behat/classes/component_named_selector.php
new file mode 100644 (file)
index 0000000..8b58484
--- /dev/null
@@ -0,0 +1,124 @@
+<?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/>.
+
+/**
+ * Class representing a named selector that can be used in Behat tests.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Class representing a named selector that can be used in Behat tests.
+ *
+ * Named selectors are what make Behat steps like
+ *   Then I should see "Useful text" in the "General" "fieldset"
+ * Here, "fieldset" is the named selector, and "General" is the locator.
+ *
+ * Selectors can either be exact, in which case the locator needs to
+ * match exactly, or can be partial, for example the way
+ *   When I click "Save" "button"
+ * will trigger a "Save changes" button.
+ *
+ * Instances of this class get returned by the get_exact_named_selectors()
+ * and get_partial_named_selectors() methods in classes like behat_mod_mymod.
+ * The code that makes the magic work is in the trait behat_named_selector
+ * used by both behat_exact_named_selector and behat_partial_named_selector.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class behat_component_named_selector {
+    /** @var string */
+    protected $alias;
+
+    /** @var array List of xpaths */
+    protected $xpaths;
+
+    /** @var string */
+    protected $istextselector;
+
+    /**
+     * Create the selector definition.
+     *
+     * As an example, if you define
+     *   new behat_component_named_selector('Message',
+     *           [".//*[@data-conversation-id]//img[contains(@alt, %locator%)]/.."])
+     * in get_partial_named_selectors in behat_message in
+     * message/tests/behat/behat_message.php, then steps like
+     *   When "Group 1" "core_message > Message" should exist
+     * will work.
+     *
+     * Text selectors are things that contain other things (e.g. some particular text), e.g.
+     *   Then I can see "Some text" in the "Whatever" "text_selector"
+     * whereas non-text selectors are atomic things, like
+     *   When I click the "Whatever" "widget".
+     *
+     * @param string $alias The 'friendly' name of the thing. This will be prefixed with the component name.
+     *      For example, if the mod_mymod plugin, says 'Thingy', then "mod_mymod > Thingy" becomes a selector.
+     * @param array $xpaths A list of xpaths one or more XPaths that the selector gets transformed into.
+     * @param bool $istextselector Whether this selector can also be used as a text selector.
+     */
+    public function __construct(string $alias, array $xpaths, bool $istextselector = true) {
+        $this->alias = $alias;
+        $this->xpaths = $xpaths;
+        $this->istextselector = $istextselector;
+    }
+
+    /**
+     * Whether this is a text selector.
+     *
+     * @return bool
+     */
+    public function is_text_selector(): bool {
+        return $this->istextselector;
+    }
+
+    /**
+     * Get the name of the selector.
+     * This is a back-end feature and contains a namespaced md5 of the human-readable name.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_name(string $component): string {
+        return implode('_', [$component, md5($this->alias)]);
+    }
+
+    /**
+     * Get the alias of the selector.
+     * This is the human-readable name that you would typically interact with.
+     *
+     * @param string $component
+     * @return string
+     */
+    public function get_alias(string $component): string {
+        return implode(" > ", [$component, $this->alias]);;
+    }
+
+    /**
+     * Get the list of combined xpaths.
+     *
+     * @return string The list of xpaths combined with the xpath | (OR) operator
+     */
+    public function get_combined_xpath(): string {
+        return implode(' | ', $this->xpaths);
+    }
+}
index 4902075..e178dae 100644 (file)
@@ -32,6 +32,9 @@
  */
 class behat_exact_named_selector extends \Behat\Mink\Selector\ExactNamedSelector {
 
+    // Use the named selector trait.
+    use behat_named_selector;
+
     /**
      * Creates selector instance.
      */
@@ -63,6 +66,9 @@ class behat_exact_named_selector extends \Behat\Mink\Selector\ExactNamedSelector
         'text_exact' => 'text',
     );
 
+    /** @var List of deprecated selectors */
+    protected static $deprecatedselectors = [];
+
     /**
      * Allowed selectors getter.
      *
diff --git a/lib/behat/classes/named_selector.php b/lib/behat/classes/named_selector.php
new file mode 100644 (file)
index 0000000..e683ffc
--- /dev/null
@@ -0,0 +1,113 @@
+<?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/>.
+
+/**
+ * Moodle-specific common functions for named selectors.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+/**
+ * Common functions for named selectors.
+ *
+ * This has to be a trait, because we need this in both the classes
+ * behat_exact_named_selector and behat_partial_named_selector, and
+ * those classes have to be subclasses of \Behat\Mink\Selector\ExactNamedSelector
+ * and \Behat\Mink\Selector\PartialNamedSelector. This trait is a way achieve
+ * that without duplciated code.
+ *
+ * @package    core
+ * @category   test
+ * @copyright  2019 Andrew Nicols <andrew@nicols.co.uk>
+ * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+trait behat_named_selector {
+
+    /**
+     * Registers new XPath selector with specified name.
+     *
+     * @param string $component
+     * @param behat_component_named_selector $selector
+     */
+    public function register_component_selector(string $component, behat_component_named_selector $selector) {
+        $alias = $selector->get_alias($component);
+        $name = $selector->get_name($component);
+        static::$allowedselectors[$alias] = $name;
+
+        if ($selector->is_text_selector()) {
+            static::$allowedtextselectors[$alias] = $name;
+        }
+
+        // We must use Reflection here. The replacements property is private and cannot be accessed otherwise.
+        // This is due to an API limitation in Mink.
+        $rc = new \ReflectionClass(\Behat\Mink\Selector\NamedSelector::class);
+        $r = $rc->getProperty('replacements');
+        $r->setAccessible(true);
+        $replacements = $r->getValue($this);
+
+        $selectorxpath = strtr($selector->get_combined_xpath(), $replacements);
+
+        parent::registerNamedXpath($name, $selectorxpath);
+    }
+
+    /**
+     * Registers new XPath selector with specified name.
+     *
+     * @param string $component
+     * @param behat_component_named_replacement $replacement
+     */
+    public function register_replacement(string $component, behat_component_named_replacement $replacement) {
+        // We must use Reflection here. The replacements property is private and cannot be accessed otherwise.
+        // This is due to an API limitation in Mink.
+        $rc = new \ReflectionClass(\Behat\Mink\Selector\NamedSelector::class);
+        $r = $rc->getProperty('replacements');
+        $r->setAccessible(true);
+        $existing = $r->getValue($this);
+
+        $from = $replacement->get_from($component);
+
+        if (isset($existing[$from])) {
+            throw new \coding_exception("A named replacement already exists in the partial named selector for '{$from}'.  " .
+                "Replacement names must be unique, and should be namespaced to the component");
+        }
+
+        $translatedto = strtr($replacement->get_to(), $existing);
+        $this->registerReplacement($from, $translatedto);
+    }
+
+    /**
+     * Check whether the specified selector has been deprecated and marked for replacement.
+     *
+     * @param string $selector
+     * @return bool
+     */
+    public static function is_deprecated_selector(string $selector): bool {
+        return array_key_exists($selector, static::$deprecatedselectors);
+    }
+
+    /**
+     * Fetch the replacement name of a deprecated selector.
+     *
+     * @param string $selector
+     * @return bool
+     */
+    public static function get_deprecated_replacement(string $selector): ?string {
+        return static::$deprecatedselectors[$selector];
+    }
+}
index 8503da8..86830de 100644 (file)
@@ -33,6 +33,9 @@
  */
 class behat_partial_named_selector extends \Behat\Mink\Selector\PartialNamedSelector {
 
+    // Use the named selector trait.
+    use behat_named_selector;
+
     /**
      * Creates selector instance.
      */
@@ -271,6 +274,15 @@ XPATH
         ],
     ];
 
+    /** @var List of deprecated selectors */
+    protected static $deprecatedselectors = [
+        'group_message' => 'core_message > Message',
+        'group_message_member' => 'core_message > Message member',
+        'group_message_tab' => 'core_message > Message tab',
+        'group_message_list_area' => 'core_message > Message list area',
+        'group_message_message_content' => 'core_message > Message content',
+    ];
+
     /**
      * Allowed selectors getter.
      *
index 1988e01..5338be1 100644 (file)
@@ -345,6 +345,23 @@ class behat_util extends testing_util {
         return behat_command::get_parent_behat_dir() . '/test_environment_enabled.txt';
     }
 
+    /**
+     * Removes config settings that were added to the main $CFG config within the Behat CLI
+     * run.
+     *
+     * Database storage is already handled by reset_database and existing config values will
+     * be reset automatically by initialise_cfg(), so we only need to remove added ones.
+     */
+    public static function remove_added_config() {
+        global $CFG;
+        if (!empty($CFG->behat_cli_added_config)) {
+            foreach ($CFG->behat_cli_added_config as $key => $value) {
+                unset($CFG->{$key});
+            }
+            unset($CFG->behat_cli_added_config);
+        }
+    }
+
     /**
      * Reset contents of all database tables to initial values, reset caches, etc.
      */
@@ -375,6 +392,7 @@ class behat_util extends testing_util {
 
         // Initialise $CFG with default values. This is needed for behat cli process, so we don't have modified
         // $CFG values from the old run. @see set_config.
+        self::remove_added_config();
         initialise_cfg();
     }
 
index be81f80..5a900b8 100644 (file)
@@ -60,7 +60,9 @@ class checkbox_toggleall implements renderable, templatable {
      *     <ul>
      *         <li><b>id          </b> string - The element ID.</li>
      *         <li><b>name        </b> string - The element name.</li>
-     *         <li><b>classes     </b> string - CSS classes that you want to add for your checkbox.</li>
+     *         <li><b>classes     </b> string - CSS classes that you want to add for your checkbox or toggle controls.
+     *                                          For button type master toggle controls, this could be any Bootstrap 4 btn classes
+     *                                          that you might want to add. Defaults to "btn-secondary".</li>
      *         <li><b>value       </b> string|int - The element's value.</li>
      *         <li><b>checked     </b> boolean - Whether to render this initially as checked.</li>
      *         <li><b>label       </b> string - The label for the checkbox element.</li>
index 71f216e..be9692d 100644 (file)
@@ -321,6 +321,6 @@ function xmldb_main_install() {
     make_default_scale();
     make_competence_scale();
 
-    require_once($CFG->libdir . '/badgeslib.php');
+    require_once($CFG->dirroot . '/badges/upgradelib.php'); // Core install and upgrade related functions only for badges.
     badges_install_default_backpacks();
 }
index 667b6c1..9355360 100644 (file)
@@ -2028,16 +2028,18 @@ function xmldb_main_upgrade($oldversion) {
 
     if ($oldversion < 2018092800.02) {
         // Delete any contacts that are not mutual (meaning they both haven't added each other).
-        $sql = "SELECT c1.id
-                  FROM {message_contacts} c1
-             LEFT JOIN {message_contacts} c2
-                    ON c1.userid = c2.contactid
-                   AND c1.contactid = c2.userid
-                 WHERE c2.id IS NULL";
-        if ($contacts = $DB->get_records_sql($sql)) {
-            list($insql, $inparams) = $DB->get_in_or_equal(array_keys($contacts));
-            $DB->delete_records_select('message_contacts', "id $insql", $inparams);
-        }
+        do {
+            $sql = "SELECT c1.id
+                      FROM {message_contacts} c1
+                 LEFT JOIN {message_contacts} c2
+                        ON c1.userid = c2.contactid
+                       AND c1.contactid = c2.userid
+                     WHERE c2.id IS NULL";
+            if ($contacts = $DB->get_records_sql($sql, null, 0, 1000)) {
+                list($insql, $inparams) = $DB->get_in_or_equal(array_keys($contacts));
+                $DB->delete_records_select('message_contacts', "id $insql", $inparams);
+            }
+        } while ($contacts);
 
         upgrade_main_savepoint(true, 2018092800.02);
     }
@@ -3361,7 +3363,7 @@ function xmldb_main_upgrade($oldversion) {
         }
 
         // Add default backpacks.
-        require_once($CFG->libdir.'/badgeslib.php'); // Core Upgrade-related functions for badges only.
+        require_once($CFG->dirroot . '/badges/upgradelib.php'); // Core install and upgrade related functions only for badges.
         badges_install_default_backpacks();
 
         // Main savepoint reached.
index cb20d06..2d5e675 100644 (file)
@@ -2159,23 +2159,26 @@ function readfile_accel($file, $mimetype, $accelerate) {
         }
     }
 
-    if ($accelerate and !empty($CFG->xsendfile)) {
-        if (empty($CFG->disablebyteserving) and $mimetype !== 'text/plain') {
-            header('Accept-Ranges: bytes');
-        } else {
-            header('Accept-Ranges: none');
-        }
+    if ($accelerate and empty($CFG->disablebyteserving) and $mimetype !== 'text/plain') {
+        header('Accept-Ranges: bytes');
+    } else {
+        header('Accept-Ranges: none');
+    }
 
+    if ($accelerate) {
         if (is_object($file)) {
             $fs = get_file_storage();
-            if ($fs->xsendfile($file->get_contenthash())) {
-                return;
+            if ($fs->supports_xsendfile()) {
+                if ($fs->xsendfile($file->get_contenthash())) {
+                    return;
+                }
             }
-
         } else {
-            require_once("$CFG->libdir/xsendfilelib.php");
-            if (xsendfile($file)) {
-                return;
+            if (!empty($CFG->xsendfile)) {
+                require_once("$CFG->libdir/xsendfilelib.php");
+                if (xsendfile($file)) {
+                    return;
+                }
             }
         }
     }
@@ -2185,7 +2188,6 @@ function readfile_accel($file, $mimetype, $accelerate) {
     header('Last-Modified: '. gmdate('D, d M Y H:i:s', $lastmodified) .' GMT');
 
     if ($accelerate and empty($CFG->disablebyteserving) and $mimetype !== 'text/plain') {
-        header('Accept-Ranges: bytes');
 
         if (!empty($_SERVER['HTTP_RANGE']) and strpos($_SERVER['HTTP_RANGE'],'bytes=') !== FALSE) {
             // byteserving stuff - for acrobat reader and download accelerators
@@ -2223,9 +2225,6 @@ function readfile_accel($file, $mimetype, $accelerate) {
                 byteserving_send_file($handle, $mimetype, $ranges, $filesize);
             }
         }
-    } else {
-        // Do not byteserve
-        header('Accept-Ranges: none');
     }
 
     header('Content-Length: '.$filesize);
index 743beb9..0334499 100644 (file)
@@ -1833,6 +1833,15 @@ class file_storage {
         return $this->filesystem->xsendfile($contenthash);
     }
 
+    /**
+     * Returns true if filesystem is configured to support xsendfile.
+     *
+     * @return bool
+     */
+    public function supports_xsendfile() {
+        return $this->filesystem->supports_xsendfile();
+    }
+
     /**
      * Content exists
      *
index 3f7d6f0..ae1d362 100644 (file)
@@ -443,6 +443,16 @@ abstract class file_system {
         return xsendfile($this->get_remote_path_from_hash($contenthash));
     }
 
+    /**
+     * Returns true if filesystem is configured to support xsendfile.
+     *
+     * @return bool
+     */
+    public function supports_xsendfile() {
+        global $CFG;
+        return !empty($CFG->xsendfile);
+    }
+
     /**
      * Validate that the content hash matches the content hash of the file on disk.
      *
index f1c3f75..fc01b1e 100644 (file)
@@ -1130,4 +1130,47 @@ class stored_file {
     public function compare_to_string($content) {
         return $this->get_contenthash() === file_storage::hash_from_string($content);
     }
+
+    /**
+     * Generate a rotated image for this stored_file based on exif information.
+     *
+     * @return array|false False when a problem occurs, else the image data and image size.
+     * @since Moodle 3.8
+     */
+    public function rotate_image() {
+        $content = $this->get_content();
+        $mimetype = $this->get_mimetype();
+
+        if ($mimetype === "image/jpeg" && function_exists("exif_read_data")) {
+            $exif = @exif_read_data("data://image/jpeg;base64," . base64_encode($content));
+            if (isset($exif['ExifImageWidth']) && isset($exif['ExifImageLength']) && isset($exif['Orientation'])) {
+                $rotation = [
+                    3 => -180,
+                    6 => -90,
+                    8 => -270,
+                ];
+                $orientation = $exif['Orientation'];
+                if ($orientation !== 1) {
+                    $source = @imagecreatefromstring($content);
+                    $data = @imagerotate($source, $rotation[$orientation], 0);
+                    if (!empty($data)) {
+                        if ($orientation == 1 || $orientation == 3) {
+                            $size = [
+                                'width' => $exif["ExifImageWidth"],
+                                'height' => $exif["ExifImageLength"],
+                            ];
+                        } else {
+                            $size = [
+                                'height' => $exif["ExifImageWidth"],
+                                'width' => $exif["ExifImageLength"],
+                            ];
+                        }
+                        imagedestroy($source);
+                        return [$data, $size];
+                    }
+                }
+            }
+        }
+        return [false, false];
+    }
 }
index dc8b0b0..c8341c5 100644 (file)
@@ -1405,6 +1405,14 @@ function set_config($name, $value, $plugin=null) {
                 $config->value = $value;
                 $DB->insert_record('config', $config, false);
             }
+            // When setting config during a Behat test (in the CLI script, not in the web browser
+            // requests), remember which ones are set so that we can clear them later.
+            if (defined('BEHAT_TEST')) {
+                if (!property_exists($CFG, 'behat_cli_added_config')) {
+                    $CFG->behat_cli_added_config = [];
+                }
+                $CFG->behat_cli_added_config[$name] = true;
+            }
         }
         if ($name === 'siteidentifier') {
             cache_helper::update_site_identifier($value);
index e0570e4..10124c0 100644 (file)
@@ -4511,10 +4511,12 @@ EOD;
      * @param int $limit limit the number of tags to display, if size of $tags is more than this limit the "more" link
      *               will be appended to the end, JS will toggle the rest of the tags
      * @param context $pagecontext specify if needed to overwrite the current page context for the view tag link
+     * @param bool $accesshidelabel if true, the label should have class="accesshide" added.
      * @return string
      */
-    public function tag_list($tags, $label = null, $classes = '', $limit = 10, $pagecontext = null) {
-        $list = new \core_tag\output\taglist($tags, $label, $classes, $limit, $pagecontext);
+    public function tag_list($tags, $label = null, $classes = '', $limit = 10,
+            $pagecontext = null, $accesshidelabel = false) {
+        $list = new \core_tag\output\taglist($tags, $label, $classes, $limit, $pagecontext, $accesshidelabel);
         return $this->render_from_template('core_tag/taglist', $list->export_for_template($this));
     }
 
index 8ab9a38..7aee57f 100644 (file)
@@ -1440,11 +1440,25 @@ function question_category_options($contexts, $top = false, $currentcat = 0,
             if ($category->contextid == $contextid) {
                 $cid = $category->id;
                 if ($currentcat != $cid || $currentcat == 0) {
-                    $countstring = !empty($category->questioncount) ?
-                            " ($category->questioncount)" : '';
-                    $categoriesarray[$contextstring][$cid] =
-                            format_string($category->indentedname, true,
-                                array('context' => $context)) . $countstring;
+                    $a = new stdClass;
+                    $a->name = format_string($category->indentedname, true,
+                            array('context' => $context));
+                    if ($category->idnumber !== null && $category->idnumber !== '') {
+                        $a->idnumber = s($category->idnumber);
+                    }
+                    if (!empty($category->questioncount)) {
+                        $a->questioncount = $category->questioncount;
+                    }
+                    if (isset($a->idnumber) && isset($a->questioncount)) {
+                        $formattedname = get_string('categorynamewithidnumberandcount', 'question', $a);
+                    } else if (isset($a->idnumber)) {
+                        $formattedname = get_string('categorynamewithidnumber', 'question', $a);
+                    } else if (isset($a->questioncount)) {
+                        $formattedname = get_string('categorynamewithcount', 'question', $a);
+                    } else {
+                        $formattedname = $a->name;
+                    }
+                    $categoriesarray[$contextstring][$cid] = $formattedname;
                 }
             }
         }
@@ -1875,14 +1889,14 @@ class question_edit_contexts {
     }
 
     /**
-     * @return array all parent contexts
+     * @return context[] all parent contexts
      */
     public function all() {
         return $this->allcontexts;
     }
 
     /**
-     * @return object lowest context which must be either the module or course context
+     * @return context lowest context which must be either the module or course context
      */
     public function lowest() {
         return $this->allcontexts[0];
@@ -1890,7 +1904,7 @@ class question_edit_contexts {
 
     /**
      * @param string $cap capability
-     * @return array parent contexts having capability, zero based index
+     * @return context[] parent contexts having capability, zero based index
      */
     public function having_cap($cap) {
         $contextswithcap = array();
@@ -1904,7 +1918,7 @@ class question_edit_contexts {
 
     /**
      * @param array $caps capabilities
-     * @return array parent contexts having at least one of $caps, zero based index
+     * @return context[] parent contexts having at least one of $caps, zero based index
      */
     public function having_one_cap($caps) {
         $contextswithacap = array();
@@ -1921,14 +1935,14 @@ class question_edit_contexts {
 
     /**
      * @param string $tabname edit tab name
-     * @return array parent contexts having at least one of $caps, zero based index
+     * @return context[] parent contexts having at least one of $caps, zero based index
      */
     public function having_one_edit_tab_cap($tabname) {
         return $this->having_one_cap(self::$caps[$tabname]);
     }
 
     /**
-     * @return those contexts where a user can add a question and then use it.
+     * @return context[] those contexts where a user can add a question and then use it.
      */
     public function having_add_and_use() {
         $contextswithcap = array();
@@ -1993,7 +2007,7 @@ class question_edit_contexts {
     /**
      * Throw error if at least one parent context hasn't got one of the caps $caps
      *
-     * @param array $cap capabilities
+     * @param array $caps capabilities
      */
     public function require_one_cap($caps) {
         if (!$this->have_one_cap($caps)) {
index 2b72e08..81a944a 100644 (file)
@@ -863,9 +863,9 @@ class flexible_table {
      * @return string contents of cell in column 'fullname', for this row.
      */
     function col_fullname($row) {
-        global $COURSE;
+        global $PAGE, $COURSE;
 
-        $name = fullname($row);
+        $name = fullname($row, has_capability('moodle/site:viewfullnames', $PAGE->context));
         if ($this->download) {
             return $name;
         }
index 4648b43..7ebc0d0 100644 (file)
         "togglegroup": "toggle-group",
         "label": "Select everything!",
         "checked": true,
-        "classes": "p-1",
+        "classes": "btn-primary btn-lg",
         "selectall": "Select all",
         "deselectall": "Deselect all"
     }
 }}
-<button type="button" id="{{id}}" name="{{name}}" class="btn btn-secondary {{classes}}"
+<button type="button" id="{{id}}" name="{{name}}" class="btn {{^classes}}btn-secondary{{/classes}}{{#classes}}{{.}}{{/classes}}"
         data-action="toggle"
         data-toggle="master"
         data-togglegroup="{{togglegroup}}"
index 1a514a4..7b6f149 100644 (file)
@@ -33,7 +33,8 @@
         "checked": true,
         "classes": "p-1",
         "selectall": "Select all",
-        "deselectall": "Deselect all"
+        "deselectall": "Deselect all",
+        "labelclasses": "badge badge-info"
     }
 }}
 <input id="{{id}}" name="{{name}}" type="checkbox" {{#classes}}class="{{.}}"{{/classes}} value="{{value}}"
index ac013ad..d68ba8e 100644 (file)
@@ -31,7 +31,8 @@
         "togglegroup": "toggle-group",
         "label": "Select me!",
         "checked": true,
-        "classes": "p-1"
+        "classes": "p-1",
+        "labelclasses": "badge badge-info"
     }
 }}
 <input id="{{id}}" name="{{name}}" type="checkbox" {{#classes}}class="{{.}}"{{/classes}} value="{{value}}"
index 46b3c87..41f6f3d 100644 (file)
@@ -322,6 +322,7 @@ class behat_hooks extends behat_base {
 
         // Register behat selectors for theme, if suite is changed. We do it for every suite change.
         if ($suitename !== self::$runningsuite) {
+            self::$runningsuite = $suitename;
             behat_context_helper::set_environment($scope->getEnvironment());
 
             // We need the Mink session to do it and we do it only before the first scenario.
@@ -342,6 +343,12 @@ class behat_hooks extends behat_base {
 
             $this->getSession()->getSelectorsHandler()->registerSelector('named_partial', new $namedpartialclass());
             $this->getSession()->getSelectorsHandler()->registerSelector('named_exact', new $namedexactclass());
+
+            // Register component named selectors.
+            foreach (\core_component::get_component_names() as $component) {
+                $this->register_component_selectors_for_component($component);
+            }
+
         }
 
         // Reset mink session between the scenarios.
@@ -373,7 +380,6 @@ class behat_hooks extends behat_base {
         // Set the theme if not default.
         if ($suitename !== "default") {
             set_config('theme', $suitename);
-            self::$runningsuite = $suitename;
         }
 
         // Reset the scenariorunning variable to ensure that Step 0 occurs.
@@ -396,7 +402,7 @@ class behat_hooks extends behat_base {
      * Yes, this is in a strange location and should be in the BeforeScenario hook, but failures in the test setUp lead
      * to the test being incorrectly marked as skipped with no way to force the test to be failed.
      *
-     * @param   BeforeStepScope $scope
+     * @param BeforeStepScope $scope
      * @BeforeStep
      */
     public function before_step(BeforeStepScope $scope) {
@@ -425,7 +431,6 @@ class behat_hooks extends behat_base {
                         new ExpectationException($message, $session)
                     );
 
-                self::$initprocessesfinished = true;
             }
             $this->scenariorunning = true;
         }
@@ -709,6 +714,58 @@ class behat_hooks extends behat_base {
     protected static function is_first_scenario() {
         return !(self::$initprocessesfinished);
     }
+
+    /**
+     * Register a set of component selectors.
+     *
+     * @param string $component
+     */
+    public function register_component_selectors_for_component(string $component): void {
+        $componentclassname = "behat_{$component}";
+
+        if (!behat_context_helper::has_context($componentclassname)) {
+            if ("core_" === substr($component, 0, 5)) {
+                $componentclassname = "behat_" . substr($component, 5);
+                if (!behat_context_helper::has_context($componentclassname)) {
+                    return;
+                }
+            } else {
+                return;
+            }
+        }
+
+        $context = behat_context_helper::get($componentclassname);
+        $namedpartial = $this->getSession()->getSelectorsHandler()->getSelector('named_partial');
+        $namedexact = $this->getSession()->getSelectorsHandler()->getSelector('named_exact');
+
+        // Replacements must come before selectors as they are used in the selectors.
+        foreach ($context->get_named_replacements() as $replacement) {
+            $namedpartial->register_replacement($component, $replacement);
+            $namedexact->register_replacement($component, $replacement);
+        }
+
+        foreach ($context->get_partial_named_selectors() as $selector) {
+            $namedpartial->register_component_selector($component, $selector);
+        }
+
+        foreach ($context->get_exact_named_selectors() as $selector) {
+            $namedexact->register_component_selector($component, $selector);
+        }
+
+    }
+
+    /**
+     * Mark the first step as having been completed.
+     *
+     * This must be the last BeforeStep hook in the setup.
+     *
+     * @param BeforeStepScope $scope
+     * @BeforeStep
+     */
+    public function first_step_setup_complete(BeforeStepScope $scope) {
+        self::$initprocessesfinished = true;
+    }
+
 }
 
 /**
index 78c9262..58abfaf 100644 (file)
@@ -533,6 +533,196 @@ class behat_navigation extends behat_base {
         $USER = $globuser;
     }
 
+    /**
+     * Open a given page, belonging to a plugin or core component.
+     *
+     * The page-type are interpreted by each plugin to work out the
+     * corresponding URL. See the resolve_url method in each class like
+     * behat_mod_forum. That method should document which page types are
+     * recognised, and how the name identifies them.
+     *
+     * For pages belonging to core, the 'core > ' bit is omitted.
+     *
+     * @When I am on the :page page
+     * @param string $page the component and page name.
+     *      E.g. 'Admin notifications' or 'core_user > Preferences'.
+     * @throws Exception if the specified page cannot be determined.
+     */
+    public function i_am_on_page(string $page) {
+        $this->getSession()->visit($this->locate_path(
+                $this->resolve_page_helper($page)->out_as_local_url()));
+    }
+
+    /**
+     * Open a given page logged in as a given user.
+     *
+     * This is like the combination
+     *   When I log in as "..."
+     *   And I am on the "..." page
+     * but with the advantage that you go straight to the desired page, without
+     * having to wait for the Dashboard to load.
+     *
+     * @When I am on the :page page logged in as :username
+     * @param string $page the type of page. E.g. 'Admin notifications' or 'core_user > Preferences'.
+     * @param string $username the name of the user to log in as. E.g. 'admin'.
+     * @throws Exception if the specified page cannot be determined.
+     */
+    public function i_am_on_page_logged_in_as(string $page, string $username) {
+        self::execute('behat_auth::i_log_in_as', [$username, $this->resolve_page_helper($page)]);
+    }
+
+    /**
+     * Helper used by i_am_on_page() and i_am_on_page_logged_in_as().
+     *
+     * @param string $page the type of page. E.g. 'Admin notifications' or 'core_user > Preferences'.
+     * @return moodle_url the corresponding URL.
+     */
+    protected function resolve_page_helper(string $page): moodle_url {
+        list($component, $name) = $this->parse_page_name($page);
+        if ($component === 'core') {
+            return $this->resolve_core_page_url($name);
+        } else {
+            $context = behat_context_helper::get('behat_' . $component);
+            return $context->resolve_page_url($name);
+        }
+    }
+
+    /**
+     * Parse a full page name like 'Admin notifications' or 'core_user > Preferences'.
+     *
+     * E.g. parsing 'mod_quiz > View' gives ['mod_quiz', 'View'].
+     *
+     * @param string $page the full page name
+     * @return array with two elements, component and page name.
+     */
+    protected function parse_page_name(string $page): array {
+        $dividercount = substr_count($page, ' > ');
+        if ($dividercount === 0) {
+            return ['core', $page];
+        } else if ($dividercount === 1) {
+            list($component, $name) = explode(' > ', $page);
+            if ($component === 'core') {
+                throw new coding_exception('Do not specify the component "core > ..." for core pages.');
+            }
+            return [$component, $name];
+        } else {
+            throw new coding_exception('The page name most be in the form ' .
+                    '"{page-name}" for core pages, or "{component} > {page-name}" ' .
+                    'for pages belonging to other components. ' .
+                    'For example "Admin notifications" or "mod_quiz > View".');
+        }
+    }
+
+    /**
+     * Open a given instance of a page, belonging to a plugin or core component.
+     *
+     * The instance identifier and page-type are interpreted by each plugin to
+     * work out the corresponding URL. See the resolve_page_instance_url method
+     * in each class like behat_mod_forum. That method should document which page
+     * types are recognised, and how the name identifies them.
+     *
+     * For pages belonging to core, the 'core > ' bit is omitted.
+     *
+     * @When I am on the :identifier :type page
+     * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
+     * @param string $type the component and page type. E.g. 'mod_quiz > View'.
+     * @throws Exception if the specified page cannot be determined.
+     */
+    public function i_am_on_page_instance(string $identifier, string $type) {
+        $this->getSession()->visit($this->locate_path(
+                $this->resolve_page_instance_helper($identifier, $type)->out_as_local_url()));
+    }
+
+    /**
+     * Open a given page logged in as a given user.
+     *
+     * This is like the combination
+     *   When I log in as "..."
+     *   And I am on the "..." "..." page
+     * but with the advantage that you go straight to the desired page, without
+     * having to wait for the Dashboard to load.
+     *
+     * @When I am on the :identifier :type page logged in as :username
+     * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
+     * @param string $type the component and page type. E.g. 'mod_quiz > View'.
+     * @param string $username the name of the user to log in as. E.g. 'student'.
+     * @throws Exception if the specified page cannot be determined.
+     */
+    public function i_am_on_page_instance_logged_in_as(string $identifier,
+            string $type, string $username) {
+        self::execute('behat_auth::i_log_in_as',
+                [$username, $this->resolve_page_instance_helper($identifier, $type)]);
+    }
+
+    /**
+     * Helper used by i_am_on_page() and i_am_on_page_logged_in_as().
+     *
+     * @param string $identifier identifies the particular page. E.g. 'Test quiz'.
+     * @param string $pagetype the component and page type. E.g. 'mod_quiz > View'.
+     * @return moodle_url the corresponding URL.
+     */
+    protected function resolve_page_instance_helper(string $identifier, string $pagetype): moodle_url {
+        list($component, $type) = $this->parse_page_name($pagetype);
+        if ($component === 'core') {
+            return $this->resolve_core_page_instance_url($type, $identifier);
+        } else {
+            $context = behat_context_helper::get('behat_' . $component);
+            return $context->resolve_page_instance_url($type, $identifier);
+        }
+    }
+
+    /**
+     * Convert core page names to URLs for steps like 'When I am on the "[page name]" page'.
+     *
+     * Recognised page names are:
+     * | Homepage            | Homepage (normally dashboard).                                 |
+     * | Admin notifications | Admin notification screen.                                     |
+     *
+     * @param string $name identifies which identifies this page, e.g. 'Homepage', 'Admin notifications'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_core_page_url(string $name): moodle_url {
+        switch ($name) {
+            case 'Homepage':
+                return new moodle_url('/');
+
+            case 'Admin notifications':
+                return new moodle_url('/admin/');
+
+            default:
+                throw new Exception('Unrecognised core page type "' . $name . '."');
+        }
+    }
+
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
+     *
+     * Recognised page names are:
+     * | Page type     | Identifier meaning | description                          |
+     * | Category page | category idnumber  | List of courses in that category.    |
+     *
+     * @param string $type identifies which type of page this is, e.g. 'Category page'.
+     * @param string $identifier identifies the particular page, e.g. 'test-cat'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_core_page_instance_url(string $type, string $identifier): moodle_url {
+        global $DB;
+
+        switch ($type) {
+            case 'Category page':
+                $categoryid = $DB->get_field('course_categories', 'id', ['idnumber' => $identifier]);
+                if (!$categoryid) {
+                    throw new Exception('The specified category with idnumber "' . $identifier . '" does not exist');
+                }
+                return new moodle_url('/course/category.php', ['id' => $categoryid]);
+
+            default:
+                throw new Exception('Unrecognised core page type "' . $type . '."');
+        }
+    }
+
     /**
      * Opens the course homepage.
      *
index 6314669..25bf48a 100644 (file)
@@ -37,7 +37,7 @@ require_once($CFG->libdir . '/tests/fixtures/testable_flexible_table.php');
  * @copyright  2013 Damyon Wiese <damyon@moodle.com>
  * @license    http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
  */
-class core_tablelib_testcase extends basic_testcase {
+class core_tablelib_testcase extends advanced_testcase {
     protected function generate_columns($cols) {
         $columns = array();
         foreach (range(0, $cols - 1) as $j) {
@@ -352,6 +352,67 @@ class core_tablelib_testcase extends basic_testcase {
         );
     }
 
+    /**
+     * Data provider for test_fullname_column
+     *
+     * @return array
+     */
+    public function fullname_column_provider() {
+        return [
+            ['language'],
+            ['alternatename lastname'],
+            ['firstname lastnamephonetic'],
+        ];
+    }
+
+    /**
+     * Test fullname column observes configured alternate fullname format configuration
+     *
+     * @param string $format
+     * @return void
+     *
+     * @dataProvider fullname_column_provider
+     */
+    public function test_fullname_column(string $format) {
+        $this->resetAfterTest();
+        $this->setAdminUser();
+
+        set_config('alternativefullnameformat', $format);
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $table = $this->create_and_setup_table(['fullname'], [], true, false, [], []);
+        $this->assertContains(fullname($user, true), $table->format_row($user)['fullname']);
+    }
+
+    /**
+     * Test fullname column ignores fullname format configuration for a user with viewfullnames capability prohibited
+     *
+     * @param string $format
+     * @return void
+     *
+     * @dataProvider fullname_column_provider
+     */
+    public function test_fullname_column_prohibit_viewfullnames(string $format) {
+        global $DB, $CFG;
+
+        $this->resetAfterTest();
+
+        set_config('alternativefullnameformat', $format);
+
+        $currentuser = $this->getDataGenerator()->create_user();
+        $this->setUser($currentuser);
+
+        // Prohibit the viewfullnames from the default user role.
+        $userrole = $DB->get_record('role', ['id' => $CFG->defaultuserroleid]);
+        role_change_permission($userrole->id, context_system::instance(), 'moodle/site:viewfullnames', CAP_PROHIBIT);
+
+        $user = $this->getDataGenerator()->create_user();
+
+        $table = $this->create_and_setup_table(['fullname'], [], true, false, [], []);
+        $this->assertContains(fullname($user, false), $table->format_row($user)['fullname']);
+    }
+
     public function test_get_row_html() {
         $data = $this->generate_data(1, 5);
         $columns = $this->generate_columns(5);
index 88d411a..2f785c6 100644 (file)
@@ -2,7 +2,7 @@ This files describes API changes in core libraries and APIs,
 information provided here is intended especially for developers.
 
 === 3.8 ===
-
+* The rotate_image function has been added to the stored_file class (MDL-63349)
 * The yui checknet module is removed. Call \core\session\manager::keepalive instead.
 * The generate_uuid() function has been deprecated. Please use \core\uuid::generate() instead.
 * Remove lib/pear/auth/RADIUS.php (MDL-65746)
@@ -22,6 +22,8 @@ information provided here is intended especially for developers.
     at least a single checkbox item is selected or not.
 * Final deprecation (removal) of the core/modal_confirm dialogue.
 * Upgrade scssphp to v1.0.2, This involves renaming classes from Leafo => ScssPhp as the repo has changed.
+* Implement supports_xsendfile() method and allow support for xsendfile in alternative_file_system_class
+  independently of local files (MDL-66304).
 * The methods get_local_path_from_storedfile and get_remote_path_from_storedfile in lib/filestore/file_system.php
   are now public. If you are overriding these then you will need to change your methods to public in your class.
 * $CFG->httpswwwroot has been removed. It is no longer necessary as loginhttps has already been removed and it's no longer being
index a04970a..bbf0e70 100644 (file)
@@ -30,10 +30,21 @@ require_once('lib.php');
 redirect_if_major_upgrade_required();
 
 $testsession = optional_param('testsession', 0, PARAM_INT); // test session works properly
-$anchor      = optional_param('anchor', '', PARAM_RAW);      // Used to restore hash anchor to wantsurl.
+$anchor      = optional_param('anchor', '', PARAM_RAW);     // Used to restore hash anchor to wantsurl.
 
 $resendconfirmemail = optional_param('resendconfirmemail', false, PARAM_BOOL);
 
+// It might be safe to do this for non-Behat sites, or there might
+// be a security risk. For now we only allow it on Behat sites.
+// If you wants to do the analysis, you may be able to remove the
+// if (BEHAT_SITE_RUNNING).
+if (defined('BEHAT_SITE_RUNNING') && BEHAT_SITE_RUNNING) {
+    $wantsurl    = optional_param('wantsurl', '', PARAM_LOCALURL);   // Overrides $SESSION->wantsurl if given.
+    if ($wantsurl !== '') {
+        $SESSION->wantsurl = (new moodle_url($wantsurl))->out(false);
+    }
+}
+
 $context = context_system::instance();
 $PAGE->set_url("$CFG->wwwroot/login/index.php");
 $PAGE->set_context($context);
index cd8bc43..28731d9 100644 (file)
@@ -37,6 +37,69 @@ require_once(__DIR__ . '/../../../lib/behat/behat_base.php');
  */
 class behat_message extends behat_base {
 
+    /**
+     * Return the list of partial named selectors.
+     *
+     * @return array
+     */
+    public static function get_partial_named_selectors(): array {
+        return [
+            new behat_component_named_selector('Message', [".//*[@data-conversation-id]//img[%altMatch%]/.."]),
+            new behat_component_named_selector('Message conversation', [
+                <<<XPATH
+    .//*[@data-region='message-drawer' and contains(., %locator%)]//div[@data-region='content-message-container']
+XPATH
+            ], false),
+            new behat_component_named_selector('Message header', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='header-content' and contains(., %locator%)]
+XPATH
+            ]),
+            new behat_component_named_selector('Message member', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='group-info-content-container']
+    //div[@class='list-group' and not(contains(@class, 'hidden'))]//*[%core_message/textMatch%]
+XPATH
+                , <<<XPATH
+    .//*[@data-region='message-drawer']//div[@data-region='group-info-content-container']
+    //div[@data-region='empty-message-container' and not(contains(@class, 'hidden')) and contains(., %locator%)]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message tab', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//button[@data-toggle='collapse' and contains(string(), %locator%)]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message list area', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//*[contains(@data-region, concat('view-overview-', %locator%))]
+XPATH
+            ], false),
+            new behat_component_named_selector('Message content', [
+                <<<XPATH
+    .//*[@data-region='message-drawer']//*[@data-region='message' and @data-message-id and contains(., %locator%)]
+XPATH
+            ], false),
+        ];
+    }
+
+    /**
+     * Return a list of the Mink named replacements for the component.
+     *
+     * Named replacements allow you to define parts of an xpath that can be reused multiple times, or in multiple
+     * xpaths.
+     *
+     * This method should return a list of {@link behat_component_named_replacement} and the docs on that class explain
+     * how it works.
+     *
+     * @return behat_component_named_replacement[]
+     */
+    public static function get_named_replacements(): array {
+        return [
+            new behat_component_named_replacement('textMatch', 'text()[contains(., %locator%)]'),
+        ];
+    }
+
     /**
      * Open the messaging UI.
      *
@@ -57,7 +120,7 @@ class behat_message extends behat_base {
     public function i_open_the_conversations_list(string $tab) {
         $this->execute('behat_general::i_click_on', [
             $this->escape($tab),
-            'group_message_tab'
+            'core_message > Message tab'
         ]);
     }
 
@@ -213,7 +276,7 @@ class behat_message extends behat_base {
         $this->execute('behat_general::i_click_on',
             array(
                 $this->escape($conversationname),
-                'group_message',
+                'core_message > Message',
             )
         );
     }
index 9de5bc3..1504d8e 100644 (file)
@@ -40,15 +40,15 @@ Feature: Delete messages from conversations
   Scenario: Delete a message sent by the user from a group conversation
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "How are you?" "group_message_message_content"
-    And I click on "Can somebody help me?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "How are you?" "core_message > Message content"
+    And I click on "Can somebody help me?" "core_message > Message content"
     And I should see "3" in the "[data-region='message-selected-court']" "css_element"
 #   Clicking to unselect
-    And I click on "How are you?" "group_message_message_content"
-    And I click on "Can somebody help me?" "group_message_message_content"
+    And I click on "How are you?" "core_message > Message content"
+    And I click on "Can somebody help me?" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -57,19 +57,19 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
-    And I should see "How are you?" in the "Group 1" "group_message_conversation"
-    And I should see "Can somebody help me?" in the "Group 1" "group_message_conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
+    And I should see "How are you?" in the "Group 1" "core_message > Message conversation"
+    And I should see "Can somebody help me?" in the "Group 1" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a group conversation; one sent by another user.
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "How are you?" "group_message_message_content"
+    And I click on "How are you?" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -78,9 +78,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
-    And I should not see "How are you?" in the "Group 1" "group_message_conversation"
-    And I should see "Can somebody help me?" in the "Group 1" "group_message_conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
+    And I should not see "How are you?" in the "Group 1" "core_message > Message conversation"
+    And I should see "Can somebody help me?" in the "Group 1" "core_message > Message conversation"
     And I should not see "Messages selected"
 #   Check messages were not deleted for other users
     And I log out
@@ -94,10 +94,10 @@ Feature: Delete messages from conversations
   Scenario: Cancel deleting two messages from a group conversation
     Given I log in as "student1"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "How are you?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "How are you?" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -105,7 +105,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "How are you?" in the "Group 1" "group_message_conversation"
+    And I should see "How are you?" in the "Group 1" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Delete a message sent by the user from a private conversation
@@ -115,7 +115,7 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -124,9 +124,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a private conversation; one sent by another user
@@ -136,9 +136,9 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hello!" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -147,9 +147,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should not see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should not see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 #   Check messages were not deleted for the other user
     And I log out
@@ -168,8 +168,8 @@ Feature: Delete messages from conversations
     And I open the "Private" conversations list
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -177,7 +177,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Delete a message sent by the user from a favorite conversation
@@ -188,7 +188,7 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -197,8 +197,8 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Delete two messages from a favourite conversation; one sent by another user
@@ -209,9 +209,9 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
     And I should see "1" in the "[data-region='message-selected-court']" "css_element"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hello!" "core_message > Message content"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
@@ -220,9 +220,9 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='confirm-delete-selected-messages']" "xpath_element"
     Then I should not see "Delete"
     And I should not see "Hi!"
-    And I should not see "Hello!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
-    And I should see "Are you free?" in the "Student 2" "group_message_conversation"
+    And I should not see "Hello!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
+    And I should see "Are you free?" in the "Student 2" "core_message > Message conversation"
     And I should not see "Messages selected"
 
   Scenario: Cancel deleting two messages from a favourite conversation
@@ -233,8 +233,8 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in messaging
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
 #   Canceling deletion, so messages should be there
@@ -242,7 +242,7 @@ Feature: Delete messages from conversations
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     Then I should not see "Cancel"
     And I should see "Hi!"
-    And I should see "Hello!" in the "Student 2" "group_message_conversation"
+    And I should see "Hello!" in the "Student 2" "core_message > Message conversation"
     And I should see "2" in the "[data-region='message-selected-court']" "css_element"
 
   Scenario: Check an empty favourite conversation is still favourite
@@ -253,9 +253,9 @@ Feature: Delete messages from conversations
     And I open messaging
     And I should see "Student 2"
     And I select "Student 2" conversation in the "favourites" conversations list
-    And I click on "Hi!" "group_message_message_content"
-    And I click on "Hello!" "group_message_message_content"
-    And I click on "Are you free?" "group_message_message_content"
+    And I click on "Hi!" "core_message > Message content"
+    And I click on "Hello!" "core_message > Message content"
+    And I click on "Are you free?" "core_message > Message content"
     And "Delete selected messages" "button" should exist
     When I click on "Delete selected messages" "button"
     And I should see "Delete"
index 842b55e..51ca228 100644 (file)
@@ -31,35 +31,35 @@ Feature: Star and unstar conversations
     Given I log in as "student1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Group 1" in the "favourites" "group_message_list_area"
+    And I should see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Group" conversations list
-    And I should not see "Group 1" in the "group-messages" "group_message_list_area"
+    And I should not see "Group 1" in the "group-messages" "core_message > Message list area"
 
   Scenario: Unstar a group conversation
     Given I log in as "student1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Group 1" in the "favourites" "group_message_list_area"
+    And I should see "Group 1" in the "favourites" "core_message > Message list area"
     And I select "Group 1" conversation in messaging
     And I open contact menu
     And I click on "Unstar" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Group 1" in the "favourites" "group_message_list_area"
+    And I should not see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Group" conversations list
-    And I should see "Group 1" in the "group-messages" "group_message_list_area"
+    And I should see "Group 1" in the "group-messages" "core_message > Message list area"
 
   Scenario: Star a private conversation
     Given the following "private messages" exist:
@@ -68,15 +68,15 @@ Feature: Star and unstar conversations
     Then I log in as "student1"
     And I open messaging
     And I open the "Private" conversations list
-    And "Student 2" "group_message" should exist
+    And "Student 2" "core_message > Message" should exist
     And I select "Student 2" conversation in messaging
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should not see "Student 2" in the "messages" "group_message_list_area"
+    And I should not see "Student 2" in the "messages" "core_message > Message list area"
 
   Scenario: Unstar a private conversation
     Given the following "private messages" exist:
@@ -87,12 +87,12 @@ Feature: Star and unstar conversations
       | student1 | student2 |
     Then I log in as "student1"
     And I open messaging
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
     And I select "Student 2" conversation in messaging
     And I open contact menu
     And I click on "Unstar" "link" in the "//div[@data-region='header-container']" "xpath_element"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Group 1" in the "favourites" "group_message_list_area"
+    And I should not see "Group 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should see "Student 2" in the "messages" "group_message_list_area"
\ No newline at end of file
+    And I should see "Student 2" in the "messages" "core_message > Message list area"
index e990727..bde9a02 100644 (file)
@@ -47,26 +47,26 @@ Feature: Create conversations for course's groups
     Given I log in as "teacher1"
     Then I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
-    And "Group 2" "group_message" should exist
-    And "Group 3" "group_message" should not exist
+    And "Group 1" "core_message > Message" should exist
+    And "Group 2" "core_message > Message" should exist
+    And "Group 3" "core_message > Message" should not exist
     And I log out
     And I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
-    And "Group 2" "group_message" should not exist
-    And "Group 3" "group_message" should not exist
+    And "Group 1" "core_message > Message" should exist
+    And "Group 2" "core_message > Message" should not exist
+    And "Group 3" "core_message > Message" should not exist
 
   Scenario: View group conversation's participants numbers
     Given I log in as "teacher1"
     Then I open messaging
     And I open the "Group" conversations list
     And I select "Group 1" conversation in messaging
-    And I should see "5 participants" in the "Group 1" "group_message_header"
+    And I should see "5 participants" in the "Group 1" "core_message > Message header"
     And I go back in "view-conversation" message drawer
     And I select "Group 2" conversation in messaging
-    And I should see "1 participants" in the "Group 2" "group_message_header"
+    And I should see "1 participants" in the "Group 2" "core_message > Message header"
 
   Scenario: View group conversation's participants list
     Given I log in as "teacher1"
@@ -75,20 +75,20 @@ Feature: Create conversations for course's groups
     # Check Group 1 participants list.
     And I select "Group 1" conversation in messaging
     And I open messaging information
-    And "Teacher 1" "group_message_member" should not exist
-    And "Student 0" "group_message_member" should exist
-    And "Student 1" "group_message_member" should exist
-    And "Student 2" "group_message_member" should exist
-    And "Student 3" "group_message_member" should exist
-    And "Student 4" "group_message_member" should not exist
+    And "Teacher 1" "core_message > Message member" should not exist
+    And "Student 0" "core_message > Message member" should exist
+    And "Student 1" "core_message > Message member" should exist
+    And "Student 2" "core_message > Message member" should exist
+    And "Student 3" "core_message > Message member" should exist
+    And "Student 4" "core_message > Message member" should not exist
     And I go back in "group-info-content-container" message drawer
     And I go back in "view-conversation" message drawer
     # Check Group 2 participants list.
     And I select "Group 2" conversation in messaging
     And I open messaging information
-    And "Teacher 1" "group_message_member" should not exist
-    And "No participants" "group_message_member" should exist
-    And "Student 4" "group_message_member" should not exist
+    And "Teacher 1" "core_message > Message member" should not exist
+    And "No participants" "core_message > Message member" should exist
+    And "Student 4" "core_message > Message member" should not exist
 
   Scenario: Check group conversation members are synced when a new group member is added
     Given I log in as "teacher1"
@@ -99,13 +99,13 @@ Feature: Create conversations for course's groups
     And I open messaging
     And I open the "Group" conversations list
     And I select "Group 1" conversation in messaging
-    And I should see "6 participants" in the "Group 1" "group_message_header"
+    And I should see "6 participants" in the "Group 1" "core_message > Message header"
     And I open messaging information
-    And "Student 4" "group_message_member" should exist
+    And "Student 4" "core_message > Message member" should exist
     And I go back in "group-info-content-container" message drawer
     And I go back in "view-conversation" message drawer
     And I select "Group 2" conversation in messaging
-    And I should see "2 participants" in the "Group 2" "group_message_header"
+    And I should see "2 participants" in the "Group 2" "core_message > Message header"
     And I open messaging information
-    And "No participants" "group_message_member" should not exist
-    And "Student 4" "group_message_member" should exist
+    And "No participants" "core_message > Message member" should not exist
+    And "Student 4" "core_message > Message member" should exist
index 183c07a..5d7d65a 100644 (file)
@@ -35,17 +35,17 @@ Feature: Message delete conversations
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should not see "What do you need?" in the "Student 1" "group_message_conversation"
-    And I should not see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should not see "What do you need?" in the "Student 1" "core_message > Message conversation"
+    And I should not see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 #   Check user is deleting private conversation only for them
     And I log out
     And I log in as "student1"
     And I open messaging
     And I select "Student 2" conversation in the "messages" conversations list
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "What do you need?" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "What do you need?" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Cancel deleting a private conversation
     Given I log in as "student1"
@@ -57,8 +57,8 @@ Feature: Message delete conversations
     And I should see "Cancel"
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     And I should not see "Cancel"
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Delete a starred conversation
     Given the following "favourite conversations" exist:
@@ -73,17 +73,17 @@ Feature: Message delete conversations
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should not see "What do you need?" in the "Student 2" "group_message_conversation"
-    And I should not see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should not see "What do you need?" in the "Student 2" "core_message > Message conversation"
+    And I should not see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 #   Check user is deleting private conversation only for them
     And I log out
     And I log in as "student2"
     And I open messaging
     And I select "Student 1" conversation in the "messages" conversations list
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "What do you need?" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "What do you need?" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 
   Scenario: Cancel deleting a starred conversation
     Given the following "favourite conversations" exist:
@@ -92,16 +92,16 @@ Feature: Message delete conversations
     When I log in as "student1"
     And I open messaging
     And I select "Student 2" conversation in the "favourites" conversations list
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
     And I open contact menu
     And I click on "Delete conversation" "link" in the "//div[@data-region='header-container']" "xpath_element"
 #   Cancel deletion, so conversation should be there
     And I should see "Cancel"
     And I click on "//button[@data-action='cancel-confirm']" "xpath_element"
     And I should not see "Cancel"
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Check a deleted starred conversation is still starred
     Given the following "favourite conversations" exist:
@@ -115,10 +115,10 @@ Feature: Message delete conversations
     Then I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
-    And I should not see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should not see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I go back in "view-conversation" message drawer
-    And I should not see "Student 2" in the "favourites" "group_message_list_area"
+    And I should not see "Student 2" in the "favourites" "core_message > Message list area"
     And I send "Hi!" message to "Student 2" user
     And I go back in "view-conversation" message drawer
     And I go back in "view-search" message drawer
-    And I should see "Student 2" in the "favourites" "group_message_list_area"
+    And I should see "Student 2" in the "favourites" "core_message > Message list area"
index 45f1a93..519cee1 100644 (file)
@@ -31,23 +31,23 @@ Feature: Message send messages
     Given I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
     When I send "Hi!" message in the message area
-    Then I should see "Hi!" in the "Group 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
+    Then I should see "Hi!" in the "Group 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I open messaging
-    And "Group 1" "group_message" should exist
+    And "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in messaging
-    And I should see "Hi!" in the "Group 1" "group_message_conversation"
+    And I should see "Hi!" in the "Group 1" "core_message > Message conversation"
 
   Scenario: Send a message to a starred conversation
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
+    Then "Group 1" "core_message > Message" should exist
     And I select "Group 1" conversation in the "group-messages" conversations list
     And I open contact menu
     And I click on "Star" "link" in the "//div[@data-region='header-container']" "xpath_element"
@@ -56,11 +56,11 @@ Feature: Message send messages
     And I should see "Group 1"
     And I select "Group 1" conversation in the "favourites" conversations list
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Group 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Group 1" "group_message_conversation"
+    And I should see "Hi!" in the "Group 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Group 1" "core_message > Message conversation"
     And I go back in "view-conversation" message drawer
     And I open the "Group" conversations list
-    And I should not see "Group 1" in the "Group" "group_message_tab"
+    And I should not see "Group 1" in the "Group" "core_message > Message tab"
 
   Scenario: Send a message to a private conversation via contact tab
     Given the following "message contacts" exist:
@@ -71,17 +71,17 @@ Feature: Message send messages
     And I click on "Contacts" "link"
     And I click on "Student 2" "link" in the "//*[@data-section='contacts']" "xpath_element"
     When I send "Hi!" message in the message area
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
 
   Scenario: Try to send a message to a private conversation is not contact but you are allowed to send a message
     Given I log in as "student1"
     And I open messaging
     When I send "Hi!" message to "Student 2" user
-    Then I should see "Hi!" in the "Student 2" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 2" "group_message_conversation"
+    Then I should see "Hi!" in the "Student 2" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 2" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I open messaging
     And I select "Student 1" conversation in messaging
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
\ No newline at end of file
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
index 4a40e60..ca30488 100644 (file)
@@ -33,15 +33,15 @@ Feature: Mute and unmute conversations
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
-    And "muted" "icon_container" in the "Group 1" "group_message" should not be visible
+    Then "Group 1" "core_message > Message" should exist
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should not be visible
     And I select "Group 1" conversation in messaging
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should not be visible
     And I open contact menu
     And I click on "Mute" "link" in the "[data-region='header-container']" "css_element"
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Group 1" "group_message" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should be visible
 
   Scenario: Mute a private conversation
     When I log in as "student1"
@@ -49,14 +49,14 @@ Feature: Mute and unmute conversations
     Then I should see "Private"
     And I open the "Private" conversations list
     And I should see "Student 2"
-    And "muted" "icon_container" in the "Student 2" "group_message" should not be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should not be visible
     And I select "Student 2" conversation in messaging
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should not be visible
     And I open contact menu
     And I click on "Mute" "link" in the "[data-region='header-container']" "css_element"
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Student 2" "group_message" should be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should be visible
 
   Scenario: Unmute a group conversation
     Given the following "muted group conversations" exist:
@@ -65,15 +65,15 @@ Feature: Mute and unmute conversations
     When I log in as "student1"
     And I open messaging
     And I open the "Group" conversations list
-    Then "Group 1" "group_message" should exist
-    And "muted" "icon_container" in the "Group 1" "group_message" should be visible
+    Then "Group 1" "core_message > Message" should exist
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should be visible
     And I select "Group 1" conversation in messaging
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should be visible
     And I open contact menu
     And I click on "Unmute" "link" in the "[data-region='header-container']" "css_element"
-    And "muted" "icon_container" in the "Group 1" "group_message_header" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message header" should not be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Group 1" "group_message" should not be visible
+    And "muted" "icon_container" in the "Group 1" "core_message > Message" should not be visible
 
   Scenario: Unmute a private conversation
     Given the following "muted private conversations" exist:
@@ -84,11 +84,11 @@ Feature: Mute and unmute conversations
     Then I should see "Private"
     And I open the "Private" conversations list
     And I should see "Student 2"
-    And "muted" "icon_container" in the "Student 2" "group_message" should be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should be visible
     And I select "Student 2" conversation in messaging
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should be visible
     And I open contact menu
     And I click on "Unmute" "link" in the "[data-region='header-container']" "css_element"
     And "muted" "icon_container" in the "[data-action='view-contact']" "css_element" should not be visible
     And I go back in "view-conversation" message drawer
-    And "muted" "icon_container" in the "Student 2" "group_message" should not be visible
+    And "muted" "icon_container" in the "Student 2" "core_message > Message" should not be visible
index 5063b3c..ea7a669 100644 (file)
@@ -15,47 +15,47 @@ Feature: Self conversation
   Scenario: Self conversation exists
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student" conversation in messaging
     And I should see "Personal space"
 
   Scenario: Self conversation can be unstarred
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student" conversation in messaging
     And I open contact menu
-    And I click on "Unstar" "link" in the "Student 1" "group_message_header"
+    And I click on "Unstar" "link" in the "Student 1" "core_message > Message header"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Student 1" in the "favourites" "group_message_list_area"
+    And I should not see "Student 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should see "Student 1" in the "messages" "group_message_list_area"
+    And I should see "Student 1" in the "messages" "core_message > Message list area"
 
   Scenario: Self conversation can be deleted
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student 1" conversation in messaging
     And I open contact menu
-    And I click on "Delete conversation" "link" in the "Student 1" "group_message_header"
+    And I click on "Delete conversation" "link" in the "Student 1" "core_message > Message header"
     And I should see "Delete"
     And I click on "//button[@data-action='confirm-delete-conversation']" "xpath_element"
     And I should not see "Delete"
     And I go back in "view-conversation" message drawer
     And I open the "Starred" conversations list
-    And I should not see "Student 1" in the "favourites" "group_message_list_area"
+    And I should not see "Student 1" in the "favourites" "core_message > Message list area"
     And I open the "Private" conversations list
-    And I should not see "Student 1" in the "messages" "group_message_list_area"
+    And I should not see "Student 1" in the "messages" "core_message > Message list area"
 
   Scenario: Send a message to a self-conversation via message drawer
     Given I log in as "student1"
     When I open messaging
-    Then "Student 1" "group_message" should exist
+    Then "Student 1" "core_message > Message" should exist
     And I select "Student 1" conversation in messaging
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
 
   Scenario: Send a message to a self-conversation via user profile
     Given I log in as "student1"
@@ -63,5 +63,5 @@ Feature: Self conversation
     Then I should see "Message"
     And I click on "Message" "icon"
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "Student 1" "group_message_conversation"
-    And I should see "##today##j F##" in the "Student 1" "group_message_conversation"
+    And I should see "Hi!" in the "Student 1" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "Student 1" "core_message > Message conversation"
index 97f5ffa..bf6095e 100644 (file)
@@ -31,23 +31,23 @@ Feature: Unread messages
     Given I log in as "student1"
     When I open messaging
     And I open the "Group" conversations list
-    Then "New group" "group_message" should exist
+    Then "New group" "core_message > Message" should exist
     And I select "New group" conversation in messaging
     And I send "Hi!" message in the message area
-    And I should see "Hi!" in the "New group" "group_message_conversation"
-    And I should see "##today##j F##" in the "New group" "group_message_conversation"
+    And I should see "Hi!" in the "New group" "core_message > Message conversation"
+    And I should see "##today##j F##" in the "New group" "core_message > Message conversation"
     And I log out
     And I log in as "student2"
     And I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Group" "group_message_tab"
-    And "New group" "group_message" should exist
-    And I should see "1" in the "New group" "group_message"
+    And I should see "1" in the "Group" "core_message > Message tab"
+    And "New group" "core_message > Message" should exist
+    And I should see "1" in the "New group" "core_message > Message"
     And I select "New group" conversation in messaging
-    And I should see "Hi!" in the "New group" "group_message_conversation"
+    And I should see "Hi!" in the "New group" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Group" "group_message_tab"
-    And I should not see "1" in the "New group" "group_message"
+    And I should not see "1" in the "Group" "core_message > Message tab"
+    And I should not see "1" in the "New group" "core_message > Message"
 
   Scenario: Unread messages for private conversation
     Given the following "private messages" exist:
@@ -57,14 +57,14 @@ Feature: Unread messages
     When I log in as "student1"
     Then I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Private" "group_message_tab"
-    And "Student 2" "group_message" should exist
-    And I should see "1" in the "Student 2" "group_message"
+    And I should see "1" in the "Private" "core_message > Message tab"
+    And "Student 2" "core_message > Message" should exist
+    And I should see "1" in the "Student 2" "core_message > Message"
     And I select "Student 2" conversation in messaging
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Private" "group_message_tab"
-    And I should not see "1" in the "Student 2" "group_message"
+    And I should not see "1" in the "Private" "core_message > Message tab"
+    And I should not see "1" in the "Student 2" "core_message > Message"
 
   Scenario: Unread messages for starred conversation
     Given the following "private messages" exist:
@@ -77,11 +77,11 @@ Feature: Unread messages
     When I log in as "student1"
     Then I should see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
     And I open messaging
-    And I should see "1" in the "Starred" "group_message_tab"
-    And "Student 2" "group_message" should exist
-    And I should see "1" in the "Student 2" "group_message"
+    And I should see "1" in the "Starred" "core_message > Message tab"
+    And "Student 2" "core_message > Message" should exist
+    And I should see "1" in the "Student 2" "core_message > Message"
     And I select "Student 2" conversation in messaging
-    And I should see "Hi!" in the "Student 2" "group_message_conversation"
+    And I should see "Hi!" in the "Student 2" "core_message > Message conversation"
     And I should not see "1" in the "//*[@title='Toggle messaging drawer']/../*[@data-region='count-container']" "xpath_element"
-    And I should not see "1" in the "Starred" "group_message_tab"
-    And I should not see "1" in the "Student 2" "group_message"
+    And I should not see "1" in the "Starred" "core_message > Message tab"
+    And I should not see "1" in the "Student 2" "core_message > Message"
index 6060236..9c02a10 100644 (file)
@@ -56,6 +56,10 @@ class document_services {
     const STAMPS_FILEAREA = 'stamps';
     /** Filename for combined pdf */
     const COMBINED_PDF_FILENAME = 'combined.pdf';
+    /**  Temporary place to save JPG Image to PDF file */
+    const TMP_JPG_TO_PDF_FILEAREA = 'tmp_jpg_to_pdf';
+    /**  Temporary place to save (Automatically) Rotated JPG FILE */
+    const TMP_ROTATED_JPG_FILEAREA = 'tmp_rotated_jpg';
     /** Hash of blank pdf */
     const BLANK_PDF_HASH = '4c803c92c71f21b423d13de570c8a09e0a31c718';
 
@@ -187,9 +191,28 @@ EOD;
                 $pluginfiles = $plugin->get_files($submission, $user);
                 foreach ($pluginfiles as $filename => $file) {
                     if ($file instanceof \stored_file) {
-                        if ($file->get_mimetype() === 'application/pdf') {
+                        $mimetype = $file->get_mimetype();
+                        // PDF File, no conversion required.
+                        if ($mimetype === 'application/pdf') {
                             $files[$filename] = $file;
-                        } else if ($convertedfile = $converter->start_conversion($file, 'pdf')) {
+                        } else if ($plugin->allow_image_conversion() && $mimetype === "image/jpeg") {
+                            // Rotates image based on the EXIF value.
+                            list ($rotateddata, $size) = $file->rotate_image();
+                            if ($rotateddata) {
+                                $file = self::save_rotated_image_file($assignment, $userid, $attemptnumber,
+                                    $rotateddata, $filename);
+                            }
+                            // Save as PDF file if there is no available converter.
+                            if (!$converter->can_convert_format_to('jpg', 'pdf')) {
+                                $pdffile = self::save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size);
+                                if ($pdffile) {
+                                    $files[$filename] = $pdffile;
+                                }
+                            }
+                        }
+                        // The file has not been converted to PDF, try to convert it to PDF.
+                        if (!isset($files[$filename])
+                            && $convertedfile = $converter->start_conversion($file, 'pdf')) {
                             $files[$filename] = $convertedfile;
                         }
                     } else if ($converter->can_convert_format_to('html', 'pdf')) {
@@ -967,4 +990,83 @@ EOD;
         }
         return null;
     }
+
+    /**
+     * Convert jpg file to pdf file
+     * @param int|\assign $assignment Assignment
+     * @param int $userid User ID
+     * @param int $attemptnumber Attempt Number
+     * @param \stored_file $file file to save
+     * @param null|array $size size of image
+     * @return \stored_file
+     * @throws \file_exception
+     * @throws \stored_file_creation_exception
+     */
+    private static function save_jpg_to_pdf($assignment, $userid, $attemptnumber, $file, $size=null) {
+        // Temporary file.
+        $filename = $file->get_filename();
+        $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
+            . self::TMP_JPG_TO_PDF_FILEAREA . DIRECTORY_SEPARATOR
+            . self::hash($assignment, $userid, $attemptnumber));
+        $tempfile = $tmpdir . DIRECTORY_SEPARATOR . $filename . ".pdf";
+        // Determine orientation.
+        $orientation = 'P';
+        if (!empty($size['width']) && !empty($size['height'])) {
+            if ($size['width'] > $size['height']) {
+                $orientation = 'L';
+            }
+        }
+        // Save JPG image to PDF file.
+        $pdf = new pdf();
+        $pdf->SetHeaderMargin(0);
+        $pdf->SetFooterMargin(0);
+        $pdf->SetMargins(0, 0, 0, true);
+        $pdf->setPrintFooter(false);
+        $pdf->setPrintHeader(false);
+        $pdf->setImageScale(PDF_IMAGE_SCALE_RATIO);
+        $pdf->AddPage($orientation);
+        $pdf->SetAutoPageBreak(false);
+        // Width has to be define here to fit into A4 page. Otherwise the image will be inserted with original size.
+        if ($orientation == 'P') {
+            $pdf->Image('@' . $file->get_content(), 0, 0, 210);
+        } else {
+            $pdf->Image('@' . $file->get_content(), 0, 0, 297);
+        }
+        $pdf->setPageMark();
+        $pdf->save_pdf($tempfile);
+        $filearea = self::TMP_JPG_TO_PDF_FILEAREA;
+        $pdffile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
+        if (file_exists($tempfile)) {
+            unlink($tempfile);
+            rmdir($tmpdir);
+        }
+        return $pdffile;
+    }
+
+    /**
+     * Save rotated image data to file.
+     * @param int|\assign $assignment Assignment
+     * @param int $userid User ID
+     * @param int $attemptnumber Attempt Number
+     * @param resource $rotateddata image data to save
+     * @param string $filename name of the image file
+     * @return \stored_file
+     * @throws \file_exception
+     * @throws \stored_file_creation_exception
+     */
+    private static function save_rotated_image_file($assignment, $userid, $attemptnumber, $rotateddata, $filename) {
+        $filearea = self::TMP_ROTATED_JPG_FILEAREA;
+        $tmpdir = make_temp_directory('assignfeedback_editpdf' . DIRECTORY_SEPARATOR
+            . $filearea . DIRECTORY_SEPARATOR
+            . self::hash($assignment, $userid, $attemptnumber));
+        $tempfile = $tmpdir . DIRECTORY_SEPARATOR . basename($filename);
+        imagejpeg($rotateddata, $tempfile);
+        $newfile = self::save_file($assignment, $userid, $attemptnumber, $filearea, $tempfile);
+        if (file_exists($tempfile)) {
+            unlink($tempfile);
+            rmdir($tmpdir);
+        }
+        return $newfile;
+    }
+
 }
index 7ad838a..e0e0539 100644 (file)
@@ -637,4 +637,12 @@ class assign_submission_file extends assign_submission_plugin {
 
         return $sets;
     }
+
+    /**
+     * Determine if the plugin allows image file conversion
+     * @return bool
+     */
+    public function allow_image_conversion() {
+        return true;
+    }
 }
index 8bbcb9a..2280cd0 100644 (file)
@@ -146,4 +146,12 @@ abstract class assign_submission_plugin extends assign_plugin {
     public function submission_is_empty(stdClass $data) {
         return false;
     }
+
+    /**
+     * Determine if the plugin allows image file conversion
+     * @return bool
+     */
+    public function allow_image_conversion() {
+        return false;
+    }
 }
index 5268533..e68d31c 100644 (file)
@@ -1,5 +1,7 @@
 This files describes API changes in the assign code.
 === 3.8 ===
+* The allow_image_conversion method has been added to the submissionplugins. It determines whether the submission plugin
+  allows image conversion or not. By default conversion is not allowed (except when overwritten in the submission plugin)
 * Webservice function mod_assign_get_submission_status, return value 'warnofungroupedusers', changed from PARAM_BOOL to PARAM_ALPHA. See the description for possible values.
 * The following functions have been finally deprecated and can not be used anymore:
     * assign_scale_used()
index 3343a14..c780cbf 100644 (file)
@@ -297,7 +297,7 @@ class mod_choice_renderer extends plugin_renderer_base {
                 'name' => $selectallid,
                 'value' => 1,
                 'label' => get_string('selectall'),
-                'classes' => 'mr-1'
+                'classes' => 'btn-secondary mr-1'
             ], true);
             $actiondata .= $OUTPUT->render($selectallcheckbox);
 
index d140593..bd562c9 100644 (file)
@@ -1444,7 +1444,7 @@ function data_print_template($template, $records, $data, $search='', $page=0, $r
             $checkbox = new \core\output\checkbox_toggleall('listview-entries', false, [
                 'id' => "entry_{$record->id}",
                 'name' => 'delcheck[]',
-                'class' => 'recordcheckbox',
+                'classes' => 'recordcheckbox',
                 'value' => $record->id,
             ]);
             $replacement[] = $OUTPUT->render($checkbox);
index 0812fb8..5f7800e 100644 (file)
@@ -516,7 +516,7 @@ if ($showactivity) {
                     'name' => $selectallid,
                     'value' => 1,
                     'label' => get_string('selectall'),
-                    'classes' => 'mr-1',
+                    'classes' => 'btn-secondary mr-1',
                 ], true);
                 echo $OUTPUT->render($mastercheckbox);
 
index fb20199..8aa381d 100644 (file)
@@ -267,7 +267,7 @@ if (empty($students)) {
             $checkbox = new \core\output\checkbox_toggleall('feedback-non-respondents', false, [
                 'id' => 'messageuser-' . $student->id,
                 'name' => 'messageuser[]',
-                'class' => 'mr-1',
+                'classes' => 'mr-1',
                 'value' => $student->id,
                 'label' => get_string('includeuserinrecipientslist', 'mod_feedback', fullname($student)),
                 'labelclasses' => 'accesshide',
index 2aff436..117351b 100644 (file)
@@ -179,8 +179,11 @@ class summary_table extends table_sql {
      * @return string User's full name.
      */
     public function col_fullname($data): string {
-        global $OUTPUT;
+        if ($this->is_downloading()) {
+            return fullname($data);
+        }
 
+        global $OUTPUT;
         return $OUTPUT->user_picture($data, array('size' => 35, 'courseid' => $this->cm->course, 'includefullname' => true));
     }
 
@@ -384,6 +387,7 @@ class summary_table extends table_sql {
         $this->collapsible(false);
         $this->sortable(true, 'firstname', SORT_ASC);
         $this->pageable(true);
+        $this->is_downloadable(true);
         $this->no_sorting('select');
         $this->set_attribute('id', 'forumreport_summary_table');
     }
@@ -646,4 +650,17 @@ class summary_table extends table_sql {
 
         return (count($groups) < $groupsavailablecount);
     }
+
+    /**
+     * Download the summary report in the selected format.
+     *
+     * @param string $format The format to download the report.
+     */
+    public function download($format) {
+        $filename = 'summary_report_' . userdate(time(), get_string('backupnameformat', 'langconfig'),
+                99, false);
+
+        $this->is_downloading($format, $filename);
+        $this->out($this->perpage, false);
+    }
 }
index 78ac735..61f407b 100644 (file)
@@ -37,6 +37,8 @@ $filters = [];
 $filters['forums'] = [$forumid];
 $filters['groups'] = optional_param_array('filtergroups', [], PARAM_INT);
 
+$download = optional_param('download', '', PARAM_ALPHA);
+
 $cm = null;
 $modinfo = get_fast_modinfo($courseid);
 
@@ -73,27 +75,32 @@ $PAGE->set_title($forumname);
 $PAGE->set_heading($course->fullname);
 $PAGE->navbar->add(get_string('nodetitle', "forumreport_summary"));
 
-echo $OUTPUT->header();
-echo $OUTPUT->heading(get_string('summarytitle', 'forumreport_summary', $forumname), 2, 'p-b-2');
-
-if (!empty($filters['groups'])) {
-    \core\notification::info(get_string('viewsdisclaimer', 'forumreport_summary'));
-}
-
-// Render the report filters form.
-$renderer = $PAGE->get_renderer('forumreport_summary');
-echo $renderer->render_filters_form($cm, $url, $filters);
-
 // Prepare and display the report.
-$bulkoperations = !empty($CFG->messaging) && has_capability('moodle/course:bulkmessaging', $context);
+$bulkoperations = !$download && !empty($CFG->messaging) && has_capability('moodle/course:bulkmessaging', $context);
 
 $table = new \forumreport_summary\summary_table($courseid, $filters, $bulkoperations);
 $table->baseurl = $url;
 
-echo $renderer->render_summary_table($table, $perpage);
+if ($download) {
+    $table->download($download);
+} else {
+    echo $OUTPUT->header();
+    echo $OUTPUT->heading(get_string('summarytitle', 'forumreport_summary', $forumname), 2, 'p-b-2');
 
-if ($bulkoperations) {
-    echo $renderer->render_bulk_action_menu();
-}
+    if (!empty($filters['groups'])) {
+        \core\notification::info(get_string('viewsdisclaimer', 'forumreport_summary'));
+    }
+
+    // Render the report filters form.
+    $renderer = $PAGE->get_renderer('forumreport_summary');
+
+    echo $renderer->render_filters_form($cm, $url, $filters);
+    $table->show_download_buttons_at(array(TABLE_P_BOTTOM));
+    echo $renderer->render_summary_table($table, $perpage);
 
-echo $OUTPUT->footer();
+    if ($bulkoperations) {
+        echo $renderer->render_bulk_action_menu();
+    }
+
+    echo $OUTPUT->footer();
+}
index e4d5269..8eef0ef 100644 (file)
@@ -207,7 +207,7 @@ class custom_view extends \core_question\bank\view {
     }
 
     protected function print_category_info($category) {
-        $formatoptions = new stdClass();
+        $formatoptions = new \stdClass();
         $formatoptions->noclean = true;
         $strcategory = get_string('category', 'quiz');
         echo '<div class="categoryinfo"><div class="categorynamefieldcontainer">' .
index fa15f54..ecde327 100644 (file)
@@ -44,7 +44,7 @@ class question_name_text_column extends \core_question\bank\question_name_column
         if ($labelfor) {
             echo '<label for="' . $labelfor . '">';
         }
-        echo quiz_question_tostring($question);
+        echo quiz_question_tostring($question, false, true, true, $question->tags);
         if ($labelfor) {
             echo '</label>';
         }
@@ -55,6 +55,12 @@ class question_name_text_column extends \core_question\bank\question_name_column
         $fields = parent::get_required_fields();
         $fields[] = 'q.questiontext';
         $fields[] = 'q.questiontextformat';
+        $fields[] = 'q.idnumber';
         return $fields;
     }
+
+    public function load_additional_data(array $questions) {
+        parent::load_additional_data($questions);
+        parent::load_question_tags($questions);
+    }
 }
index 8db703b..ad16a5a 100644 (file)
@@ -2048,17 +2048,43 @@ class qubaids_for_quiz_user extends qubaid_join {
  * @param bool $showicon If true, show the question's icon with the question. False by default.
  * @param bool $showquestiontext If true (default), show question text after question name.
  *       If false, show only question name.
- * @return string
- */
-function quiz_question_tostring($question, $showicon = false, $showquestiontext = true) {
+ * @param bool $showidnumber If true, show the question's idnumber, if any. False by default.
+ * @param core_tag_tag[]|bool $showtags if array passed, show those tags. Else, if true, get and show tags,
+ *       else, don't show tags (which is the default).
+ * @return string HTML fragment.
+ */
+function quiz_question_tostring($question, $showicon = false, $showquestiontext = true,
+        $showidnumber = false, $showtags = false) {
+    global $OUTPUT;
     $result = '';
 
+    // Question name.
     $name = shorten_text(format_string($question->name), 200);
     if ($showicon) {
         $name .= print_question_icon($question) . ' ' . $name;
     }
     $result .= html_writer::span($name, 'questionname');
 
+    // Question idnumber.
+    if ($showidnumber && $question->idnumber !== null && $question->idnumber !== '') {
+        $result .= ' ' . html_writer::span(
+                html_writer::span(get_string('idnumber', 'question'), 'accesshide') .
+                ' ' . $question->idnumber, 'badge badge-primary');
+    }
+
+    // Question tags.
+    if (is_array($showtags)) {
+        $tags = $showtags;
+    } else if ($showtags) {
+        $tags = core_tag_tag::get_item_tags('core_question', 'question', $question->id);
+    } else {
+        $tags = [];
+    }
+    if ($tags) {
+        $result .= $OUTPUT->tag_list($tags, null, 'd-inline', 0, null, true);
+    }
+
+    // Question text.
     if ($showquestiontext) {
         $questiontext = question_utils::to_plain_text($question->questiontext,
                 $question->questiontextformat, array('noclean' => true, 'para' => false));
index eae1b28..ca30770 100644 (file)
@@ -968,8 +968,7 @@ table.quizreviewsummary td.cell {
     border: 0 none;
 }
 
-#categoryquestions th.modifiername .sorters,
-#categoryquestions th.creatorname .sorters {
+#categoryquestions th .sorters {
     font-weight: normal;
     font-size: 0.8em;
 }
index a35e68f..7344a0a 100644 (file)
@@ -11,11 +11,11 @@ Feature: Add a quiz
       | student1 | Sam1      | Student1 | student1@example.com |
     And the following "courses" exist:
       | fullname | shortname | category |
-      | Course 1 | C1 | 0 |
+      | Course 1 | C1        | 0        |
     And the following "course enrolments" exist:
-      | user | course | role |
-      | teacher1 | C1 | editingteacher |
-      | student1 | C1 | student |
+      | user     | course | role           |
+      | teacher1 | C1     | editingteacher |
+      | student1 | C1     | student        |
     When I log in as "teacher1"
     And I am on "Course 1" course homepage with editing mode on
     And I add a "Quiz" to section "1" and I fill the form with:
index b38f227..f946e52 100644 (file)
@@ -35,9 +35,7 @@ Feature: Attempt a quiz
       | slot | response |
       |   1  | True     |
       |   2  | False    |
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I follow "Review"
     Then I should see "25.00 out of 100.00"
 
@@ -66,9 +64,7 @@ Feature: Attempt a quiz
       |           | 4         | 1       |
       | Section 3 | 5         | 0       |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
 
     Then I should see "Section 1" in the "Quiz navigation" "block"
@@ -109,9 +105,7 @@ Feature: Attempt a quiz
       | question | page |
       | TF1      | 1    |
       | TF2      | 2    |
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     Then I should see "Text of the first question"
     And I should not see "Text of the second question"
index ab48d67..5ce5fb2 100644 (file)
@@ -29,9 +29,7 @@ Feature: The various checks that may happen when an attept is started
     And quiz "Quiz 1" contains the following questions:
       | question | page |
       | TF1      | 1    |
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     Then I should see "Text of the first question"
 
@@ -43,9 +41,7 @@ Feature: The various checks that may happen when an attept is started
     And quiz "Quiz 1" contains the following questions:
       | question | page |
       | TF1      | 1    |
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     Then I should see "To attempt this quiz you need to know the quiz password" in the "Start attempt" "dialogue"
     And I should see "The quiz has a time limit of 1 hour. Time will " in the "Start attempt" "dialogue"
@@ -61,9 +57,7 @@ Feature: The various checks that may happen when an attept is started
     And quiz "Quiz 1" contains the following questions:
       | question | page |
       | TF1      | 1    |
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     And I click on "Cancel" "button" in the "Start attempt" "dialogue"
     Then I should see "Quiz 1 description"
@@ -77,9 +71,7 @@ Feature: The various checks that may happen when an attept is started
     And quiz "Quiz 1" contains the following questions:
       | question | page |
       | TF1      | 1    |
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     And I set the field "Quiz password" to "Toad"
     And I click on "Start attempt" "button" in the "Start attempt" "dialogue"
@@ -101,9 +93,7 @@ Feature: The various checks that may happen when an attept is started
     And quiz "Quiz 1" contains the following questions:
       | question | page |
       | TF1      | 1    |
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     And I set the field "Quiz password" to "Toad"
     And I click on "Start attempt" "button" in the "Start attempt" "dialogue"
index 4398197..2e63a89 100644 (file)
@@ -33,10 +33,8 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
 
   @javascript
   Scenario: After completing a question, there is a redo question button that restarts the question
-    Given I log in as "student"
-    And I am on "Course 1" course homepage
-    When I follow "Quiz 1"
-    And I press "Attempt quiz now"
+    Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
+    When I press "Attempt quiz now"
     And I click on "False" "radio" in the "First question" "question"
     And I click on "Check" "button" in the "First question" "question"
     And I press "Try another question like this one"
@@ -45,26 +43,20 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
 
   @javascript
   Scenario: The redo question button is visible but disabled for teachers
-    Given I log in as "student"
-    And I am on "Course 1" course homepage
-    When I follow "Quiz 1"
-    And I press "Attempt quiz now"
+    Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
+    When I press "Attempt quiz now"
     And I click on "False" "radio" in the "First question" "question"
     And I click on "Check" "button" in the "First question" "question"
     And I log out
-    And I log in as "teacher"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    And I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher"
     And I follow "Attempts: 1"
     And I follow "Review attempt"
     Then the "Try another question like this one" "button" should be disabled
 
   @javascript
   Scenario: The redo question buttons are no longer visible after the attempt is submitted.
-    Given I log in as "student"
-    And I am on "Course 1" course homepage
-    When I follow "Quiz 1"
-    And I press "Attempt quiz now"
+    Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
+    When I press "Attempt quiz now"
     And I click on "False" "radio" in the "First question" "question"
     And I click on "Check" "button" in the "First question" "question"
     And I press "Finish attempt ..."
@@ -73,11 +65,9 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
     Then "Try another question like this one" "button" should not exist
 
   @javascript @_switch_window
-  Scenario: Teachers reviewing can see all the qestions attempted in a slot
-    Given I log in as "student"
-    And I am on "Course 1" course homepage
-    When I follow "Quiz 1"
-    And I press "Attempt quiz now"
+  Scenario: Teachers reviewing can see all the questions attempted in a slot
+    Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
+    When I press "Attempt quiz now"
     And I click on "False" "radio" in the "First question" "question"
     And I click on "Check" "button" in the "First question" "question"
     And I press "Try another question like this one"
@@ -85,9 +75,7 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
     And I press "Submit all and finish"
     And I click on "Submit all and finish" "button" in the "Confirmation" "dialogue"
     And I log out
-    And I log in as "teacher"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    And I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher"
     And I follow "Attempts: 1"
     And I follow "Review attempt"
     And I click on "1" "link" in the "First question" "question"
@@ -106,10 +94,8 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
 
   @javascript
   Scenario: Redoing question 1 should save any changes to question 2 on the same page
-    Given I log in as "student"
-    And I am on "Course 1" course homepage
-    When I follow "Quiz 1"
-    And I press "Attempt quiz now"
+    Given I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
+    When I press "Attempt quiz now"
     And I click on "False" "radio" in the "First question" "question"
     And I click on "Check" "button" in the "First question" "question"
     And I click on "True" "radio" in the "Second question" "question"
@@ -131,10 +117,8 @@ Feature: Allow students to redo questions in a practice quiz, without starting a
     And user "student" has started an attempt at quiz "Quiz 2" randomised as follows:
       | slot | actualquestion |
       | 1    | TF1            |
-    And I log in as "student"
-    And I am on "Course 1" course homepage
-    When I follow "Quiz 2"
-    And I press "Continue the last attempt"
+    And I am on the "Quiz 2" "mod_quiz > View" page logged in as "student"
+    When I press "Continue the last attempt"
     And I should see "First question"
     And I click on "False" "radio"
     And I click on "Check" "button"
index 6155408..0caf8af 100644 (file)
@@ -34,20 +34,14 @@ Feature: Attempt a quiz where some questions require that the previous question
       | TF1      | 1    | 0               |
       | TF2      | 1    | 1               |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
 
     Then I should see "First question"
     And I should see "This question cannot be attempted until the previous question has been completed."
     And I should not see "Second question"
     And I log out
-    And I log in as "teacher"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I follow "Attempts: 1"
-    And I follow "Review attempt"
+    And I am on the "Quiz 1 > student > Attempt 1" "mod_quiz > Attempt review" page logged in as "teacher"
     And I should see "First question"
     And I should see "This question cannot be attempted until the previous question has been completed."
     And I should not see "Second question"
@@ -68,9 +62,7 @@ Feature: Attempt a quiz where some questions require that the previous question
       | TF1      | 1    | 0               |
       | TF2      | 1    | 1               |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     And I click on "True" "radio" in the "First question" "question"
     And I press "Check"
@@ -95,9 +87,7 @@ Feature: Attempt a quiz where some questions require that the previous question
       | TF1      | 1    | 0               |
       | TF2      | 1    | 1               |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     And I press "Finish attempt ..."
     And I press "Submit all and finish"
@@ -120,9 +110,7 @@ Feature: Attempt a quiz where some questions require that the previous question
       | TF1      | 1    | 0               |
       | TF2      | 1    | 1               |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
 
     Then I should see "First question"
@@ -146,9 +134,7 @@ Feature: Attempt a quiz where some questions require that the previous question
       | heading   | firstslot | shuffle |
       | Section 1 | 1         | 1       |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
 
     Then I should see "First question"
@@ -173,9 +159,7 @@ Feature: Attempt a quiz where some questions require that the previous question
       | Section 1 | 1         | 1       |
       | Section 2 | 2         | 0       |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
     And I press "Next page"
 
@@ -196,9 +180,7 @@ Feature: Attempt a quiz where some questions require that the previous question
       | TF1      | 1    | 1               |
       | TF2      | 1    | 1               |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
 
     Then I should see "First question"
@@ -219,9 +201,7 @@ Feature: Attempt a quiz where some questions require that the previous question
       | Story    | 1    | 0               |
       | TF2      | 1    | 1               |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
 
     Then I should see "First question"
@@ -242,9 +222,7 @@ Feature: Attempt a quiz where some questions require that the previous question
       | Info     | 1    | 0               |
       | TF1      | 1    | 1               |
 
-    When I log in as "student"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "student"
     And I press "Attempt quiz now"
 
     Then I should see "Read me"
index eaa8b44..9eefeff 100644 (file)
@@ -29,8 +29,7 @@ Feature: Backup and restore of quizzes
     When I am on "Course 1" course homepage with editing mode on
     And I duplicate "Quiz 1" activity editing the new copy with:
       | Name | Quiz 2 |
-    And I follow "Quiz 2"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then I should see "TF1"
     And I should see "TF2"
 
@@ -73,7 +72,6 @@ Feature: Backup and restore of quizzes
     And I upload "mod/quiz/tests/fixtures/moodle_28_quiz.mbz" file to "Files" filemanager
     And I press "Save changes"
     And I restore "moodle_28_quiz.mbz" backup into "Course 1" course using this options:
-    And I follow "Restored Moodle 2.8 quiz"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Restored Moodle 2.8 quiz" "mod_quiz > Edit" page
     Then I should see "TF1"
     And I should see "TF2"
index 0269b75..563ffbe 100644 (file)
@@ -40,6 +40,115 @@ use Behat\Mink\Exception\ExpectationException as ExpectationException;
  */
 class behat_mod_quiz extends behat_question_base {
 
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[page name]" page'.
+     *
+     * Recognised page names are:
+     * | None so far!      |                                                              |
+     *
+     * @param string $page name of the page, with the component name removed e.g. 'Admin notification'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_page_url(string $page): moodle_url {
+        switch ($page) {
+            default:
+                throw new Exception('Unrecognised quiz page type "' . $page . '."');
+        }
+    }
+
+    /**
+     * Convert page names to URLs for steps like 'When I am on the "[identifier]" "[page type]" page'.
+     *
+     * Recognised page names are:
+     * | pagetype          | name meaning                                | description                                  |
+     * | View              | Quiz name                                   | The quiz info page (view.php)                |
+     * | Edit              | Quiz name                                   | The edit quiz page (edit.php)                |
+     * | Group overrides   | Quiz name                                   | The manage group overrides page              |
+     * | User overrides    | Quiz name                                   | The manage user overrides page               |
+     * | Grades report     | Quiz name                                   | The overview report for a quiz               |
+     * | Responses report  | Quiz name                                   | The responses report for a quiz              |
+     * | Statistics report | Quiz name                                   | The statistics report for a quiz             |
+     * | Attempt review    | Quiz name > username > [Attempt] attempt no | Review page for a given attempt (review.php) |
+     *
+     * @param string $type identifies which type of page this is, e.g. 'Attempt review'.
+     * @param string $identifier identifies the particular page, e.g. 'Test quiz > student > Attempt 1'.
+     * @return moodle_url the corresponding URL.
+     * @throws Exception with a meaningful error message if the specified page cannot be found.
+     */
+    protected function resolve_page_instance_url(string $type, string $identifier): moodle_url {
+        global $DB;
+
+        switch ($type) {
+            case 'View':
+                return new moodle_url('/mod/quiz/view.php',
+                        ['id' => $this->get_cm_by_quiz_name($identifier)->id]);
+
+            case 'Edit':
+                return new moodle_url('/mod/quiz/edit.php',
+                        ['cmid' => $this->get_cm_by_quiz_name($identifier)->id]);
+
+            case 'Group overrides':
+                return new moodle_url('/mod/quiz/overrides.php',
+                    ['cmid' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'group']);
+
+            case 'User overrides':
+                return new moodle_url('/mod/quiz/overrides.php',
+                    ['cmid' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'user']);
+
+            case 'Grades report':
+                return new moodle_url('/mod/quiz/report.php',
+                    ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'overview']);
+
+            case 'Responses report':
+                return new moodle_url('/mod/quiz/report.php',
+                    ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'responses']);
+
+            case 'Statistics report':
+                return new moodle_url('/mod/quiz/report.php',
+                    ['id' => $this->get_cm_by_quiz_name($identifier)->id, 'mode' => 'statistics']);
+
+            case 'Attempt review':
+                if (substr_count($identifier, ' > ') !== 2) {
+                    throw new coding_exception('For "attempt review", name must be ' .
+                            '"{Quiz name} > {username} > Attempt {attemptnumber}", ' .
+                            'for example "Quiz 1 > student > Attempt 1".');
+                }
+                list($quizname, $username, $attemptno) = explode(' > ', $identifier);
+                $attemptno = (int) trim(str_replace ('Attempt', '', $attemptno));
+                $quiz = $this->get_quiz_by_name($quizname);
+                $user = $DB->get_record('user', ['username' => $username], '*', MUST_EXIST);
+                $attempt = $DB->get_record('quiz_attempts',
+                        ['quiz' => $quiz->id, 'userid' => $user->id, 'attempt' => $attemptno], '*', MUST_EXIST);
+                return new moodle_url('/mod/quiz/review.php', ['attempt' => $attempt->id]);
+
+            default:
+                throw new Exception('Unrecognised quiz page type "' . $type . '."');
+        }
+    }
+
+    /**
+     * Get a quiz by name.
+     *
+     * @param string $name quiz name.
+     * @return stdClass the corresponding DB row.
+     */
+    protected function get_quiz_by_name(string $name): stdClass {
+        global $DB;
+        return $DB->get_record('quiz', array('name' => $name), '*', MUST_EXIST);
+    }
+
+    /**
+     * Get a quiz cmid from the quiz name.
+     *
+     * @param string $name quiz name.
+     * @return stdClass cm from get_coursemodule_from_instance.
+     */
+    protected function get_cm_by_quiz_name(string $name): stdClass {
+        $quiz = $this->get_quiz_by_name($name);
+        return get_coursemodule_from_instance('quiz', $quiz->id, $quiz->course);
+    }
+
     /**
      * Put the specified questions on the specified pages of a given quiz.
      *
@@ -67,7 +176,7 @@ class behat_mod_quiz extends behat_question_base {
     public function quiz_contains_the_following_questions($quizname, TableNode $data) {
         global $DB;
 
-        $quiz = $DB->get_record('quiz', array('name' => $quizname), '*', MUST_EXIST);
+        $quiz = $this->get_quiz_by_name($quizname);
 
         // Deal with backwards-compatibility, optional first row.
         $firstrow = $data->getRow(0);
index 3888204..41071b6 100644 (file)
@@ -17,15 +17,12 @@ Feature: Edit quiz page - adding things
     And the following "activities" exist:
       | activity   | name   | intro                           | course | idnumber |
       | quiz       | Quiz 1 | Quiz 1 for testing the Add menu | C1     | quiz1    |
-    And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
-    Then I should see "Editing quiz: Quiz 1"
+    And I am on the "Quiz 1" "mod_quiz > Edit" page logged in as "teacher1"
+    And I should see "Editing quiz: Quiz 1"
 
   @javascript
   Scenario: Add some new question to the quiz using '+ a new question' options of the 'Add' menu.
-    And I open the "last" add to quiz menu
+    When I open the "last" add to quiz menu
     And I follow "a new question"
     And I set the field "item_qtype_essay" to "1"
     And I press "submitbutton"
@@ -108,7 +105,7 @@ Feature: Edit quiz page - adding things
       in various categories and add them to the question bank.
 
     # Create a couple of sub categories.
-    And I am on "Course 1" course homepage
+    When I am on "Course 1" course homepage
     And I navigate to "Question bank > Categories" in current page administration
     Then I should see "Add category"
     Then I set the field "Parent category" to "Default for C1"
@@ -190,9 +187,7 @@ Feature: Edit quiz page - adding things
 
     # Add questions from question bank using the Add menu.
     # Add Essay 03 from question bank.
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     And I open the "last" add to quiz menu
     And I follow "from question bank"
     Then the "Add selected questions to the quiz" "button" should be disabled
index 56e983d..b8c8e68 100644 (file)
@@ -21,30 +21,31 @@ Feature: Adding questions to a quiz from the question bank
       | contextlevel | reference | name           |
       | Course       | C1        | Test questions |
     And the following "questions" exist:
-      | questioncategory | qtype     | name             | user     | questiontext     |
-      | Test questions   | essay     | question 01 name | admin    | Question 01 text |
-      | Test questions   | essay     | question 02 name | teacher1 | Question 02 text |
+      | questioncategory | qtype     | name             | user     | questiontext     | idnumber |
+      | Test questions   | essay     | question 01 name | admin    | Question 01 text |          |
+      | Test questions   | essay     | question 02 name | teacher1 | Question 02 text | qidnum   |
 
   Scenario: The questions can be filtered by tag
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     When I navigate to "Question bank > Questions" in current page administration
-    And I click on "Edit" "link" in the "question 01 name" "table_row"
+    And I choose "Edit question" action for "question 01 name" in the question bank
     And I set the following fields to these values:
       | Tags | foo |
     And I press "id_submitbutton"
-    And I click on "Edit" "link" in the "question 02 name" "table_row"
+    And I choose "Edit question" action for "question 02 name" in the question bank
     And I set the following fields to these values:
       | Tags | bar |
     And I press "id_submitbutton"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     And I open the "last" add to quiz menu
     And I follow "from question bank"
+    Then I should see "foo" in the "question 01 name" "table_row"
+    And I should see "bar" in the "question 02 name" "table_row"
+    And I should see "qidnum" in the "question 02 name" "table_row"
     And I set the field "Filter by tags..." to "foo"
     And I press key "13" in the field "Filter by tags..."
-    Then I should see "question 01 name" in the "categoryquestions" "table"
+    And I should see "question 01 name" in the "categoryquestions" "table"
     And I should not see "question 02 name" in the "categoryquestions" "table"
 
   Scenario: The question modal can be paginated
@@ -71,9 +72,7 @@ Feature: Adding questions to a quiz from the question bank
       | Test questions   | essay     | question 21 name | teacher1 | Question 21 text |
       | Test questions   | essay     | question 22 name | teacher1 | Question 22 text |
     And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     And I open the "last" add to quiz menu
     And I follow "from question bank"
     And I click on "2" "link" in the ".pagination" "css_element"
@@ -94,10 +93,8 @@ Feature: Adding questions to a quiz from the question bank
       | Section 1 | 1         | 0       |
       | Section 2 | 2         | 0       |
     And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    When I navigate to "Edit quiz" in current page administration
-    And I open the "Page 1" add to quiz menu
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
+    When I open the "Page 1" add to quiz menu
     And I follow "from question bank"
     And I set the field with xpath "//tr[contains(normalize-space(.), 'question 03 name')]//input[@type='checkbox']" to "1"
     And I click on "Add selected questions to the quiz" "button"
index 2249d11..65c6a57 100644 (file)
@@ -30,17 +30,15 @@ Feature: Adding random questions to a quiz based on category and tags
     Given I log in as "teacher1"
     And I am on "Course 1" course homepage
     When I navigate to "Question bank > Questions" in current page administration
-    And I click on "Edit" "link" in the "question 1 name" "table_row"
+    And I choose "Edit question" action for "question 1 name" in the question bank
     And I set the following fields to these values:
       | Tags | foo |
     And I press "id_submitbutton"
-    And I click on "Manage tags" "link" in the "question 2 name" "table_row"
-    And I set the following fields to these values:
+    And I choose "Manage tags" action for "question 2 name" in the question bank
+    And I set the following fields in the "Question tags" "dialogue" to these values:
       | Tags | bar |
     And I press "Save changes"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     And I open the "last" add to quiz menu
     And I follow "a random question"
     And I open the autocomplete suggestions list
@@ -52,8 +50,6 @@ Feature: Adding random questions to a quiz based on category and tags
       | capability             | permission | role           | contextlevel | reference |
       | moodle/question:useall | Prevent    | editingteacher | Course       | C1        |
     And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     When I open the "last" add to quiz menu
     Then I should not see "a random question"
index 6a0d36b..7f838d6 100644 (file)
@@ -31,9 +31,7 @@ Feature: Edit quiz page - drag-and-drop
       | Question B | 1    |
       | Question C | 2    |
     And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
   @javascript
   Scenario: Re-order questions by clicking on the move icon.
index 9d9a220..8c402ac 100644 (file)
@@ -21,8 +21,6 @@ Feature: Edit quiz page - remove multiple questions
       | activity   | name   | course | idnumber |
       | quiz       | Quiz 1 | C1     | quiz1    |
     And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
 
   @javascript
   Scenario: Delete selected question using select multiple items feature.
@@ -36,7 +34,7 @@ Feature: Edit quiz page - remove multiple questions
       | Question A | 1    |
       | Question B | 1    |
       | Question C | 2    |
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
     # Confirm the starting point.
     Then I should see "Question A" on quiz page "1"
@@ -70,7 +68,7 @@ Feature: Edit quiz page - remove multiple questions
       | Question A | 1    |
       | Question B | 2    |
       | Question C | 2    |
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
   # Confirm the starting point.
     Then I should see "Question A" on quiz page "1"
@@ -100,8 +98,8 @@ Feature: Edit quiz page - remove multiple questions
     And quiz "Quiz 1" contains the following questions:
       | question   | page |
       | Question A | 1    |
-    When I navigate to "Edit quiz" in current page administration
-    And I click on "Select multiple items" "button"
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
+    When I click on "Select multiple items" "button"
     And I click on "selectquestion-1" "checkbox"
     And I click on "Delete selected" "button"
     And I click on "Yes" "button" in the "Confirm" "dialogue"
@@ -119,9 +117,9 @@ Feature: Edit quiz page - remove multiple questions
       | Question A | 1    |
       | Question B | 1    |
       | Question C | 2    |
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
-  # Confirm the starting point.
+    # Confirm the starting point.
     Then I should see "Question A" on quiz page "1"
     And I should see "Question B" on quiz page "1"
     And I should see "Question C" on quiz page "2"
@@ -129,7 +127,7 @@ Feature: Edit quiz page - remove multiple questions
     And I should see "Questions: 3"
     And I should see "This quiz is open"
 
-  # Delete all questions in page. Page contains multiple questions
+    # Delete all questions in page. Page contains multiple questions
     When I click on "Select multiple items" "button"
     Then I press "Select all"
     And I click on "Delete selected" "button"
@@ -153,7 +151,7 @@ Feature: Edit quiz page - remove multiple questions
       | Question A | 1    |
       | Question B | 1    |
       | Question C | 2    |
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
   # Confirm the starting point.
     Then I should see "Question A" on quiz page "1"
@@ -191,7 +189,7 @@ Feature: Edit quiz page - remove multiple questions
       | Section 1 | 1         | 0       |
       | Section 2 | 2         | 0       |
       | Section 3 | 4         | 0       |
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
     When I click on "Select multiple items" "button"
     And I click on "selectquestion-3" "checkbox"
@@ -230,7 +228,7 @@ Feature: Edit quiz page - remove multiple questions
       | Section 1 | 1         | 0       |
       | Section 2 | 2         | 0       |
       | Section 3 | 4         | 0       |
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
     When I click on "Select multiple items" "button"
     And I click on "selectquestion-2" "checkbox"
index a5d37a1..1ae73b6 100644 (file)
@@ -21,8 +21,6 @@ Feature: Edit quiz page - remove questions
       | activity   | name   | course | idnumber |
       | quiz       | Quiz 1 | C1     | quiz1    |
     And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
 
   @javascript
   Scenario: Delete questions by clicking on the delete icon.
@@ -36,7 +34,7 @@ Feature: Edit quiz page - remove questions
       | Question A | 1    |
       | Question B | 1    |
       | Question C | 2    |
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
     # Confirm the starting point.
     Then I should see "Question A" on quiz page "1"
@@ -81,7 +79,7 @@ Feature: Edit quiz page - remove questions
       | heading   | firstslot | shuffle |
       | Heading 1 | 1         | 1       |
       | Heading 2 | 2         | 1       |
-    When I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "Delete" "link" in the "Question A" "list_item" should not be visible
     Then "Delete" "link" in the "Question B" "list_item" should be visible
     Then "Delete" "link" in the "Question C" "list_item" should be visible
@@ -94,6 +92,6 @@ Feature: Edit quiz page - remove questions
     And quiz "Quiz 1" contains the following questions:
       | question   | page |
       | Question A | 1    |
-    When I navigate to "Edit quiz" in current page administration
-    And I delete "Question A" in the quiz by clicking the delete icon
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
+    When I delete "Question A" in the quiz by clicking the delete icon
     Then I should see "Questions: 0"
index 3fa3574..0fe6656 100644 (file)
@@ -19,9 +19,7 @@ Feature: Edit quiz page - pagination
       | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
 
     When I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
   @javascript
   Scenario: Repaginate questions with N question(s) per page as well as clicking
index 813ab55..079b212 100644 (file)
@@ -30,9 +30,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
     And quiz "Quiz 1" contains the following questions:
       | question | page | requireprevious |
       | TF1      | 1    | 1               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "be attempted" "link" should not be visible
     # The text "be attempted" is used as a relatively unique string in both the add and remove links.
 
@@ -49,9 +47,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | question | page | requireprevious |
       | TF1      | 1    | 0               |
       | TF2      | 1    | 1               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "This question cannot be attempted until the previous question has been completed." "link" should be visible
 
   @javascript
@@ -67,9 +63,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | question                | page | requireprevious |
       | Random (Test questions) | 1    | 0               |
       | TF1                     | 1    | 1               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "This question cannot be attempted until the previous question has been completed." "link" should be visible
 
   @javascript
@@ -87,9 +81,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | TF1      | 1    | 0               |
       | TF2      | 1    | 0               |
       | TF3      | 1    | 0               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     When I follow "No restriction on when question 2 can be attempted • Click to change"
     Then "Question 2 cannot be attempted until the previous question 1 has been completed • Click to change" "link" should be visible
     And "No restriction on when question 3 can be attempted • Click to change" "link" should be visible
@@ -109,9 +101,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | TF1      | 1    | 0               |
       | TF2      | 1    | 1               |
       | TF3      | 1    | 1               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     When I follow "Question 3 cannot be attempted until the previous question 2 has been completed • Click to change"
     Then "Question 2 cannot be attempted until the previous question 1 has been completed • Click to change" "link" should be visible
     And "No restriction on when question 3 can be attempted • Click to change" "link" should be visible
@@ -131,9 +121,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | Random (Test questions) | 1    | 0               |
       | TF1                     | 1    | 1               |
       | TF2                     | 1    | 1               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "be attempted" "link" in the "TF1" "list_item" should not be visible
     Then "be attempted" "link" in the "TF2" "list_item" should not be visible
 
@@ -153,9 +141,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
     And quiz "Quiz 1" contains the following sections:
       | heading   | firstslot | shuffle |
       | Section 1 | 1         | 1       |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "be attempted" "link" in the "TF2" "list_item" should not be visible
 
   @javascript
@@ -175,9 +161,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | heading   | firstslot | shuffle |
       | Section 1 | 1         | 1       |
       | Section 2 | 2         | 0       |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "be attempted" "link" in the "TF2" "list_item" should not be visible
 
   @javascript
@@ -193,9 +177,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | question | page | requireprevious |
       | TF1      | 1    | 1               |
       | TF2      | 1    | 1               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "be attempted" "link" in the "TF2" "list_item" should not be visible
 
   @javascript
@@ -211,9 +193,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | question | page | requireprevious |
       | Story    | 1    | 0               |
       | TF1      | 1    | 0               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "be attempted" "link" in the "TF1" "list_item" should not be visible
 
   @javascript
@@ -229,9 +209,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | question | page | requireprevious |
       | Info     | 1    | 0               |
       | TF1      | 1    | 0               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "be attempted" "link" in the "TF1" "list_item" should not be visible
 
   @javascript
@@ -249,9 +227,7 @@ Feature: Edit quizzes where some questions require the previous one to have been
       | TF1      | 1    | 0               |
       | TF2      | 1    | 1               |
       | TF3      | 1    | 1               |
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     When I move "Question 1" to "After Question 3" in the quiz by clicking the move icon
     Then "Question 2 cannot be attempted until the previous question 1 has been completed • Click to change" "link" should be visible
     And "No restriction on when question 3 can be attempted • Click to change" "link" should be visible
index a771b99..fbb0d0e 100644 (file)
@@ -35,9 +35,7 @@ Feature: Edit quiz page - section headings
       | TF1      | 1    |
       | TF2      | 2    |
       | TF3      | 3    |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     Then I should see "Shuffle"
 
   @javascript
@@ -45,13 +43,11 @@ Feature: Edit quiz page - section headings
     Given the following "activities" exist:
       | activity   | name   | intro              | course | idnumber |
       | quiz       | Quiz 1 | Quiz 1 description | C1     | quiz1    |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I change quiz section heading "" to "This is section one"
     Then I should see "This is section one"
 
- @javascript
 @javascript
   Scenario: Modify section headings
     Given the following "activities" exist:
       | activity   | name   | intro              | course | idnumber |
@@ -74,9 +70,7 @@ Feature: Edit quiz page - section headings
       |           | 1         | 0       |
       | Heading 2 | 2         | 0       |
       | Heading 3 | 3         | 1       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I change quiz section heading "" to "This is section one"
     And I change quiz section heading "Heading 2" to "This is section two"
     Then I should see "This is section one"
@@ -105,9 +99,7 @@ Feature: Edit quiz page - section headings
       | Heading 1 | 1         | 0       |
       | Heading 2 | 2         | 0       |
       | Heading 3 | 3         | 1       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     When I change quiz section heading "Heading 1" to ""
     Then I should not see "Heading 1"
     And I should see "Heading 2"
@@ -143,9 +135,7 @@ Feature: Edit quiz page - section headings
       | Heading 1 | 1         | 0       |
       | Heading 2 | 2         | 0       |
       | Heading 3 | 3         | 1       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I follow "Remove heading 'Heading 2'"
     And I should see "Are you sure you want to remove the 'Heading 2' section heading?"
     And I click on "Yes" "button" in the "Confirm" "dialogue"
@@ -172,9 +162,7 @@ Feature: Edit quiz page - section headings
       | heading   | firstslot | shuffle |
       | Heading 1 | 1         | 0       |
       | Heading 2 | 2         | 0       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I change quiz section heading "Heading 2" to "Edited heading"
     Then I should see "Edited heading"
     And "Edit heading 'Edited heading'" "link" should be visible
@@ -206,9 +194,7 @@ Feature: Edit quiz page - section headings
       | Heading 1 | 1         | 0       |
       | Heading 2 | 3         | 0       |
       | Heading 3 | 5         | 1       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I move "TF5" to "After Question 2" in the quiz by clicking the move icon
     Then I should see "TF5" on quiz page "2"
 
@@ -238,9 +224,7 @@ Feature: Edit quiz page - section headings
       | Heading 1 | 1         | 0       |
       | Heading 2 | 3         | 0       |
       | Heading 3 | 5         | 1       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I move "TF1" to "After Question 3" in the quiz by clicking the move icon
     Then I should see "TF1" on quiz page "2"
 
@@ -264,9 +248,7 @@ Feature: Edit quiz page - section headings
       | Heading 1 | 1         | 0       |
       | Heading 2 | 2         | 0       |
       | Heading 3 | 3         | 1       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     Then "Remove heading 'Heading 1'" "link" should not exist
     And "Remove heading 'Heading 2'" "link" should exist
     And "Remove heading 'Heading 3'" "link" should exist
@@ -291,9 +273,7 @@ Feature: Edit quiz page - section headings
       | Heading 1 | 1         | 0       |
       | Heading 2 | 2         | 0       |
       | Heading 3 | 3         | 0       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I click on shuffle for section "Heading 1" on the quiz edit page
     And I click on shuffle for section "Heading 2" on the quiz edit page
     Then shuffle for section "Heading 1" should be "On" on the quiz edit page
@@ -319,9 +299,7 @@ Feature: Edit quiz page - section headings
       | Heading 1 | 1         | 1       |
       | Heading 2 | 2         | 1       |
       | Heading 3 | 3         | 1       |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I click on shuffle for section "Heading 1" on the quiz edit page
     And I click on shuffle for section "Heading 2" on the quiz edit page
     Then shuffle for section "Heading 1" should be "Off" on the quiz edit page
@@ -345,9 +323,7 @@ Feature: Edit quiz page - section headings
       | TF1      | 1    |
       | TF2      | 1    |
       | TF3      | 2    |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I click on the "Add" page break icon after question "TF1"
     And I open the action menu in "Page 1" "list_item"
     Then "a new section heading" "link" in the "Page 1" "list_item" should not be visible
@@ -380,9 +356,7 @@ Feature: Edit quiz page - section headings
       | TF3      | 3    |
       | TF4      | 4    |
 
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I click on the "Remove" page break icon after question "TF1"
     And I open the "Page 2" add to quiz menu
     And I choose "a new section heading" in the open action menu
@@ -419,9 +393,7 @@ Feature: Edit quiz page - section headings
       | TF9      | 9    |
       | TF10     | 10   |
       | TF11     | 11   |
-    When I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     And I click on the "Remove" page break icon after question "TF10"
     And I open the "Page 10" add to quiz menu
     And I choose "a new section heading" in the open action menu
index 90088ee..7b4184d 100644 (file)
@@ -68,7 +68,7 @@ Feature: Edit quiz marks with no attempts
       | Decimal places in grades | 3 |
       | Decimal places in question grades | 5 |
     And I press "Save and display"
-    And I navigate to "Edit quiz" in current page administration
+    When I am on the "Quiz 1" "mod_quiz > Edit" page
     # Then the field "maxgrade" matches value "20.000" -- with exact match on decimal places.
     Then "//input[@name = 'maxgrade' and @value = '20.000']" "xpath_element" should exist
     And I should see "2.00000"
index 5236cac..a23acfb 100644 (file)
@@ -36,9 +36,7 @@ Feature: Edit quiz marks with attempts
     And I press "Attempt quiz now"
     And I log out
     And I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
 
   @javascript
   Scenario: Set the max mark for a question.
@@ -80,7 +78,7 @@ Feature: Edit quiz marks with attempts
       | Decimal places in grades | 3 |
       | Decimal places in question grades | 5 |
     And I press "Save and display"
-    And I navigate to "Edit quiz" in current page administration
+    And I am on the "Quiz 1" "mod_quiz > Edit" page
     # Then the field "maxgrade" matches value "20.000" -- with exact match on decimal places.
     Then "//input[@name = 'maxgrade' and @value = '20.000']" "xpath_element" should exist
     And I should see "2.00000"
index e361b2a..d8218f3 100644 (file)
@@ -30,7 +30,7 @@ Feature: Teachers can override the grade for any question
       | TF1      | 1    |
     And I log in as "student1"
     And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
+    And I am on the "Quiz 1" "mod_quiz > View" page
     And I press "Attempt quiz now"
     And I follow "Finish attempt ..."
     And I press "Submit all and finish"
@@ -40,10 +40,7 @@ Feature: Teachers can override the grade for any question
   @javascript @_switch_window @_bug_phantomjs
   Scenario: Validating the marking of an essay question attempt.
     When I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I follow "Attempts: 1"
-    And I follow "Review attempt"
+    And I am on the "Quiz 1 > student1 > Attempt 1" "mod_quiz > Attempt review" page
     And I follow "Make comment or override mark"
     And I switch to "commentquestion" window
     And I set the field "Mark" to "25"
@@ -64,10 +61,7 @@ Feature: Teachers can override the grade for any question
     And I follow "Manage private files"
     And I upload "mod/quiz/tests/fixtures/moodle_logo.jpg" file to "Files" filemanager
     And I click on "Save changes" "button"
-    And I am on "Course 1" course homepage
-    And I follow "Quiz 1"
-    And I follow "Attempts: 1"
-    And I follow "Review attempt"
+    And I am on the "Quiz 1 > student1 > Attempt 1" "mod_quiz > Attempt review" page
     And I follow "Make comment or override mark"
     And I switch to "commentquestion" window
     And I set the field "Comment" to "Administrator's comment"
index a410d0e..d8c8733 100644 (file)
@@ -35,9 +35,7 @@ Feature: Preview a quiz as a teacher
 
   @javascript
   Scenario: Preview a quiz
-    When I log in as "teacher"
-    And I am on "Course 1" course homepage
-    When I follow "Quiz 1"
+    When I am on the "Quiz 1" "mod_quiz > View" page logged in as "teacher"
     And I follow "Review"
     Then I should see "25.00 out of 100.00"
     And I follow "Finish review"
index b90b775..c20ac9f 100644 (file)
@@ -46,10 +46,7 @@ Feature: Quiz group override
     Given the following "permission overrides" exist:
       | capability                  | permission | role           | contextlevel | reference |
       | moodle/site:accessallgroups | Prevent    | editingteacher | Course       | C1        |
-    When I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz"
-    And I navigate to "Group overrides" in current page administration
+    When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "teacher1"
     And I press "Add group override"
     Then the "Override group" select box should contain "Group 1"
     And the "Override group" select box should not contain "Group 2"
@@ -58,18 +55,12 @@ Feature: Quiz group override
     Given the following "permission overrides" exist:
       | capability                  | permission | role           | contextlevel | reference |
       | moodle/site:accessallgroups | Prevent    | editingteacher | Course       | C1        |
-    When I log in as "teacher3"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz"
-    And I navigate to "Group overrides" in current page administration
+    When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "teacher3"
     Then I should see "No groups you can access."
     And the "Add group override" "button" should be disabled
 
   Scenario: A teacher with accessallgroups permission should see all group overrides
-    Given I log in as "admin"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz"
-    And I navigate to "Group overrides" in current page administration
+    When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "admin"
     And I press "Add group override"
     And I set the following fields to these values:
       | Override group | Group 1 |
@@ -80,10 +71,7 @@ Feature: Quiz group override
       | Attempts allowed | 2       |
     And I press "Save"
     And I log out
-    When I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz"
-    And I navigate to "Group overrides" in current page administration
+    And I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "teacher1"
     Then I should see "Group 1" in the ".generaltable" "css_element"
     And I should see "Group 2" in the ".generaltable" "css_element"
 
@@ -91,10 +79,7 @@ Feature: Quiz group override
     Given the following "permission overrides" exist:
       | capability                  | permission | role           | contextlevel | reference |
       | moodle/site:accessallgroups | Prevent    | editingteacher | Course       | C1        |
-    And I log in as "admin"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz"
-    And I navigate to "Group overrides" in current page administration
+    When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "admin"
     And I press "Add group override"
     And I set the following fields to these values:
       | Override group | Group 1 |
@@ -105,9 +90,6 @@ Feature: Quiz group override
       | Attempts allowed | 2 |
     And I press "Save"
     And I log out
-    When I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz"
-    And I navigate to "Group overrides" in current page administration
+    When I am on the "Test quiz" "mod_quiz > Group overrides" page logged in as "teacher1"
     Then I should see "Group 1" in the ".generaltable" "css_element"
     And I should not see "Group 2" in the ".generaltable" "css_element"
index 52b1f21..4bd2b0b 100644 (file)
@@ -37,9 +37,7 @@ Feature: Quiz with no calendar capabilites
       | id_timeclose_month | 2 |
       | id_timeclose_year | 2017 |
     And I log out
-    When I log in as "teacher1"
-    And I am on "Course 1" course homepage with editing mode on
-    And I follow "Test quiz name"
+    When I am on the "Test quiz name" "mod_quiz > View" page logged in as "teacher1"
     And I navigate to "Edit settings" in current page administration
     And I set the following fields to these values:
       | id_timeopen_year | 2018 |
index 1337e97..ade7c76 100644 (file)
@@ -41,55 +41,43 @@ Feature: Quiz reset
     And I am on "Course 1" course homepage
     And I navigate to "Reset" in current page administration
     And I set the following fields to these values:
-        | Delete all quiz attempts | 1  |
+        | Delete all quiz attempts | 1 |
     And I press "Reset course"
     And I press "Continue"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz name"
-    And I navigate to "Results" in current page administration
+    And I am on the "Test quiz name" "mod_quiz > Grades report" page
     Then I should see "Attempts: 0"
 
   @javascript
   Scenario: Use course reset to remove user overrides.
-    When I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz name"
-    And I navigate to "User overrides" in current page administration
+    When I am on the "Test quiz name" "mod_quiz > User overrides" page logged in as "teacher1"
     And I press "Add user override"
     And I set the following fields to these values:
-        | Override user    | Student1  |
-        | Attempts allowed | 2 |
+        | Override user    | Student1 |
+        | Attempts allowed | 2        |
     And I press "Save"
     And I should see "Sam1 Student1"
     And I am on "Course 1" course homepage
     And I navigate to "Reset" in current page administration
     And I set the following fields to these values:
-        | Delete all user overrides | 1  |
+        | Delete all user overrides | 1 |
     And I press "Reset course"
     And I press "Continue"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz name"
-    And I navigate to "User overrides" in current page administration
+    And I am on the "Test quiz name" "mod_quiz > User overrides" page
     Then I should not see "Sam1 Student1"
 
   Scenario: Use course reset to remove group overrides.
-    When I log in as "teacher1"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz name"
-    And I navigate to "Group overrides" in current page administration
+    When I am on the "Test quiz name" "mod_quiz > Group overrides" page logged in as "teacher1"
     And I press "Add group override"
     And I set the following fields to these values:
-        | Override group    | Group 1  |
-        | Attempts allowed | 2 |
+        | Override group   | Group 1 |
+        | Attempts allowed | 2       |
     And I press "Save"
     And I should see "Group 1"
     And I am on "Course 1" course homepage
     And I navigate to "Reset" in current page administration
     And I set the following fields to these values:
-        | Delete all group overrides | 1  |
+        | Delete all group overrides | 1 |
     And I press "Reset course"
     And I press "Continue"
-    And I am on "Course 1" course homepage
-    And I follow "Test quiz name"
-    And I navigate to "Group overrides" in current page administration
+    And I am on the "Test quiz name" "mod_quiz > Group overrides" page
     Then I should not see "Group 1"
index 4c262dd..1ba7628 100644 (file)
@@ -67,7 +67,7 @@
         <testsuite name="core_cohort_testsuite">
             <directory suffix="_test.php">cohort/tests</directory>
         </testsuite>
-        <testsuite name="core_grade_testsuite">
+        <testsuite name="core_grades_testsuite">
             <directory suffix="_test.php">lib/grade/tests</directory>
             <directory suffix="_test.php">grade/tests</directory>
             <directory suffix="_test.php">grade/grading/tests</directory>
index 1b45eb1..1713cad 100644 (file)
@@ -143,8 +143,13 @@ class question_category_list_item extends list_item {
         $questionbankurl = new moodle_url('/question/edit.php', $this->parentlist->pageurl->params());
         $questionbankurl->param('cat', $category->id . ',' . $category->contextid);
         $item = '';
-        $text = format_string($category->name, true, ['context' => $this->parentlist->context])
-                . ' (' . $category->questioncount . ')';
+        $text = format_string($category->name, true, ['context' => $this->parentlist->context]);
+        if ($category->idnumber !== null && $category->idnumber !== '') {
+            $text .= ' ' . html_writer::span(
+                    html_writer::span(get_string('idnumber', 'question'), 'accesshide') .
+                    ' ' . $category->idnumber, 'badge badge-primary');
+        }
+        $text .= ' (' . $category->questioncount . ')';
         $item .= html_writer::tag('b', html_writer::link($questionbankurl, $text,
                         ['title' => $editqestions]) . ' ');
         $item .= format_text($category->info, $category->infoformat,
index 9994b9f..58f892f 100644 (file)
@@ -34,7 +34,7 @@ namespace core_question\bank;
 
 abstract class column_base {
     /**
-     * @var question_bank_view
+     * @var view $qbank the question bank view we are helping to render.
      */
     protected $qbank;
 
@@ -43,7 +43,7 @@ abstract class column_base {
 
     /**
      * Constructor.
-     * @param $qbank the question_bank_view we are helping to render.
+     * @param view $qbank the question bank view we are helping to render.
      */
     public function __construct(view $qbank) {
         $this->qbank = $qbank;
@@ -103,8 +103,6 @@ abstract class column_base {
 
     /**
      * Title for this column. Not used if is_sortable returns an array.
-     * @param object $question the row from the $question table, augmented with extra information.
-     * @param string $rowclasses CSS class names that should be applied to this row of output.
      */
     protected abstract function get_title();
 
@@ -118,10 +116,10 @@ abstract class column_base {
 
     /**
      * Get a link that changes the sort order, and indicates the current sort state.
-     * @param $name internal name used for this type of sorting.
-     * @param $currentsort the current sort order -1, 0, 1 for descending, none, ascending.
-     * @param $title the link text.
-     * @param $defaultreverse whether the default sort order for this column is descending, rather than ascending.
+     * @param string $sort the column to sort on.
+     * @param string $title the link text.
+     * @param string $tip the link tool-tip text. If empty, defaults to title.
+     * @param bool $defaultreverse whether the default sort order for this column is descending, rather than ascending.
      * @return string HTML fragment.
      */
     protected function make_sort_link($sort, $title, $tip, $defaultreverse = false) {
@@ -149,7 +147,7 @@ abstract class column_base {
 
     /**
      * Get an icon representing the corrent sort state.
-     * @param $reverse sort is descending, not ascending.
+     * @param bool $reverse sort is descending, not ascending.
      * @return string HTML image tag.
      */
     protected function get_sort_icon($reverse) {
@@ -175,8 +173,8 @@ abstract class column_base {
     /**
      * Output the opening column tag.  If it is set as heading, it will use <th> tag instead of <td>
      *
-     * @param stdClass $question
-     * @param array $rowclasses
+     * @param \stdClass $question
+     * @param string $rowclasses
      */
     protected function display_start($question, $rowclasses) {
         $tag = 'td';
@@ -198,9 +196,10 @@ abstract class column_base {
     }
 
     /**
-     * @param object $question the row from the $question table, augmented with extra information.
-     * @return string internal name for this column. Used as a CSS class name,
-     *     and to store information about the current sort. Must match PARAM_ALPHA.
+     * Get the internal name for this column. Used as a CSS class name,
+     * and to store information about the current sort. Must match PARAM_ALPHA.
+     *
+     * @return string column name.
      */
     public abstract function get_name();
 
@@ -258,6 +257,42 @@ abstract class column_base {
         return array();
     }
 
+    /**
+     * If this column needs extra data (e.g. tags) then load that here.
+     *
+     * The extra data should be added to the question object in the array.
+     * Probably a good idea to check that another column has not already
+     * loaded the data you want.
+     *
+     * @param \stdClass[] $questions the questions that will be displayed.
+     */
+    public function load_additional_data(array $questions) {
+    }
+
+    /**
+     * Load the tags for each question.
+     *
+     * Helper that can be used from {@link load_additional_data()};
+     *
+     * @param array $questions
+     */
+    public function load_question_tags(array $questions) {
+        $firstquestion = reset($questions);
+        if (isset($firstquestion->tags)) {
+            // Looks like tags are already loaded, so don't do it again.
+            return;
+        }
+
+        // Load the tags.
+        $tagdata = \core_tag_tag::get_items_tags('core_question', 'question',
+                array_keys($questions));
+
+        // Add them to the question objects.
+        foreach ($tagdata as $questionid => $tags) {
+            $questions[$questionid]->tags = $tags;
+        }
+    }
+
     /**
      * Can this column be sorted on? You can return either:
      *  + false for no (the default),
@@ -265,7 +300,7 @@ abstract class column_base {
      *  + an array of subnames to sort on as follows
      *  return array(
      *      'firstname' => array('field' => 'uc.firstname', 'title' => get_string('firstname')),
-     *      'lastname' => array('field' => 'uc.lastname', 'field' => get_string('lastname')),
+     *      'lastname' => array('field' => 'uc.lastname', 'title' => get_string('lastname')),
      *  );
      * As well as field, and field, you can also add 'revers' => 1 if you want the default sort
      * order to be DESC.
@@ -278,7 +313,6 @@ abstract class column_base {
     /**
      * Helper method for building sort clauses.
      * @param bool $reverse whether the normal direction should be reversed.
-     * @param string $normaldir 'ASC' or 'DESC'
      * @return string 'ASC' or 'DESC'
      */
     protected function sortorder($reverse) {
@@ -290,8 +324,8 @@ abstract class column_base {
     }
 
     /**
-     * @param $reverse Whether to sort in the reverse of the default sort order.
-     * @param $subsort if is_sortable returns an array of subnames, then this will be
+     * @param bool $reverse Whether to sort in the reverse of the default sort order.
+     * @param string $subsort if is_sortable returns an array of subnames, then this will be
      *      one of those. Otherwise will be empty.
      * @return string some SQL to go in the order by clause.
      */
@@ -299,14 +333,14 @@ abstract class column_base {
         $sortable = $this->is_sortable();
         if (is_array($sortable)) {
             if (array_key_exists($subsort, $sortable)) {
-                return $sortable[$subsort]['field'] . $this->sortorder($reverse, !empty($sortable[$subsort]['reverse']));
+                return $sortable[$subsort]['field'] . $this->sortorder($reverse);
             } else {
-                throw new coding_exception('Unexpected $subsort type: ' . $subsort);
+                throw new \coding_exception('Unexpected $subsort type: ' . $subsort);
             }
         } else if ($sortable) {
             return $sortable . $this->sortorder($reverse);
         } else {
-            throw new coding_exception('sort_expression called on a non-sortable column.');
+            throw new \coding_exception('sort_expression called on a non-sortable column.');
         }
     }
 }
diff --git a/question/classes/bank/question_name_idnumber_tags_column.php b/question/classes/bank/question_name_idnumber_tags_column.php
new file mode 100644 (file)
index 0000000..b9dc3f8
--- /dev/null
@@ -0,0 +1,89 @@
+<?php
+// This file is part of Moodle - http://moodle.org/
+//
+// Moodle is free software: you can redistribute it and/or modify
+// it under the terms of the GNU General Public License as published by
+// the Free Software Foundation, either version 3 of the License, or
+// (at your option) any later version.
+//
+// Moodle is distributed in the hope that it will be useful,
+// but WITHOUT ANY WARRANTY; without even the implied warranty of
+// MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
+// GNU General Public License for more details.
+//
+// You should have received a copy of the GNU General Public License
+// along with Moodle.  If not, see <http://www.gnu.org/licenses/>.
+
+/**
+ * A question bank column showing the question name with idnumber and tags.
+ *
+ * @package   core_question
+ * @copyright 2019 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+
+namespace core_question\bank;
+defined('MOODLE_INTERNAL') || die();
+
+
+/**
+ * A question bank column showing the question name with idnumber and tags.
+ *
+ * @copyright 2019 The Open University
+ * @license   http://www.gnu.org/copyleft/gpl.html GNU GPL v3 or later
+ */
+class question_name_idnumber_tags_column extends question_name_column {
+    public function get_name() {
+        return 'qnameidnumbertags';
+    }
+
+    protected function display_content($question, $rowclasses) {
+        global $OUTPUT;
+
+        $layoutclasses = 'd-inline-flex flex-nowrap overflow-hidden w-100';
+        $labelfor = $this->label_for($question);
+        if ($labelfor) {
+            echo '<label for="' . $labelfor . '" class="' . $layoutclasses . '">';
+            $closetag = '</label>';
+        } else {
+            echo '<span class="' . $layoutclasses . '">';
+            $closetag = '</span>';
+        }
+
+        // Question name.
+        echo \html_writer::span(format_string($question->name), 'questionname flex-grow-1 flex-shrink-1 text-truncate');
+
+        // Question idnumber.
+        if ($question->idnumber !== null && $question->idnumber !== '') {
+            echo ' ' . \html_writer::span(
+                            \html_writer::span(get_string('idnumber', 'question'), 'accesshide') . ' ' .
+                            \html_writer::span($question->idnumber, 'badge badge-primary'), 'ml-1');
+        }
+
+        // Question tags.
+        if (!empty($question->tags)) {
+            $tags = \core_tag_tag::get_item_tags('core_question', 'question', $question->id);
+            echo $OUTPUT->tag_list($tags, null, 'd-inline flex-shrink-1 text-truncate ml-1', 0, null, true);
+        }
+
+        echo $closetag; // Computed above to ensure it matches.
+    }
+
+    public function get_required_fields() {
+        $fields = parent::get_required_fields();
+        $fields[] = 'q.idnumber';
+        return $fields;
+    }
+
+    public function is_sortable() {
+        return [
+            'name' => ['field' => 'q.name', 'title' => get_string('questionname', 'question')],
+            'lastname' => ['field' => 'q.idnumber', 'title' => get_string('idnumber', 'question')],
+        ];
+    }
+
+    public function load_additional_data(array $questions) {
+        parent::load_additional_data($questions);
+        parent::load_question_tags($questions);
+    }
+}
index 12b781f..850ce2b 100644 (file)
@@ -17,6 +17,8 @@
 
 namespace core_question\bank;
 
+use core_question\bank\search\condition;
+
 /**
  * Functions used to show question editing interface
  *
@@ -50,21 +52,80 @@ namespace core_question\bank;
 class view {
     const MAX_SORTS = 3;
 
+    /**
+     * @var \moodle_url base URL for the current page. Used as the
+     * basis for making URLs for actions that reload the page.
+     */
     protected $baseurl;
+
+    /**
+     * @var \moodle_url used as a basis for URLs that edit a question.
+     */
     protected $editquestionurl;
-    protected $quizorcourseid;
+
+    /**
+     * @var \question_edit_contexts
+     */
     protected $contexts;
+
+    /**
+     * @var object|\cm_info|null if we are in a module context, the cm.
+     */
     protected $cm;
+
+    /**
+     * @var object the course we are within.
+     */
     protected $course;
+
+    /**
+     * @var \question_bank_column_base[] these are all the 'columns' that are
+     * part of the display. Array keys are the class name.
+     */
+    protected $requiredcolumns;
+
+    /**
+     * @var \question_bank_column_base[] these are the 'columns' that are
+     * actually displayed as a column, in order. Array keys are the class name.
+     */
     protected $visiblecolumns;
+
+    /**
+     * @var \question_bank_column_base[] these are the 'columns' that are
+     * actually displayed as an additional row (e.g. question text), in order.
+     * Array keys are the class name.
+     */
     protected $extrarows;
-    protected $requiredcolumns;
+
+    /**
+     * @var array list of column class names for which columns to sort on.
+     */
     protected $sort;
+
+    /**
+     * @var int|null id of the a question to highlight in the list (if present).
+     */
     protected $lastchangedid;
+
+    /**
+     * @var string SQL to count the number of questions matching the current
+     * search conditions.
+     */
     protected $countsql;
+
+    /**
+     * @var string SQL to actually load the question data to display.
+     */
     protected $loadsql;
+
+    /**
+     * @var array params used by $countsql and $loadsql (which currently must be the same).
+     */
     protected $sqlparams;
-    /** @var array of \core_question\bank\search\condition objects. */
+
+    /**
+     * @var condition[] search conditions.
+     */
     protected $searchconditions = array();
 
     /**
@@ -75,19 +136,11 @@ class view {
      * @param object $cm (optional) activity settings.
      */
     public function __construct($contexts, $pageurl, $course, $cm = null) {
-        global $CFG, $PAGE;
-
         $this->contexts = $contexts;
         $this->baseurl = $pageurl;
         $this->course = $course;
         $this->cm = $cm;
 
-        if (!empty($cm) && $cm->modname == 'quiz') {
-            $this->quizorcourseid = '&amp;quizid=' . $cm->instance;
-        } else {
-            $this->quizorcourseid = '&amp;courseid=' .$this->course->id;
-        }
-
         // Create the url of the new question page to forward to.
         $returnurl = $pageurl->out_as_local_url(false);
         $this->editquestionurl = new \moodle_url('/question/question.php',
@@ -102,7 +155,7 @@ class view {
 
         $this->init_columns($this->wanted_columns(), $this->heading_column());
         $this->init_sort();
-        $this->init_search_conditions($this->contexts, $this->course, $this->cm);
+        $this->init_search_conditions();
     }
 
     /**
@@ -124,9 +177,9 @@ class view {
 
         if (empty($CFG->questionbankcolumns)) {
             $questionbankcolumns = array('checkbox_column', 'question_type_column',
-                                     'question_name_column', 'tags_action_column', 'edit_action_column',
-                                     'copy_action_column', 'preview_action_column', 'delete_action_column',
-                                     'creator_name_column', 'modifier_name_column');
+                    'question_name_idnumber_tags_column', 'tags_action_column', 'edit_action_column',
+                    'copy_action_column', 'preview_action_column', 'delete_action_column',
+                    'creator_name_column', 'modifier_name_column');
         } else {
              $questionbankcolumns = explode(',', $CFG->questionbankcolumns);
         }
@@ -226,8 +279,10 @@ class view {
 
     /**
      * Deal with a sort name of the form columnname, or colname_subsort by
-     * breaking it up, validating the bits that are presend, and returning them.
+     * breaking it up, validating the bits that are present, and returning them.
      * If there is no subsort, then $subsort is returned as ''.
+     *
+     * @param string $sort the sort parameter to process.
      * @return array array($colname, $subsort).
      */
     protected function parse_subsort($sort) {
@@ -272,7 +327,7 @@ class view {
                 }
             }
             // Deal with subsorts.
-            list($colname, $subsort) = $this->parse_subsort($sort);
+            list($colname) = $this->parse_subsort($sort);
             $this->requiredcolumns[$colname] = $this->get_column_type($colname);
             $this->sort[$sort] = $order;
         }
@@ -296,7 +351,7 @@ class view {
     }
 
     /**
-     * @param $sort a column or column_subsort name.
+     * @param string $sort a column or column_subsort name.
      * @return int the current sort order for this column -1, 0, 1
      */
     public function get_primary_sort_order($sort) {
@@ -311,6 +366,7 @@ class view {
 
     /**
      * Get a URL to redisplay the page with a new sort for the question bank.
+     *
      * @param string $sort the column, or column_subsort to sort on.
      * @param bool $newsortreverse whether to sort in reverse order.
      * @return string The new URL.
@@ -336,7 +392,8 @@ class view {
 
     /**
      * Create the SQL query to retrieve the indicated questions
-     * @param stdClass $category no longer used.
+     *
+     * @param \stdClass $category no longer used.
      * @param bool $recurse no longer used.
      * @param bool $showhidden no longer used.
      * @deprecated since Moodle 2.7 MDL-40313.
@@ -355,8 +412,6 @@ class view {
      * \core_question\bank\search\condition filters.
      */
     protected function build_query() {
-        global $DB;
-
         // Get the required tables and fields.
         $joins = array();
         $fields = array('q.hidden', 'q.category');
@@ -402,12 +457,19 @@ class view {
         return $DB->count_records_sql($this->countsql, $this->sqlparams);
     }
 
+    /**
+     * Load the questions we need to display.
+     *
+     * @param int $page page to display.
+     * @param int $perpage number of questions per page.
+     * @return \moodle_recordset questionid => data about each question.
+     */
     protected function load_page_questions($page, $perpage) {
         global $DB;
         $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, $page * $perpage, $perpage);
-        if (!$questions->valid()) {
-            // No questions on this page. Reset to page 0.
+        if (empty($questions)) {
             $questions->close();
+            // No questions on this page. Reset to page 0.
             $questions = $DB->get_recordset_sql($this->loadsql, $this->sqlparams, 0, $perpage);
         }
         return $questions;
@@ -424,7 +486,7 @@ class view {
     /**
      * Get the URL for duplicating a given question.
      * @param int $questionid the question id.
-     * @return moodle_url the URL.
+     * @return string the URL, HTML-escaped.
      */
     public function copy_question_url($questionid) {
         return $this->editquestionurl->out(true, array('id' => $questionid, 'makecopy' => 1));
@@ -432,7 +494,7 @@ class view {
 
     /**
      * Get the context we are displaying the question bank for.
-     * @return context context object.
+     * @return \context context object.
      */
     public function get_most_specific_context() {
         return $this->contexts->lowest();
@@ -440,8 +502,8 @@ class view {
 
     /**
      * Get the URL to preview a question.
-     * @param stdClass $questiondata the data defining the question.
-     * @return moodle_url the URL.
+     * @param \stdClass $questiondata the data defining the question.
+     * @return \moodle_url the URL.
      */
     public function preview_question_url($questiondata) {
         return question_preview_url($questiondata->id, null, null, null, null,
@@ -458,7 +520,15 @@ class view {
      * deleteselected Deletes the selected questions from the category
      * Other actions:
      * category      Chooses the category
-     * displayoptions Sets display options
+     *
+     * @param string $tabname question bank edit tab name, for permission checking.
+     * @param int $page the page number to show.
+     * @param int $perpage the number of questions per page to show.
+     * @param string $cat 'categoryid,contextid'.
+     * @param int $recurse     Whether to include subcategories.
+     * @param bool $showhidden  whether deleted questions should be displayed.
+     * @param bool $showquestiontext whether the text of each question should be shown in the list. Deprecated.
+     * @param array $tagids current list of selected tags.
      */
     public function display($tabname, $page, $perpage, $cat,
             $recurse, $showhidden, $showquestiontext, $tagids = []) {
@@ -468,7 +538,7 @@ class view {
             return;
         }
         $editcontexts = $this->contexts->having_one_edit_tab_cap($tabname);
-        list($categoryid, $contextid) = explode(',', $cat);
+        list(, $contextid) = explode(',', $cat);
         $catcontext = \context::instance_by_id($contextid);
         $thiscontext = $this->get_most_specific_context();
         // Category selection form.
@@ -521,7 +591,7 @@ class view {
 
     /**
      * prints category information
-     * @param stdClass $category the category row from the database.
+     * @param \stdClass $category the category row from the database.
      * @deprecated since Moodle 2.7 MDL-40313.
      * @see \core_question\bank\search\condition
      * @todo MDL-41978 This will be deleted in Moodle 2.8
@@ -568,7 +638,7 @@ class view {
      */
     protected function display_options($recurse, $showhidden, $showquestiontext) {
         debugging('display_options() is deprecated, please use display_options_form instead.', DEBUG_DEVELOPER);
-        return $this->display_options_form($showquestiontext);
+        $this->display_options_form($showquestiontext);
     }
 
     /**
@@ -594,7 +664,7 @@ class view {
     /**
      * Display the form with options for which questions are displayed and how they are displayed.
      * @param bool $showquestiontext Display the text of the question within the list.
-     * @param string $path path to the script displaying this page.
+     * @param string $scriptpath path to the script displaying this page.
      * @param bool $showtextoption whether to include the 'Show question text' checkbox.
      */
     protected function display_options_form($showquestiontext, $scriptpath = '/question/edit.php',
@@ -619,7 +689,7 @@ class view {
         echo \html_writer::input_hidden_params($this->baseurl, $excludes);
 
         foreach ($this->searchconditions as $searchcondition) {
-            echo $searchcondition->display_options($this);
+            echo $searchcondition->display_options();
         }
         if ($showtextoption) {
             $this->display_showtext_checkbox($showquestiontext);
@@ -639,7 +709,7 @@ class view {
         print_collapsible_region_start('', 'advancedsearch', get_string('advancedsearchoptions', 'question'),
                                                'question_bank_advanced_search');
         foreach ($this->searchconditions as $searchcondition) {
-            echo $searchcondition->display_options_adv($this);
+            echo $searchcondition->display_options_adv();
         }
         print_collapsible_region_end();
     }
@@ -666,7 +736,6 @@ class view {
     }
 
     protected function create_new_question_form($category, $canadd) {
-        global $CFG;
         echo '<div class="createnewquestion">';
         if ($canadd) {
             create_new_question_button($category->id, $this->editquestionurl->params(),
@@ -681,20 +750,20 @@ class view {
      * Prints the table of questions in a category with interactions
      *
      * @param array      $contexts    Not used!
-     * @param moodle_url $pageurl     The URL to reload this page.
+     * @param \moodle_url $pageurl     The URL to reload this page.
      * @param string     $categoryandcontext 'categoryID,contextID'.
-     * @param stdClass   $cm          Not used!
-     * @param bool       $recurse     Whether to include subcategories.
+     * @param \stdClass  $cm          Not used!
+     * @param int        $recurse     Whether to include subcategories.
      * @param int        $page        The number of the page to be displayed
      * @param int        $perpage     Number of questions to show per page
-     * @param bool       $showhidden  whether deleted questions should be displayed.
-     * @param bool       $showquestiontext whether the text of each question should be shown in the list. Deprecated.
+     * @param bool       $showhidden  Not used! This is now controlled in a different way.
+     * @param bool       $showquestiontext Not used! This is now controlled in a different way.
      * @param array      $addcontexts contexts where the user is allowed to add new questions.
      */
     protected function display_question_list($contexts, $pageurl, $categoryandcontext,
             $cm = null, $recurse=1, $page=0, $perpage=100, $showhidden=false,
             $showquestiontext = false, $addcontexts = array()) {
-        global $CFG, $DB, $OUTPUT, $PAGE;
+        global $OUTPUT;
 
         // This function can be moderately slow with large question counts and may time out.
         // We probably do not want to raise it to unlimited, so randomly picking 5 minutes.
@@ -715,11 +784,18 @@ class view {
         if ($totalnumber == 0) {
             return;
         }
-        $questions = $this->load_page_questions($page, $perpage);
+        $questionsrs = $this->load_page_questions($page, $perpage);
+        $questions = [];
+        foreach ($questionsrs as $question) {
+            $questions[$question->id] = $question;
+        }
+        $questionsrs->close();
+        foreach ($this->requiredcolumns as $name => $column) {
+            $column->load_additional_data($questions);
+        }
 
         echo '<div class="categorypagingbarcontainer">';
-        $pageingurl = new \moodle_url('edit.php');
-        $r = $pageingurl->params($pageurl->params());
+        $pageingurl = new \moodle_url('edit.php', $pageurl->params());
         $pagingbar = new \paging_bar($totalnumber, $page, $perpage, $pageingurl);
         $pagingbar->pagevar = 'qpage';
         echo $OUTPUT->render($pagingbar);
@@ -737,7 +813,6 @@ class view {
             $this->print_table_row($question, $rowcount);
             $rowcount += 1;
         }
-        $questions->close();
         $this->end_table();
         echo "</div>\n";
 
@@ -771,8 +846,8 @@ class view {
      * Display the controls at the bottom of the list of questions.
      * @param int      $totalnumber Total number of questions that might be shown (if it was not for paging).
      * @param bool     $recurse     Whether to include subcategories.
-     * @param stdClass $category    The question_category row from the database.
-     * @param context  $catcontext  The context of the category being displayed.
+     * @param \stdClass $category    The question_category row from the database.
+     * @param \context  $catcontext  The context of the category being displayed.
      * @param array    $addcontexts contexts where the user is allowed to add new questions.
      */
     protected function display_bottom_controls($totalnumber, $recurse, $category, \context $catcontext, array $addcontexts) {
@@ -865,7 +940,7 @@ class view {
     }
 
     public funct